mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(ui): add Slack disconnect and revoked-credential recovery
- Disconnect a Slack workspace behind a confirmation, returning the page to its unconnected state - Report the revocation outcome from the DELETE response meta, telling the user when access still has to be removed in Slack by hand - Offer to connect the workspace again when Slack stops accepting the stored credential - Widen the integration `connected` attribute to allow null, which the OAuth exchange returns until the first connection check runs
This commit is contained in:
@@ -78,6 +78,18 @@ export interface SlackRefusalFixture {
|
||||
retryAfterSeconds: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `DELETE /integrations/{id}` reports about revoking the token at Slack.
|
||||
* Revocation is best-effort: the row goes either way, and the outcome travels in
|
||||
* JSON:API `meta` so the UI can say when access still needs removing by hand.
|
||||
*/
|
||||
export interface SlackRevocationFixture {
|
||||
/** Slack confirmed the token no longer grants Prowler anything. */
|
||||
revoked: boolean;
|
||||
/** Slack's reason when it did not. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface SlackFixture {
|
||||
/**
|
||||
* The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` /
|
||||
@@ -121,6 +133,8 @@ export interface SlackFixture {
|
||||
*/
|
||||
channelSaveRefusal: SlackRefusalFixture | null;
|
||||
testMessage: SlackTestMessageFixture;
|
||||
/** What disconnecting reports about revoking the token at Slack. */
|
||||
revocation: SlackRevocationFixture;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,6 +320,18 @@ export const SLACK_CHANNELS_PAGE_SIZE = 2;
|
||||
*/
|
||||
export const SLACK_DEFAULT_CHANNEL = SLACK_PUBLIC_CHANNEL;
|
||||
|
||||
/**
|
||||
* Slack's reason when `auth.revoke` could not be delivered while disconnecting.
|
||||
* The row is still gone; only the revocation failed.
|
||||
*/
|
||||
export const SLACK_REVOKE_FAILURE_REASON = "invalid_auth";
|
||||
|
||||
/**
|
||||
* What Slack answers once a workspace admin has revoked Prowler's token, so any
|
||||
* call made with it proves the credential unusable (contract, Cross-cutting).
|
||||
*/
|
||||
export const SLACK_TOKEN_REVOKED_REASON = "token_revoked";
|
||||
|
||||
const PROWLER_HQ: SlackWorkspaceFixture = {
|
||||
teamId: "T01PROWLER",
|
||||
teamName: "Prowler HQ",
|
||||
@@ -329,6 +355,7 @@ export const slackFixture = (
|
||||
channelsRefusal: null,
|
||||
channelSaveRefusal: null,
|
||||
testMessage: { accepted: true, error: null },
|
||||
revocation: { revoked: true, error: null },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -408,3 +435,29 @@ export const configuredSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
slackFixtureWithDefaultChannel(SLACK_DEFAULT_CHANNEL, overrides);
|
||||
|
||||
/**
|
||||
* A connected tenant whose disconnect removes the row but cannot revoke at
|
||||
* Slack — the outcome the user has to finish by hand in the workspace.
|
||||
*/
|
||||
export const revokeFailureSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({
|
||||
revocation: { revoked: false, error: SLACK_REVOKE_FAILURE_REASON },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A connected tenant whose token has been revoked at Slack: the row still says
|
||||
* connected until a check runs, and the check is what surfaces it. The check
|
||||
* itself needs a destination channel on record — the API refuses to test one
|
||||
* that has none — so this builds on the finished setup, not the bare install.
|
||||
*/
|
||||
export const revokedTokenSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
configuredSlackFixture({
|
||||
connection: { connected: false, error: SLACK_TOKEN_REVOKED_REASON },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -366,5 +366,20 @@ export const handlersForSlack = (fx: SlackFixture) => {
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Disconnect. Revocation at Slack is best-effort: the row is removed either
|
||||
// way and the outcome is reported in `meta`, so the UI can tell the user
|
||||
// when access still has to be removed by hand in the workspace.
|
||||
//
|
||||
// The contract fixes where the outcome travels but not the key names, so
|
||||
// `revoked` / `revocation_error` are this lane's reading of it — confirm at
|
||||
// the S3 coordination checkpoint and change it here first if it differs.
|
||||
http.delete(`${API}/integrations/:id`, () => {
|
||||
install = null;
|
||||
const { revoked, error } = fx.revocation;
|
||||
return HttpResponse.json({
|
||||
meta: { revoked, ...(error ? { revocation_error: error } : {}) },
|
||||
});
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -532,3 +532,78 @@ export const sendSlackTestMessage = async (
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/** What the API reports about revoking Prowler's token at Slack. */
|
||||
export interface SlackRevocation {
|
||||
/**
|
||||
* Whether Slack confirmed the token no longer grants Prowler anything, or
|
||||
* `null` when the response carried no outcome at all. The contract says the
|
||||
* outcome is always reported, so `null` means the response is wrong rather
|
||||
* than the revocation — and neither answer is claimed on the user's behalf.
|
||||
*/
|
||||
revoked: boolean | null;
|
||||
/** Slack's reason when it did not, when it gave one. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SlackDisconnectSuccess {
|
||||
/** The integration is gone from Prowler, whatever Slack answered. */
|
||||
disconnected: true;
|
||||
revocation: SlackRevocation;
|
||||
}
|
||||
|
||||
export type SlackDisconnectResult = SlackDisconnectSuccess | SlackActionError;
|
||||
|
||||
/**
|
||||
* Disconnect the workspace: `DELETE /integrations/{id}`.
|
||||
*
|
||||
* The generic `deleteIntegration` cannot serve this: it discards the response
|
||||
* body, and the whole point here is what the body carries. Revocation at Slack
|
||||
* is best-effort — the row is removed either way and the outcome travels in
|
||||
* JSON:API `meta` — so a caller has to be able to distinguish "gone and revoked"
|
||||
* from "gone, but still installed in Slack".
|
||||
*
|
||||
* `revoked` is reported only as the API states it: a body without the field (an
|
||||
* empty `204`, say) yields `null`, not `false`. An unreported outcome is not a
|
||||
* failed revocation — it must not send the user off to clean up Slack — and it
|
||||
* is not a confirmed one either, so it must not be reported as access having
|
||||
* been revoked. The row is gone in all three cases, and that much is said.
|
||||
*/
|
||||
export const disconnectSlackIntegration = async (
|
||||
id: string,
|
||||
): Promise<SlackDisconnectResult> => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "DELETE", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: await errorMessageFrom(
|
||||
response,
|
||||
`Unable to disconnect the Slack workspace: ${response.statusText}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const meta = body?.meta ?? {};
|
||||
|
||||
revalidatePath("/integrations");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
return {
|
||||
disconnected: true,
|
||||
revocation: {
|
||||
revoked: typeof meta.revoked === "boolean" ? meta.revoked : null,
|
||||
error:
|
||||
typeof meta.revocation_error === "string"
|
||||
? meta.revocation_error
|
||||
: null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,6 +40,15 @@ export type TestMessageOutcome =
|
||||
/** Sentinel: the page settled on "no channel recorded", rather than not yet. */
|
||||
const NO_DEFAULT_CHANNEL = "<no channel recorded>";
|
||||
|
||||
/** Whether disconnecting also revoked Prowler's token at Slack. */
|
||||
export const REVOCATION_OUTCOME = {
|
||||
REVOKED: "revoked",
|
||||
NOT_REVOKED: "not-revoked",
|
||||
} as const;
|
||||
|
||||
export type RevocationOutcome =
|
||||
(typeof REVOCATION_OUTCOME)[keyof typeof REVOCATION_OUTCOME];
|
||||
|
||||
interface CallbackParams {
|
||||
code?: string;
|
||||
state?: string;
|
||||
@@ -675,4 +684,109 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
);
|
||||
return (description?.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
// --- Disconnecting ------------------------------------------------------
|
||||
|
||||
get disconnectCallCount(): number {
|
||||
return this.countRequests("DELETE", "/integrations/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects the workspace, confirming the way a user has to, and reports
|
||||
* what the page says about the revocation — the two outcomes are mutually
|
||||
* exclusive, so asking for one is also a check that the other is absent.
|
||||
*/
|
||||
async disconnect(): Promise<RevocationOutcome> {
|
||||
// The card's action opens the confirmation; the dialog's own button carries
|
||||
// the noun too, so the two never resolve to each other.
|
||||
await this.clickButton(/^\s*Disconnect\s*$/);
|
||||
await this.clickButton(/Disconnect workspace/);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
if (this.alertMatching(/revocation/i)) {
|
||||
return REVOCATION_OUTCOME.NOT_REVOKED;
|
||||
}
|
||||
if (this.containsText(/Slack workspace disconnected/)) {
|
||||
return REVOCATION_OUTCOME.REVOKED;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
15000,
|
||||
"the disconnect outcome",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the page is back to offering an install with no workspace
|
||||
* connected. The consent URL is minted after the disconnect, so the install
|
||||
* affordance appears a beat after the copy does.
|
||||
*/
|
||||
async returnedToUnconnectedState(): Promise<boolean> {
|
||||
await this.waitForText(/No workspace connected/, 10000);
|
||||
return (
|
||||
(await this.waitForOrNull(
|
||||
() => this.offersInstall(),
|
||||
5000,
|
||||
"the install to be offered again",
|
||||
)) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user is told when the row was removed but Slack never confirmed
|
||||
* the revocation.
|
||||
*/
|
||||
async revocationNotice(): Promise<string> {
|
||||
const notice = await this.waitFor(
|
||||
() => this.alertMatching(/revocation/i),
|
||||
10000,
|
||||
"the revocation notice",
|
||||
);
|
||||
return (notice.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
// --- A credential Slack no longer accepts --------------------------------
|
||||
|
||||
/** What the user is told when Slack has stopped accepting the token. */
|
||||
async revokedCredentialNotice(): Promise<string> {
|
||||
const notice = await this.waitFor(
|
||||
() => this.alertMatching(/has been revoked/),
|
||||
10000,
|
||||
"the revoked-credential notice",
|
||||
);
|
||||
return (notice.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
private reconnectLink(): HTMLAnchorElement | null {
|
||||
return (
|
||||
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
|
||||
/Reconnect to Slack/.test(anchor.textContent ?? ""),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the page offers to approve Prowler in the workspace again. */
|
||||
offersReconnect(): boolean {
|
||||
return this.reconnectLink() !== null;
|
||||
}
|
||||
|
||||
/** The consent URL the reconnect affordance points at, once it is offered. */
|
||||
async reconnectUrl(): Promise<string> {
|
||||
const link = await this.waitFor(
|
||||
() => this.reconnectLink(),
|
||||
10000,
|
||||
"the reconnect link",
|
||||
);
|
||||
return link.href;
|
||||
}
|
||||
|
||||
/** The alert whose text matches, of however many the page is showing. */
|
||||
private alertMatching(pattern: RegExp): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
|
||||
).find((alert) => pattern.test(alert.textContent ?? "")) ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
connectedSlackFixture,
|
||||
INTEGRATIONS_SERVER_ERROR_DETAIL,
|
||||
partiallyReadSlackFixture,
|
||||
revokedTokenSlackFixture,
|
||||
revokeFailureSlackFixture,
|
||||
SLACK_CHANNEL_NOT_FOUND_REFUSAL,
|
||||
SLACK_MISSING_SCOPE_CODE,
|
||||
SLACK_MISSING_SCOPE_REFUSAL,
|
||||
@@ -21,8 +23,10 @@ import {
|
||||
SLACK_PRIVATE_CHANNEL,
|
||||
SLACK_PUBLIC_CHANNEL,
|
||||
SLACK_RATE_LIMITED_REFUSAL,
|
||||
SLACK_REVOKE_FAILURE_REASON,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL,
|
||||
SLACK_TEST_MESSAGE_REFUSED_DETAIL,
|
||||
SLACK_TOKEN_REVOKED_REASON,
|
||||
SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
SLACK_UNMAPPED_REASON_CODE,
|
||||
SLACK_UPSTREAM_REFUSAL,
|
||||
@@ -33,6 +37,7 @@ import {
|
||||
|
||||
import {
|
||||
CONNECTION_OUTCOME,
|
||||
REVOCATION_OUTCOME,
|
||||
SlackIntegrationHarness,
|
||||
TEST_MESSAGE_OUTCOME,
|
||||
} from "./slack-integration.harness";
|
||||
@@ -640,3 +645,65 @@ describe("sending a test message", () => {
|
||||
expect(reported).not.toBe(SLACK_UNMAPPED_REASON_CODE);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("disconnecting a workspace", () => {
|
||||
it("removes the integration and returns the card to its unconnected state", async () => {
|
||||
// Given — a tenant with a workspace connected.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When — the user disconnects and confirms.
|
||||
// Then — Slack confirmed the revocation, so the user is told the access is
|
||||
// gone and nothing warns them to finish the job by hand.
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.REVOKED);
|
||||
|
||||
// And the integration is gone, with the page offering a fresh install.
|
||||
expect(harness.disconnectCallCount).toBe(1);
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("still removes the integration when the revocation fails, and says access may need removing by hand", async () => {
|
||||
// Given — Slack will not accept the revocation. Revocation is best-effort:
|
||||
// the row goes either way and the outcome travels in `meta`.
|
||||
const harness = new SlackIntegrationHarness(revokeFailureSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.NOT_REVOKED);
|
||||
|
||||
// Then — the user reads what is true of both sides: nothing is left in
|
||||
// Prowler to retry, and the app may still be installed at Slack.
|
||||
const notice = await harness.revocationNotice();
|
||||
expect(notice).toMatch(/gone from Prowler/);
|
||||
expect(notice).toMatch(/nothing to retry here/);
|
||||
expect(notice).toMatch(/may still be installed/);
|
||||
expect(notice).toMatch(new RegExp(SLACK_REVOKE_FAILURE_REASON));
|
||||
// The row is removed regardless, so the page does not keep offering a
|
||||
// workspace that no longer exists here.
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("a credential Slack no longer accepts", () => {
|
||||
it("reports the revoked token and offers to connect the workspace again", async () => {
|
||||
// Given — the token was revoked at Slack, so the row still reads connected
|
||||
// until a check runs (contract, Cross-cutting).
|
||||
const harness = new SlackIntegrationHarness(revokedTokenSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
|
||||
|
||||
// Then — the reason Slack gave, and a way forward rather than only an
|
||||
// error: a revoked token is fixed by approving Prowler again, not by
|
||||
// checking a second time.
|
||||
expect(await harness.revokedCredentialNotice()).toMatch(
|
||||
new RegExp(SLACK_TOKEN_REVOKED_REASON),
|
||||
);
|
||||
const consentScreen = new URL(await harness.reconnectUrl());
|
||||
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
|
||||
"https://slack.com/oauth/v2/authorize",
|
||||
);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Disconnecting a Slack workspace from the Slack integration page, revoking Prowler's access at Slack and reporting when it still has to be removed from the workspace by hand
|
||||
@@ -0,0 +1 @@
|
||||
Offer to connect a Slack workspace again when Slack no longer accepts Prowler's credential
|
||||
@@ -302,7 +302,7 @@ export const JiraIntegrationsManager = ({
|
||||
icon={<JiraIcon size={32} />}
|
||||
title={`${integration.attributes.configuration.domain}`}
|
||||
connectionStatus={{
|
||||
connected: integration.attributes.connected,
|
||||
connected: integration.attributes.connected === true,
|
||||
}}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
@@ -316,7 +316,7 @@ export const S3IntegrationsManager = ({
|
||||
"/"
|
||||
}`}
|
||||
connectionStatus={{
|
||||
connected: integration.attributes.connected,
|
||||
connected: integration.attributes.connected === true,
|
||||
}}
|
||||
navigationUrl={`https://console.aws.amazon.com/s3/buckets/${integration.attributes.configuration.bucket_name}`}
|
||||
/>
|
||||
|
||||
@@ -380,7 +380,7 @@ export const SecurityHubIntegrationsManager = ({
|
||||
},
|
||||
]}
|
||||
connectionStatus={{
|
||||
connected: integration.attributes.connected,
|
||||
connected: integration.attributes.connected === true,
|
||||
}}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { format, isValid, parseISO } from "date-fns";
|
||||
import { Send, TestTube } from "lucide-react";
|
||||
import { Send, TestTube, Unplug } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { testIntegrationConnection } from "@/actions/integrations/integrations";
|
||||
import {
|
||||
disconnectSlackIntegration,
|
||||
getSlackAuthorizeUrl,
|
||||
getSlackChannels,
|
||||
sendSlackTestMessage,
|
||||
setSlackDefaultChannel,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
CardHeader,
|
||||
useToast,
|
||||
} from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import type {
|
||||
IntegrationProps,
|
||||
SlackChannelOption,
|
||||
@@ -94,6 +97,22 @@ const channelRefEquals = (
|
||||
b: SlackChannelRef | null,
|
||||
) => a?.id === b?.id && a?.name === b?.name;
|
||||
|
||||
/**
|
||||
* Slack answers that prove the stored token itself is unusable, rather than the
|
||||
* channel unreachable (contract, Cross-cutting). They are what separates "this
|
||||
* needs a new install" from "this needs a different channel", so they get their
|
||||
* own state instead of a generic failure.
|
||||
*/
|
||||
const CREDENTIAL_REVOKED_SIGNALS = [
|
||||
"token_revoked",
|
||||
"invalid_auth",
|
||||
"account_inactive",
|
||||
] as const;
|
||||
|
||||
const isCredentialRevoked = (reason: string | undefined): boolean =>
|
||||
reason !== undefined &&
|
||||
CREDENTIAL_REVOKED_SIGNALS.some((signal) => reason.includes(signal));
|
||||
|
||||
interface SlackIntegrationManagerProps {
|
||||
/** At most one exists per tenant (one workspace). */
|
||||
integration: IntegrationProps | null;
|
||||
@@ -112,6 +131,24 @@ export const SlackIntegrationManager = ({
|
||||
loadError,
|
||||
}: SlackIntegrationManagerProps) => {
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [isDisconnectOpen, setIsDisconnectOpen] = useState(false);
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
// Local state after a mutation: the row is gone the moment the API says so,
|
||||
// and this page is what the user is looking at. The server component's own
|
||||
// revalidation refreshes the same thing on the next navigation.
|
||||
const [disconnected, setDisconnected] = useState(false);
|
||||
/** Slack's reason when a disconnect removed the row but could not revoke. */
|
||||
const [revocationFailure, setRevocationFailure] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
/** Slack's reason when it stopped accepting the stored token. */
|
||||
const [credentialFailure, setCredentialFailure] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
// A connected workspace arrives with no consent URL — there is no install left
|
||||
// to start (design D10) — so one is minted only if the page turns out to need
|
||||
// it: after a disconnect, or once the credential is known to be dead.
|
||||
const [mintedInstallUrl, setMintedInstallUrl] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const integrationId = integration?.id ?? null;
|
||||
@@ -270,18 +307,30 @@ export const SlackIntegrationManager = ({
|
||||
}
|
||||
};
|
||||
|
||||
const mintInstallUrl = async () => {
|
||||
const result = await getSlackAuthorizeUrl();
|
||||
if ("authorizeUrl" in result) setMintedInstallUrl(result.authorizeUrl);
|
||||
};
|
||||
|
||||
const handleTestConnection = async (id: string) => {
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const result = await testIntegrationConnection(id);
|
||||
|
||||
if (result.success) {
|
||||
setCredentialFailure(null);
|
||||
toast({
|
||||
title: "Connection test successful!",
|
||||
description:
|
||||
result.message || "Prowler can reach your Slack workspace.",
|
||||
});
|
||||
} else {
|
||||
// A dead token is not a failure the user can fix by checking again, so
|
||||
// it gets its own state and an offer to approve Prowler again.
|
||||
if (isCredentialRevoked(result.error)) {
|
||||
setCredentialFailure(result.error ?? null);
|
||||
void mintInstallUrl();
|
||||
}
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Connection test failed",
|
||||
@@ -299,7 +348,61 @@ export const SlackIntegrationManager = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async (id: string) => {
|
||||
const workspace =
|
||||
integration?.attributes.configuration.team_name ?? "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, error } = result.revocation;
|
||||
|
||||
// The integration is gone whatever Slack answered, so the page goes back
|
||||
// to its unconnected state either way — with a consent URL ready, so
|
||||
// connecting again is a click rather than a reload.
|
||||
setDisconnected(true);
|
||||
setCredentialFailure(null);
|
||||
// Only an explicit "not revoked" 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.
|
||||
setRevocationFailure(
|
||||
revoked === false ? (error ?? "Slack gave no reason") : null,
|
||||
);
|
||||
void mintInstallUrl();
|
||||
|
||||
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;
|
||||
@@ -318,6 +421,37 @@ export const SlackIntegrationManager = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Portaled by Radix, so its place in this tree costs no layout. */}
|
||||
<Modal
|
||||
open={isDisconnectOpen}
|
||||
onOpenChange={setIsDisconnectOpen}
|
||||
title="Disconnect Slack workspace"
|
||||
description={`Prowler will revoke its access at Slack and stop posting to ${workspaceName ?? "this workspace"}. 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>
|
||||
@@ -325,6 +459,45 @@ export const SlackIntegrationManager = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{revocationFailure && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
Slack disconnected — remove Prowler'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 ({revocationFailure}), so the Prowler app may still
|
||||
be installed in {workspaceName ?? "the workspace"} — remove it from
|
||||
that workspace's Slack app settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{credentialFailure && (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>
|
||||
Prowler's access to {workspaceName ?? "this workspace"} has
|
||||
been revoked
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Slack no longer accepts the token Prowler stored (
|
||||
{credentialFailure}), so Prowler cannot post to this workspace.
|
||||
Approve Prowler in Slack again to restore access.
|
||||
</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 ? (
|
||||
@@ -338,7 +511,7 @@ export const SlackIntegrationManager = ({
|
||||
soon as it is.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : integration ? (
|
||||
) : integration && !disconnected ? (
|
||||
<Card variant="base">
|
||||
<CardHeader>
|
||||
<IntegrationCardHeader
|
||||
@@ -346,7 +519,12 @@ export const SlackIntegrationManager = ({
|
||||
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
|
||||
subtitle="Prowler posts to this workspace only."
|
||||
connectionStatus={{
|
||||
connected: integration.attributes.connected,
|
||||
// A check that came back with a dead token outranks the state
|
||||
// the page was loaded with.
|
||||
connected:
|
||||
credentialFailure === null
|
||||
? integration.attributes.connected
|
||||
: false,
|
||||
}}
|
||||
/>
|
||||
</CardHeader>
|
||||
@@ -367,17 +545,28 @@ export const SlackIntegrationManager = ({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* The check posts to the destination channel: the API answers
|
||||
400 when none is recorded yet. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isTesting || !defaultChannel}
|
||||
onClick={() => handleTestConnection(integration.id)}
|
||||
>
|
||||
<TestTube size={14} />
|
||||
{isTesting ? "Testing..." : "Test connection"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* The check posts to the destination channel: the API answers
|
||||
400 when none is recorded yet. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isTesting || !defaultChannel}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="border-border-neutral-secondary mt-6 flex flex-col gap-4 border-t pt-6">
|
||||
@@ -476,9 +665,9 @@ export const SlackIntegrationManager = ({
|
||||
Prowler asks for permission to post messages and to read the
|
||||
workspace's channel list.
|
||||
</p>
|
||||
{authorizeUrl ? (
|
||||
{installUrl ? (
|
||||
<Button asChild>
|
||||
<a href={authorizeUrl} rel="noopener noreferrer">
|
||||
<a href={installUrl} rel="noopener noreferrer">
|
||||
<SlackIcon size={16} />
|
||||
Add to Slack
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user