Add ability to disconnect Slack channel from compliance page

Users could connect a Slack channel to their compliance page but had no
way to remove or change the connection afterward. This adds a disconnect
button with a confirmation dialog next to connected Slack channels.

- GraphQL: deleteSlackConnection mutation with resolver
- Frontend: Disconnect button using useMutation with @deleteEdge

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-26 18:04:59 +01:00
parent bc51c910ae
commit 0920690816
3 changed files with 99 additions and 9 deletions

View File

@@ -1,36 +1,76 @@
import { sprintf } from "@probo/helpers"; import { sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { Badge, Button, Card, Slack } from "@probo/ui"; import { Badge, Button, Card, Slack, useConfirm } from "@probo/ui";
import { useFragment } from "react-relay"; import { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import type { CompliancePageSlackSectionDeleteMutation } from "#/__generated__/core/CompliancePageSlackSectionDeleteMutation.graphql";
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";
const fragment = graphql` const fragment = graphql`
fragment CompliancePageSlackSectionFragment on Organization { fragment CompliancePageSlackSectionFragment on Organization {
compliancePage: trustCenter { canConnectSlack: permission(action: "core:connector:initiate")
canUpdate: permission(action: "core:trust-center:update")
}
slackConnections(first: 100) { slackConnections(first: 100) {
__id
edges { edges {
node { node {
id id
channel channel
createdAt createdAt
canDelete: permission(action: "core:connector:delete")
} }
} }
} }
} }
`; `;
const deleteMutation = graphql`
mutation CompliancePageSlackSectionDeleteMutation(
$input: DeleteSlackConnectionInput!
$connections: [ID!]!
) {
deleteSlackConnection(input: $input) {
deletedSlackConnectionId @deleteEdge(connections: $connections)
}
}
`;
export function CompliancePageSlackSection(props: { fragmentRef: CompliancePageSlackSectionFragment$key }) { export function CompliancePageSlackSection(props: { fragmentRef: CompliancePageSlackSectionFragment$key }) {
const { fragmentRef } = props; const { fragmentRef } = props;
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const { __, dateTimeFormat } = useTranslate(); const { __, dateTimeFormat } = useTranslate();
const confirm = useConfirm();
const organization = useFragment<CompliancePageSlackSectionFragment$key>(fragment, fragmentRef); const organization = useFragment<CompliancePageSlackSectionFragment$key>(fragment, fragmentRef);
const [deleteSlackConnection] = useMutation<CompliancePageSlackSectionDeleteMutation>(deleteMutation);
const connectionId = organization.slackConnections.__id;
const handleDisconnect = (slackConnectionId: string) => {
confirm(
() =>
new Promise<void>((resolve, reject) => {
deleteSlackConnection({
variables: {
connections: [connectionId],
input: {
slackConnectionId,
},
},
onCompleted: () => resolve(),
onError: error => reject(error),
});
}),
{
title: __("Disconnect Slack"),
message: __("Are you sure you want to disconnect this Slack channel? This action cannot be undone."),
label: __("Disconnect"),
variant: "danger",
},
);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -60,14 +100,22 @@ export function CompliancePageSlackSection(props: { fragmentRef: CompliancePageS
)} )}
</p> </p>
</div> </div>
<div> <div className="flex items-center gap-2">
<Badge variant="success" size="md"> <Badge variant="success" size="md">
{__("Connected")} {__("Connected")}
</Badge> </Badge>
{slackConnection.canDelete && (
<Button
variant="secondary"
onClick={() => handleDisconnect(slackConnection.id)}
>
{__("Disconnect")}
</Button>
)}
</div> </div>
</Card> </Card>
))} ))}
{organization.compliancePage?.canUpdate && organization.slackConnections.edges.length === 0 && ( {organization.canConnectSlack && organization.slackConnections.edges.length === 0 && (
<Card <Card
padded padded
className="flex items-center gap-3" className="flex items-center gap-3"
@@ -100,4 +148,4 @@ function getSlackConnectionUrl(organizationId: string): string {
url.searchParams.append("continue", redirectUrl); url.searchParams.append("continue", redirectUrl);
const finalUrl = url.toString(); const finalUrl = url.toString();
return finalUrl; return finalUrl;
}; }

View File

@@ -2077,12 +2077,14 @@ type Organization implements Node {
permission(action: String!): Boolean! @goField(forceResolver: true) permission(action: String!): Boolean! @goField(forceResolver: true)
} }
type SlackConnection { type SlackConnection implements Node {
id: ID! id: ID!
channel: String channel: String
channelId: String channelId: String
createdAt: Datetime! createdAt: Datetime!
updatedAt: Datetime! updatedAt: Datetime!
permission(action: String!): Boolean! @goField(forceResolver: true)
} }
type SlackConnectionConnection { type SlackConnectionConnection {
@@ -3944,6 +3946,10 @@ type Mutation {
deleteCustomDomain( deleteCustomDomain(
input: DeleteCustomDomainInput! input: DeleteCustomDomainInput!
): DeleteCustomDomainPayload! ): DeleteCustomDomainPayload!
# Slack Connection mutations
deleteSlackConnection(
input: DeleteSlackConnectionInput!
): DeleteSlackConnectionPayload!
} }
# Input Types # Input Types
@@ -6096,6 +6102,14 @@ type DeleteCustomDomainPayload {
deletedCustomDomainId: ID! deletedCustomDomainId: ID!
} }
input DeleteSlackConnectionInput {
slackConnectionId: ID!
}
type DeleteSlackConnectionPayload {
deletedSlackConnectionId: ID!
}
# Electronic Signature # Electronic Signature
enum ElectronicSignatureStatus enum ElectronicSignatureStatus

View File

@@ -6811,6 +6811,25 @@ func (r *mutationResolver) DeleteCustomDomain(ctx context.Context, input types.D
}, nil }, nil
} }
// DeleteSlackConnection is the resolver for the deleteSlackConnection field.
func (r *mutationResolver) DeleteSlackConnection(ctx context.Context, input types.DeleteSlackConnectionInput) (*types.DeleteSlackConnectionPayload, error) {
if err := r.authorize(ctx, input.SlackConnectionID, probo.ActionConnectorDelete); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.SlackConnectionID.TenantID())
err := prb.Connectors.Delete(ctx, input.SlackConnectionID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot delete slack connection", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteSlackConnectionPayload{
DeletedSlackConnectionID: input.SlackConnectionID,
}, nil
}
// Organization is the resolver for the organization field. // Organization is the resolver for the organization field.
func (r *obligationResolver) Organization(ctx context.Context, obj *types.Obligation) (*types.Organization, error) { func (r *obligationResolver) Organization(ctx context.Context, obj *types.Obligation) (*types.Organization, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil { if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
@@ -8665,6 +8684,10 @@ func (r *riskConnectionResolver) TotalCount(ctx context.Context, obj *types.Risk
return 0, gqlutils.Internal(ctx) return 0, gqlutils.Internal(ctx)
} }
// Permission is the resolver for the permission field.
func (r *slackConnectionResolver) Permission(ctx context.Context, obj *types.SlackConnection, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
}
// Organization is the resolver for the organization field. // Organization is the resolver for the organization field.
func (r *snapshotResolver) Organization(ctx context.Context, obj *types.Snapshot) (*types.Organization, error) { func (r *snapshotResolver) Organization(ctx context.Context, obj *types.Snapshot) (*types.Organization, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil { if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
@@ -10599,6 +10622,10 @@ func (r *Resolver) Risk() schema.RiskResolver { return &riskResolver{r} }
// RiskConnection returns schema.RiskConnectionResolver implementation. // RiskConnection returns schema.RiskConnectionResolver implementation.
func (r *Resolver) RiskConnection() schema.RiskConnectionResolver { return &riskConnectionResolver{r} } func (r *Resolver) RiskConnection() schema.RiskConnectionResolver { return &riskConnectionResolver{r} }
// SlackConnection returns schema.SlackConnectionResolver implementation.
func (r *Resolver) SlackConnection() schema.SlackConnectionResolver {
return &slackConnectionResolver{r}
}
// Snapshot returns schema.SnapshotResolver implementation. // Snapshot returns schema.SnapshotResolver implementation.
func (r *Resolver) Snapshot() schema.SnapshotResolver { return &snapshotResolver{r} } func (r *Resolver) Snapshot() schema.SnapshotResolver { return &snapshotResolver{r} }
@@ -10781,6 +10808,7 @@ type rightsRequestResolver struct{ *Resolver }
type rightsRequestConnectionResolver struct{ *Resolver } type rightsRequestConnectionResolver struct{ *Resolver }
type riskResolver struct{ *Resolver } type riskResolver struct{ *Resolver }
type riskConnectionResolver struct{ *Resolver } type riskConnectionResolver struct{ *Resolver }
type slackConnectionResolver struct{ *Resolver }
type snapshotResolver struct{ *Resolver } type snapshotResolver struct{ *Resolver }
type snapshotConnectionResolver struct{ *Resolver } type snapshotConnectionResolver struct{ *Resolver }
type stateOfApplicabilityResolver struct{ *Resolver } type stateOfApplicabilityResolver struct{ *Resolver }