Fix compliance page treating any Slack connector as connected
The compliance page rendered a Slack connector as "Connected" whenever any SLACK connector existed for the org — including one created for an access review with no channel configured — and only offered Disconnect, which deletes the shared connector row. Distinguish channel-configured connections from unconfigured ones and let users (re)connect in place to pick a channel without first disconnecting, reusing the existing reconnect-with-union-scopes flow via connector_id. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, Button, Card, Slack } from "@probo/ui";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageSlackConnectionCardFragment$key } from "#/__generated__/core/CompliancePageSlackConnectionCardFragment.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment CompliancePageSlackConnectionCardFragment on SlackConnection {
|
||||
id
|
||||
channel
|
||||
channelId
|
||||
createdAt
|
||||
canDelete: permission(action: "core:connector:delete")
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
slackConnectionKey: CompliancePageSlackConnectionCardFragment$key;
|
||||
canConnect: boolean;
|
||||
buildConnectionUrl: (connectorId: string) => string;
|
||||
onDisconnect: (slackConnectionId: string) => void;
|
||||
}
|
||||
|
||||
export function CompliancePageSlackConnectionCard(props: Props) {
|
||||
const { slackConnectionKey, canConnect, buildConnectionUrl, onDisconnect } = props;
|
||||
const { __, dateTimeFormat } = useTranslate();
|
||||
|
||||
const slackConnection = useFragment(fragment, slackConnectionKey);
|
||||
|
||||
// A channel is only set once the incoming-webhook scope is granted, so its
|
||||
// absence means the connector isn't usable for the compliance page yet.
|
||||
const isConfigured = Boolean(slackConnection.channelId);
|
||||
|
||||
return (
|
||||
<Card padded className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 flex items-center justify-center bg-subtle rounded">
|
||||
<Slack className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="mr-auto">
|
||||
<h3 className="text-base font-semibold">Slack</h3>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{isConfigured
|
||||
? (
|
||||
<>
|
||||
{sprintf(
|
||||
__("Connected on %s"),
|
||||
dateTimeFormat(slackConnection.createdAt),
|
||||
)}
|
||||
{slackConnection.channel && (
|
||||
<>
|
||||
{" • "}
|
||||
{sprintf(__("Channel: %s"), slackConnection.channel)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
__("Slack is connected, but no channel is set up for the compliance page yet.")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConfigured
|
||||
? (
|
||||
<Badge variant="success" size="md">
|
||||
{__("Connected")}
|
||||
</Badge>
|
||||
)
|
||||
: (
|
||||
<Badge variant="warning" size="md">
|
||||
{__("Channel not configured")}
|
||||
</Badge>
|
||||
)}
|
||||
{canConnect && (
|
||||
<Button variant="secondary" asChild>
|
||||
<a href={buildConnectionUrl(slackConnection.id)}>
|
||||
{isConfigured ? __("Change channel") : __("Connect")}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{slackConnection.canDelete && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onDisconnect(slackConnection.id)}
|
||||
>
|
||||
{__("Disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,8 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, Button, Card, Slack, useConfirm } from "@probo/ui";
|
||||
import { Button, Card, Slack, useConfirm } from "@probo/ui";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
@@ -22,6 +21,8 @@ import type { CompliancePageSlackSectionDeleteMutation } from "#/__generated__/c
|
||||
import type { CompliancePageSlackSectionFragment$key } from "#/__generated__/core/CompliancePageSlackSectionFragment.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { CompliancePageSlackConnectionCard } from "./CompliancePageSlackConnectionCard";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment CompliancePageSlackSectionFragment on Organization {
|
||||
canConnectSlack: permission(action: "core:connector:initiate")
|
||||
@@ -31,9 +32,7 @@ const fragment = graphql`
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
channel
|
||||
createdAt
|
||||
canDelete: permission(action: "core:connector:delete")
|
||||
...CompliancePageSlackConnectionCardFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,13 +54,14 @@ export function CompliancePageSlackSection(props: { fragmentRef: CompliancePageS
|
||||
const { fragmentRef } = props;
|
||||
|
||||
const organizationId = useOrganizationId();
|
||||
const { __, dateTimeFormat } = useTranslate();
|
||||
const { __ } = useTranslate();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const organization = useFragment<CompliancePageSlackSectionFragment$key>(fragment, fragmentRef);
|
||||
const [deleteSlackConnection] = useMutation<CompliancePageSlackSectionDeleteMutation>(deleteMutation);
|
||||
|
||||
const connectionId = organization.slackConnections.__id;
|
||||
const scopes = organization.slackOAuth2Scopes;
|
||||
|
||||
const handleDisconnect = (slackConnectionId: string) => {
|
||||
confirm(
|
||||
@@ -87,48 +87,23 @@ export function CompliancePageSlackSection(props: { fragmentRef: CompliancePageS
|
||||
);
|
||||
};
|
||||
|
||||
// Passing connector_id reconnects in place (union of scopes), letting users
|
||||
// pick a channel without disconnecting first.
|
||||
const buildConnectionUrl = (connectorId?: string) =>
|
||||
getSlackConnectionUrl(organizationId, scopes, connectorId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-base font-medium">{__("Integrations")}</h2>
|
||||
<div className="space-y-2">
|
||||
{organization.slackConnections.edges.map(({ node: slackConnection }) => (
|
||||
<Card
|
||||
<CompliancePageSlackConnectionCard
|
||||
key={slackConnection.id}
|
||||
padded
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="h-10 w-10 flex items-center justify-center bg-subtle rounded">
|
||||
<Slack className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="mr-auto">
|
||||
<h3 className="text-base font-semibold">Slack</h3>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{sprintf(
|
||||
__("Connected on %s"),
|
||||
dateTimeFormat(slackConnection.createdAt),
|
||||
)}
|
||||
{slackConnection.channel && (
|
||||
<>
|
||||
{" • "}
|
||||
{sprintf(__("Channel: %s"), slackConnection.channel)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="success" size="md">
|
||||
{__("Connected")}
|
||||
</Badge>
|
||||
{slackConnection.canDelete && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleDisconnect(slackConnection.id)}
|
||||
>
|
||||
{__("Disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
slackConnectionKey={slackConnection}
|
||||
canConnect={organization.canConnectSlack}
|
||||
buildConnectionUrl={connectorId => buildConnectionUrl(connectorId)}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
))}
|
||||
{organization.canConnectSlack && organization.slackConnections.edges.length === 0 && (
|
||||
<Card
|
||||
@@ -145,7 +120,7 @@ export function CompliancePageSlackSection(props: { fragmentRef: CompliancePageS
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" asChild>
|
||||
<a href={getSlackConnectionUrl(organizationId, organization.slackOAuth2Scopes)}>
|
||||
<a href={buildConnectionUrl()}>
|
||||
{__("Connect")}
|
||||
</a>
|
||||
</Button>
|
||||
@@ -156,7 +131,11 @@ export function CompliancePageSlackSection(props: { fragmentRef: CompliancePageS
|
||||
);
|
||||
}
|
||||
|
||||
function getSlackConnectionUrl(organizationId: string, scopes: readonly string[]): string {
|
||||
function getSlackConnectionUrl(
|
||||
organizationId: string,
|
||||
scopes: readonly string[],
|
||||
connectorId?: string,
|
||||
): string {
|
||||
const baseUrl = import.meta.env.VITE_API_URL || window.location.origin;
|
||||
const url = new URL("/api/console/v1/connectors/initiate", baseUrl);
|
||||
url.searchParams.append("organization_id", organizationId);
|
||||
@@ -164,6 +143,9 @@ function getSlackConnectionUrl(organizationId: string, scopes: readonly string[]
|
||||
for (const scope of scopes) {
|
||||
url.searchParams.append("scope", scope);
|
||||
}
|
||||
if (connectorId) {
|
||||
url.searchParams.append("connector_id", connectorId);
|
||||
}
|
||||
const redirectUrl = `/organizations/${organizationId}/compliance-page`;
|
||||
url.searchParams.append("continue", redirectUrl);
|
||||
return url.toString();
|
||||
|
||||
Reference in New Issue
Block a user