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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { sprintf } from "@probo/helpers";
|
|
||||||
import { useTranslate } from "@probo/i18n";
|
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 { useFragment, useMutation } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
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 type { CompliancePageSlackSectionFragment$key } from "#/__generated__/core/CompliancePageSlackSectionFragment.graphql";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
|
import { CompliancePageSlackConnectionCard } from "./CompliancePageSlackConnectionCard";
|
||||||
|
|
||||||
const fragment = graphql`
|
const fragment = graphql`
|
||||||
fragment CompliancePageSlackSectionFragment on Organization {
|
fragment CompliancePageSlackSectionFragment on Organization {
|
||||||
canConnectSlack: permission(action: "core:connector:initiate")
|
canConnectSlack: permission(action: "core:connector:initiate")
|
||||||
@@ -31,9 +32,7 @@ const fragment = graphql`
|
|||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
channel
|
...CompliancePageSlackConnectionCardFragment
|
||||||
createdAt
|
|
||||||
canDelete: permission(action: "core:connector:delete")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,13 +54,14 @@ export function CompliancePageSlackSection(props: { fragmentRef: CompliancePageS
|
|||||||
const { fragmentRef } = props;
|
const { fragmentRef } = props;
|
||||||
|
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { __, dateTimeFormat } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
|
||||||
const organization = useFragment<CompliancePageSlackSectionFragment$key>(fragment, fragmentRef);
|
const organization = useFragment<CompliancePageSlackSectionFragment$key>(fragment, fragmentRef);
|
||||||
const [deleteSlackConnection] = useMutation<CompliancePageSlackSectionDeleteMutation>(deleteMutation);
|
const [deleteSlackConnection] = useMutation<CompliancePageSlackSectionDeleteMutation>(deleteMutation);
|
||||||
|
|
||||||
const connectionId = organization.slackConnections.__id;
|
const connectionId = organization.slackConnections.__id;
|
||||||
|
const scopes = organization.slackOAuth2Scopes;
|
||||||
|
|
||||||
const handleDisconnect = (slackConnectionId: string) => {
|
const handleDisconnect = (slackConnectionId: string) => {
|
||||||
confirm(
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-base font-medium">{__("Integrations")}</h2>
|
<h2 className="text-base font-medium">{__("Integrations")}</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{organization.slackConnections.edges.map(({ node: slackConnection }) => (
|
{organization.slackConnections.edges.map(({ node: slackConnection }) => (
|
||||||
<Card
|
<CompliancePageSlackConnectionCard
|
||||||
key={slackConnection.id}
|
key={slackConnection.id}
|
||||||
padded
|
slackConnectionKey={slackConnection}
|
||||||
className="flex items-center gap-3"
|
canConnect={organization.canConnectSlack}
|
||||||
>
|
buildConnectionUrl={connectorId => buildConnectionUrl(connectorId)}
|
||||||
<div className="h-10 w-10 flex items-center justify-center bg-subtle rounded">
|
onDisconnect={handleDisconnect}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
{organization.canConnectSlack && organization.slackConnections.edges.length === 0 && (
|
{organization.canConnectSlack && organization.slackConnections.edges.length === 0 && (
|
||||||
<Card
|
<Card
|
||||||
@@ -145,7 +120,7 @@ export function CompliancePageSlackSection(props: { fragmentRef: CompliancePageS
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" asChild>
|
<Button variant="secondary" asChild>
|
||||||
<a href={getSlackConnectionUrl(organizationId, organization.slackOAuth2Scopes)}>
|
<a href={buildConnectionUrl()}>
|
||||||
{__("Connect")}
|
{__("Connect")}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</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 baseUrl = import.meta.env.VITE_API_URL || window.location.origin;
|
||||||
const url = new URL("/api/console/v1/connectors/initiate", baseUrl);
|
const url = new URL("/api/console/v1/connectors/initiate", baseUrl);
|
||||||
url.searchParams.append("organization_id", organizationId);
|
url.searchParams.append("organization_id", organizationId);
|
||||||
@@ -164,6 +143,9 @@ function getSlackConnectionUrl(organizationId: string, scopes: readonly string[]
|
|||||||
for (const scope of scopes) {
|
for (const scope of scopes) {
|
||||||
url.searchParams.append("scope", scope);
|
url.searchParams.append("scope", scope);
|
||||||
}
|
}
|
||||||
|
if (connectorId) {
|
||||||
|
url.searchParams.append("connector_id", connectorId);
|
||||||
|
}
|
||||||
const redirectUrl = `/organizations/${organizationId}/compliance-page`;
|
const redirectUrl = `/organizations/${organizationId}/compliance-page`;
|
||||||
url.searchParams.append("continue", redirectUrl);
|
url.searchParams.append("continue", redirectUrl);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
|
|||||||
Reference in New Issue
Block a user