Files
prowler/ui/components/integrations/slack/slack-integration-manager.tsx
T

777 lines
28 KiB
TypeScript

"use client";
import { format, isValid, parseISO } from "date-fns";
import { TestTube, Unplug } from "lucide-react";
import { useEffect, useState } from "react";
import { testIntegrationConnection } from "@/actions/integrations/integrations";
import {
disconnectSlackIntegration,
getSlackAuthorizeUrl,
getSlackChannels,
setSlackAuthorizedChannels,
} from "@/actions/integrations/slack";
import { SlackIcon } from "@/components/icons/services/IconServices";
import { IntegrationCardHeader } from "@/components/integrations/shared";
import { SlackChannelMultiSelect } from "@/components/integrations/slack/slack-channel-multi-select";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
CardHeader,
useToast,
} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import {
isSlackTokenErrorCode,
SLACK_REASON_TOKEN,
slackErrorMessage,
} from "@/lib/integrations/slack-errors";
import type { SlackTokenErrorCode } from "@/lib/integrations/slack-errors";
import type {
IntegrationProps,
SlackAuthorizedChannel,
SlackChannelOption,
} from "@/types/integrations";
const CHANNELS_STATUS = {
LOADING: "loading",
ERROR: "error",
LOADED: "loaded",
} as const;
interface ChannelsLoading {
status: typeof CHANNELS_STATUS.LOADING;
}
interface ChannelsFailed {
status: typeof CHANNELS_STATUS.ERROR;
message: string;
}
interface ChannelsLoaded {
status: typeof CHANNELS_STATUS.LOADED;
channels: SlackChannelOption[];
// Rides with the list it qualifies, so it can never outlive it.
notice: string | null;
}
type ChannelsState = ChannelsLoading | ChannelsFailed | ChannelsLoaded;
const CHECK_HINT_ID = "slack-connection-check-hint";
/** The exact text the check posts (contract, Connection); the hint quotes it. */
const CONFIRMATION_MESSAGE =
"✅ Prowler connection verified. Notifications will be delivered to this channel.";
/**
* A disconnect that removed the row without Slack confirming the revocation.
* The workspace name travels with it: the notice exists to name the workspace
* to clean up, and the record is gone by the time revalidation lands.
*/
interface UnconfirmedRevocation {
workspace: string | null;
}
/** Order-insensitive: the mirror must not re-seed on a mere reordering. */
const sameChannelIds = (a: string[], b: string[]) =>
a.length === b.length && new Set([...a, ...b]).size === a.length;
/** Confirmation is part of the record: a confirm-only change must re-seed. */
const sameChannelSets = (
a: SlackAuthorizedChannel[],
b: SlackAuthorizedChannel[],
) => {
if (a.length !== b.length) return false;
const byId = new Map(a.map((channel) => [channel.id, channel]));
return b.every((channel) => {
const other = byId.get(channel.id);
return (
other?.name === channel.name &&
other?.confirmation_sent_at === channel.confirmation_sent_at
);
});
};
/**
* A stored channel the listing no longer carries stays selectable: dropping it
* would deselect it behind the user's back.
*/
const mergeChannelOptions = (
listed: SlackChannelOption[],
stored: SlackAuthorizedChannel[],
): SlackChannelOption[] => {
const merged = new Map(listed.map((channel) => [channel.id, channel]));
stored.forEach(({ id, name, is_private }) => {
if (!merged.has(id)) merged.set(id, { id, name, is_private });
});
return Array.from(merged.values());
};
/**
* Fallback for a save that answered without channels. Retained ids keep their
* confirmation, new ones start without one (contract, PATCH).
*/
const recordedFromSelection = (
channelIds: string[],
options: SlackChannelOption[],
previous: SlackAuthorizedChannel[],
): SlackAuthorizedChannel[] =>
channelIds.flatMap((channelId) => {
const option = options.find((channel) => channel.id === channelId);
if (!option) return [];
const stored = previous.find((channel) => channel.id === channelId);
return [
{ ...option, confirmation_sent_at: stored?.confirmation_sent_at ?? null },
];
});
const channelList = (names: string[]): string =>
new Intl.ListFormat("en", { style: "long", type: "conjunction" }).format(
names.map((name) => `#${name}`),
);
/**
* Slack's own reason, when the string is one: the connection check reports a
* reason and its own prose in the same field, and only a reason is an answer
* from Slack about the credential.
*/
const asReasonCode = (reason: string | null): string | null =>
reason && SLACK_REASON_TOKEN.test(reason) ? reason : null;
interface SlackIntegrationManagerProps {
/** At most one exists per tenant (one workspace). */
integration: IntegrationProps | null;
authorizeUrl: string | null;
/** This deployment has no Prowler Slack app, so no install can be started. */
unavailable: boolean;
rateLimitMessage: string | null;
loadError: string | null;
}
export const SlackIntegrationManager = ({
integration,
authorizeUrl,
unavailable,
rateLimitMessage,
loadError,
}: SlackIntegrationManagerProps) => {
const [isTesting, setIsTesting] = useState(false);
const [isDisconnectOpen, setIsDisconnectOpen] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
// The row is gone the moment the API says so; the server component's
// revalidation only catches up on the next navigation.
const [disconnected, setDisconnected] = useState(false);
const [unconfirmedRevocation, setUnconfirmedRevocation] =
useState<UnconfirmedRevocation | null>(null);
/**
* The `code` of the last refusal any Slack-backed call ran into, or `null`
* when the last answer was not a refusal. A dead grant can surface from any
* of them (contract, Cross-cutting), so every call reports here instead of
* deciding on its own.
*/
const [lastRefusalCode, setLastRefusalCode] = useState<string | null>(null);
// A connected workspace arrives with no consent URL, since no install is left
// to start (design D10), so one is minted only if a reconnect turns out to be
// the way out.
const [mintedInstallUrl, setMintedInstallUrl] = useState<string | null>(null);
const { toast } = useToast();
const integrationId = integration?.id ?? null;
const recordedChannels: SlackAuthorizedChannel[] =
integration?.attributes.configuration.channels ?? [];
// Seeded `loading`, not by the effect: the effect never runs on the server,
// so anything else would server-render a "no channels" picker until
// hydration.
const [channelsState, setChannelsState] = useState<ChannelsState>(
integrationId
? { status: CHANNELS_STATUS.LOADING }
: { status: CHANNELS_STATUS.LOADED, channels: [], notice: null },
);
// Bumped by refresh: a channel invited after load only shows on a re-read.
const [channelReloads, setChannelReloads] = useState(0);
// Local state needed: the picks are buffered until the user saves them.
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>(
recordedChannels.map((channel) => channel.id),
);
// Mirrored in state, not read from the prop, so channel-gated affordances
// move on save instead of waiting for the revalidation.
const [authorizedChannels, setAuthorizedChannels] =
useState(recordedChannels);
// The prop the mirror was last taken from: the card never unmounts, so a
// mirror seeded only at mount would go stale when the record changes.
const [syncedChannels, setSyncedChannels] = useState(recordedChannels);
const [isSavingChannels, setIsSavingChannels] = useState(false);
if (!sameChannelSets(recordedChannels, syncedChannels)) {
// The baseline is `authorizedChannels`, not `syncedChannels`: a save
// advances the mirror first, so the prop reads a later pick as none.
const shownIds = authorizedChannels.map((channel) => channel.id);
setSyncedChannels(recordedChannels);
setAuthorizedChannels(recordedChannels);
// Follow the record only while the buffered picks still match it: an
// unsaved pick is the user's, not ours to overwrite mid-edit.
if (sameChannelIds(selectedChannelIds, shownIds)) {
setSelectedChannelIds(recordedChannels.map((channel) => channel.id));
}
}
// Only an answer from Slack moves the bus: a call that never got one proves
// nothing and leaves the last answer standing.
const provedCredentialAlive = () => setLastRefusalCode(null);
const recordRefusal = (code: string | null | undefined) => {
if (code) setLastRefusalCode(code);
};
/**
* Whether the last refusal proves the grant itself is dead, rather than a
* channel unreachable or Slack busy. Derived, not stored, so it self-clears:
* a later call Slack answered at all (even to refuse a channel) is proof the
* credential works again, and the notice goes with it.
*/
const credentialFailure: SlackTokenErrorCode | null = isSlackTokenErrorCode(
lastRefusalCode,
)
? lastRefusalCode
: null;
const needsInstallUrl = disconnected || credentialFailure !== null;
useEffect(() => {
if (!needsInstallUrl) return;
let cancelled = false;
getSlackAuthorizeUrl()
.then((result) => {
if (cancelled || !("authorizeUrl" in result)) return;
setMintedInstallUrl(result.authorizeUrl);
})
.catch(() => {
// Nothing to say: the page loses a shortcut, not a way to reconnect.
});
return () => {
cancelled = true;
};
}, [needsInstallUrl]);
useEffect(() => {
if (!integrationId) return;
let cancelled = false;
setChannelsState({ status: CHANNELS_STATUS.LOADING });
getSlackChannels(integrationId)
.then((result) => {
if (cancelled) return;
setChannelsState(
"error" in result
? { status: CHANNELS_STATUS.ERROR, message: result.error }
: {
status: CHANNELS_STATUS.LOADED,
channels: result.channels,
notice: result.incomplete ?? null,
},
);
// The listing runs on arrival, so it is where a dead credential shows
// up first. A read cut short still names its refusal's code, so a grant
// that died on a later cursor page is heard too; a truncation naming
// none was Slack busy, not refusing.
if ("error" in result || result.code) recordRefusal(result.code);
else provedCredentialAlive();
})
.catch(() => {
if (cancelled) return;
setChannelsState({
status: CHANNELS_STATUS.ERROR,
message: "Could not reach Slack to read the channel list.",
});
});
return () => {
cancelled = true;
};
}, [integrationId, channelReloads]);
const listedChannels =
channelsState.status === CHANNELS_STATUS.LOADED
? channelsState.channels
: [];
const channelOptions = mergeChannelOptions(
listedChannels,
authorizedChannels,
);
// The check posts only where no confirmation has landed (contract, Connection).
const unconfirmedChannels = authorizedChannels.filter(
(channel) => channel.confirmation_sent_at === null,
);
// Dropping a channel cascades into the alert rules targeting it (design D11).
const droppedChannels = authorizedChannels.filter(
(channel) => !selectedChannelIds.includes(channel.id),
);
const checkHint = (): string => {
if (authorizedChannels.length === 0) {
return "Authorize at least one destination channel below to enable this check.";
}
return unconfirmedChannels.length > 0
? `Checks every authorized channel and posts “${CONFIRMATION_MESSAGE}” once to ${channelList(
unconfirmedChannels.map((channel) => channel.name),
)}.`
: "Checks every authorized channel. Each was confirmed once already, so nothing is posted.";
};
const handleSaveChannels = async () => {
if (!integrationId) return;
let savedChannels: SlackAuthorizedChannel[] = [];
setIsSavingChannels(true);
try {
// Only ids travel — the API validates them and derives the names.
const result = await setSlackAuthorizedChannels(
integrationId,
selectedChannelIds,
);
if ("error" in result) {
// The API validates the channels against Slack, so the save can
// discover the credential is gone.
recordRefusal(result.code);
toast({
variant: "destructive",
title: "Could not save the destination channels",
description: result.error,
});
return;
}
// Prefer the API's own record: a channel renamed in Slack since the list
// was read would otherwise show its old name.
savedChannels =
result.integration.attributes.configuration.channels ??
recordedFromSelection(
selectedChannelIds,
channelOptions,
authorizedChannels,
);
provedCredentialAlive();
setAuthorizedChannels(savedChannels);
setSelectedChannelIds(savedChannels.map((channel) => channel.id));
toast({
title: "Destination channels saved",
description:
savedChannels.length > 0
? `Prowler will post to ${channelList(
savedChannels.map((channel) => channel.name),
)}.`
: "No destination channels are authorized any more.",
});
} catch (_error) {
toast({
variant: "destructive",
title: "Could not save the destination channels",
description: "Something went wrong. Please try again.",
});
} finally {
setIsSavingChannels(false);
}
// Recording destinations is what makes a check possible (design D7), and
// the save alone only proves the API took the ids.
if (savedChannels.length > 0)
await handleTestConnection(integrationId, savedChannels);
};
const handleTestConnection = async (
id: string,
// Passed in by a chained check: it runs before the save's state lands.
channels: SlackAuthorizedChannel[] = authorizedChannels,
) => {
setIsTesting(true);
try {
const result = await testIntegrationConnection(id);
if (result.success) {
provedCredentialAlive();
toast({
title: "Connection test successful!",
description:
result.message ||
"Prowler can reach your Slack workspace and every authorized channel.",
});
} else {
// A dead credential named here is not a failure checking again can
// fix, so the reason is recorded and not only reported.
const reason = result.error?.trim() || null;
recordRefusal(asReasonCode(reason));
const explanation = reason
? slackErrorMessage({ code: reason, detail: reason })
: "Failed to reach your Slack workspace.";
// The failure names its channel by id (design D7); an id the set no
// longer holds falls back to workspace-wide, never a raw Slack id.
const refusedChannel = result.failedChannelId
? channels.find((channel) => channel.id === result.failedChannelId)
: undefined;
toast({
variant: "destructive",
title: "Connection test failed",
description: refusedChannel
? `Slack refused #${refusedChannel.name}: ${explanation}`
: explanation,
});
}
} catch (_error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to test connection. Please try again.",
});
} finally {
setIsTesting(false);
}
};
const handleDisconnect = async (id: string) => {
const recordedWorkspace =
integration?.attributes.configuration.team_name ?? null;
const workspace = recordedWorkspace ?? "your Slack workspace";
setIsDisconnecting(true);
try {
const result = await disconnectSlackIntegration(id);
if ("error" in result) {
toast({
variant: "destructive",
title: "Disconnect failed",
description: result.error,
});
return;
}
const { revoked } = result.revocation;
// The row is gone whatever Slack answered, so the page goes back to its
// unconnected state either way, and a dead credential is moot once the
// row it belonged to is gone.
setDisconnected(true);
setLastRefusalCode(null);
// Only an explicit `false` sends the user to finish the job in Slack: an
// unreported outcome is neither a failed revocation nor a confirmed one,
// so it claims neither.
setUnconfirmedRevocation(
revoked === false ? { workspace: recordedWorkspace } : null,
);
if (revoked !== false) {
toast({
title: "Slack workspace disconnected",
description:
revoked === true
? `Prowler's access to ${workspace} has been revoked.`
: `${workspace} is no longer connected to Prowler.`,
});
}
} catch (_error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to disconnect Slack. Please try again.",
});
} finally {
setIsDisconnecting(false);
setIsDisconnectOpen(false);
}
};
const workspaceName = integration?.attributes.configuration.team_name;
const installUrl = mintedInstallUrl ?? authorizeUrl;
const checkedAt = integration?.attributes.connection_last_checked_at;
const checkedOn = checkedAt ? parseISO(checkedAt) : null;
// `format` throws a RangeError on an unreadable value, which would replace
// the page with the route's error boundary: show nothing instead, as for a
// connection that was never checked.
const lastCheckedOn =
checkedOn && isValid(checkedOn) ? format(checkedOn, "yyyy/MM/dd") : null;
return (
<div className="flex flex-col gap-6">
{rateLimitMessage && (
<Alert variant="warning">
<AlertTitle>Slack is busy right now</AlertTitle>
<AlertDescription>{rateLimitMessage}</AlertDescription>
</Alert>
)}
<Modal
open={isDisconnectOpen}
onOpenChange={setIsDisconnectOpen}
title="Disconnect Slack workspace"
description={`Prowler will remove the integration, stop posting to ${workspaceName ?? "this workspace"}, and attempt to revoke its access at Slack. Connecting again means approving Prowler in Slack.`}
>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
disabled={isDisconnecting}
onClick={() => setIsDisconnectOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="lg"
disabled={isDisconnecting}
onClick={() => integration && handleDisconnect(integration.id)}
>
{!isDisconnecting && <Unplug size={20} />}
{isDisconnecting ? "Disconnecting..." : "Disconnect workspace"}
</Button>
</div>
</Modal>
{loadError && (
<Alert variant="error">
<AlertTitle>Could not load your Slack integration</AlertTitle>
<AlertDescription>{loadError}</AlertDescription>
</Alert>
)}
{unconfirmedRevocation && (
<Alert variant="warning">
<AlertTitle>
Slack disconnected — remove Prowler&apos;s access in Slack
</AlertTitle>
<AlertDescription>
The integration and the token Prowler had stored are gone from
Prowler, so there is nothing to retry here. Slack did not confirm
the revocation, so the Prowler app may still be installed in{" "}
{unconfirmedRevocation.workspace ?? "the workspace"} — remove it
from that workspace&apos;s Slack app settings.
</AlertDescription>
</Alert>
)}
{credentialFailure && (
<Alert variant="error">
<AlertTitle>
Slack no longer accepts Prowler&apos;s access to{" "}
{workspaceName ?? "this workspace"}
</AlertTitle>
{/* Each mapped sentence already ends in the thing that fixes it. */}
<AlertDescription>
{slackErrorMessage({ code: credentialFailure })} Until then, nothing
Prowler sends will reach the workspace.
</AlertDescription>
{installUrl && (
<div className="col-start-2 mt-3">
<Button asChild size="sm">
<a href={installUrl} rel="noopener noreferrer">
<SlackIcon size={16} />
Reconnect to Slack
</a>
</Button>
</div>
)}
</Alert>
)}
{/* Replaces the cards, not the whole page: an early return here would
swallow the rate-limit and load-error notices above. */}
{unavailable ? (
<Alert variant="info">
<AlertTitle>
Slack is not available in this environment yet
</AlertTitle>
<AlertDescription>
The Prowler Slack app is not configured here, so no workspace can be
connected. Nothing to do on your side — this page starts working as
soon as it is.
</AlertDescription>
</Alert>
) : integration && !disconnected ? (
<Card variant="base">
<CardHeader>
<IntegrationCardHeader
icon={<SlackIcon size={32} />}
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
subtitle="Prowler posts to this workspace only."
connectionStatus={{
// A dead token outranks the state the page was loaded with.
connected:
credentialFailure === null
? integration.attributes.connected
: false,
}}
/>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="text-xs text-gray-500 dark:text-gray-300">
{lastCheckedOn && (
<p>
<span className="font-medium">Last checked:</span>{" "}
{lastCheckedOn}
</p>
)}
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{/* The check reaches the authorized channels: the API
answers 400 while the set is empty. */}
<Button
size="sm"
variant="outline"
disabled={
isTesting ||
isSavingChannels ||
authorizedChannels.length === 0
}
// What the control does — or why it cannot — travels with
// it: an explanation across the row reads as unrelated.
aria-describedby={CHECK_HINT_ID}
onClick={() => handleTestConnection(integration.id)}
>
<TestTube size={14} />
{isTesting ? "Testing..." : "Test connection"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={isDisconnecting}
onClick={() => setIsDisconnectOpen(true)}
>
<Unplug size={14} />
Disconnect
</Button>
</div>
<p
id={CHECK_HINT_ID}
className="max-w-prose text-xs text-gray-500 sm:text-right dark:text-gray-300"
>
{checkHint()}
</p>
</div>
</div>
<div className="border-border-neutral-secondary mt-6 flex flex-col gap-4 border-t pt-6">
<SlackChannelMultiSelect
options={channelOptions}
values={selectedChannelIds}
onChange={setSelectedChannelIds}
isLoading={channelsState.status === CHANNELS_STATUS.LOADING}
error={
channelsState.status === CHANNELS_STATUS.ERROR
? channelsState.message
: null
}
incompleteNotice={
channelsState.status === CHANNELS_STATUS.LOADED
? channelsState.notice
: null
}
onRefresh={() => setChannelReloads((reloads) => reloads + 1)}
disabled={isSavingChannels}
/>
{droppedChannels.length > 0 && (
<Alert variant="warning" data-deauthorize-warning>
<AlertTitle>
Dropped channels leave your alert rules too
</AlertTitle>
<AlertDescription>
Saving this selection drops{" "}
{channelList(
droppedChannels.map((channel) => channel.name),
)}
. Every alert rule targeting a dropped channel stops
targeting it, and Prowler stops delivering there.
Notifications already delivered stay in Slack.
</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Keyed: the card's subtitle also starts "Prowler posts to". */}
<p
className="text-text-neutral-secondary text-xs"
data-authorized-channels
>
{authorizedChannels.length > 0
? `Prowler posts to ${channelList(
authorizedChannels.map((channel) => channel.name),
)}.`
: "No destination channels authorized yet."}
</p>
<Button
size="sm"
disabled={
sameChannelIds(
selectedChannelIds,
authorizedChannels.map((channel) => channel.id),
) ||
isSavingChannels ||
isTesting
}
onClick={handleSaveChannels}
>
{isSavingChannels ? "Saving..." : "Save channels"}
</Button>
</div>
</div>
</CardContent>
</Card>
) : (
<Card variant="base">
<CardHeader>
<IntegrationCardHeader
icon={<SlackIcon size={32} />}
title="No workspace connected"
subtitle="Approve Prowler in Slack to connect a workspace. No tokens to copy."
/>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-gray-600 dark:text-gray-300">
Prowler asks for permission to post messages and to read the
workspace&apos;s channel list.
</p>
{installUrl ? (
<Button asChild>
<a href={installUrl} rel="noopener noreferrer">
<SlackIcon size={16} />
Add to Slack
</a>
</Button>
) : (
<Button disabled>
<SlackIcon size={16} />
Add to Slack
</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
};