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:
Sacha Al Himdani
2026-07-01 17:19:25 +02:00
parent a651d56c8c
commit 2fb06d742e
2 changed files with 134 additions and 44 deletions

View File

@@ -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>
);
}

View File

@@ -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();