feat(ui): add Slack disconnect and revoked-credential recovery (#12437)

This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-08-21 12:42:47 +02:00
committed by GitHub
parent f39c92b8f8
commit 3e000faa31
7 changed files with 961 additions and 44 deletions
+32 -2
View File
@@ -59,6 +59,7 @@ vi.mock("@/lib", () => ({
}));
import {
disconnectSlackIntegration,
exchangeSlackOAuthCode,
getSlackAuthorizeUrl,
getSlackChannels,
@@ -351,6 +352,12 @@ const channelOptions = (count: number) =>
const RATE_LIMITED_MESSAGE =
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.";
/** A dead grant as the API reports it: reason in `code`, prose in `detail`. */
const TOKEN_EXPIRED_CODE = "token_expired";
const TOKEN_EXPIRED_DETAIL = "Slack refused the request: token_expired.";
const TOKEN_EXPIRED_MESSAGE =
"Prowler's Slack credential has expired. Connect the workspace again to restore access.";
describe("getSlackChannels", () => {
it("follows a cursor-only `next` on the listing's own URL, not on the API root", async () => {
// The link is opaque (design D6), so the API may answer with the cursor
@@ -445,9 +452,27 @@ describe("getSlackChannels", () => {
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
// A rate limit says nothing about the grant, so the truncation names none.
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: RATE_LIMITED_MESSAGE,
code: null,
});
});
it("names the reason a later page was refused, not only the wording", async () => {
fetchMock
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
.mockResolvedValueOnce(
errorResponse(400, TOKEN_EXPIRED_DETAIL, TOKEN_EXPIRED_CODE),
);
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: TOKEN_EXPIRED_MESSAGE,
code: TOKEN_EXPIRED_CODE,
});
});
@@ -456,7 +481,7 @@ describe("getSlackChannels", () => {
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({ error: RATE_LIMITED_MESSAGE });
expect(result).toEqual({ error: RATE_LIMITED_MESSAGE, code: null });
});
});
@@ -601,6 +626,10 @@ const COPY_ONLY_ACTIONS = [
name: "setSlackDefaultChannel",
call: (id: string) => setSlackDefaultChannel(id, FIRST_CHANNEL.id),
},
{
name: "disconnectSlackIntegration",
call: (id: string) => disconnectSlackIntegration(id),
},
];
const rateLimitedResponse = () =>
@@ -668,7 +697,8 @@ describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
expect(captureExceptionMock).not.toHaveBeenCalled();
expect(captureMessageMock).not.toHaveBeenCalled();
expect(result).toEqual({ error: refusal.expected });
// None of these refusals names a `code`.
expect(result).toEqual({ error: refusal.expected, code: null });
});
});
+111 -20
View File
@@ -40,6 +40,14 @@ interface SlackUnconfirmed {
interface SlackActionError {
error: string;
/**
* The refusal's `code`, when it named one, alongside the copy. A caller reads
* it to recognise a class of failure the wording cannot be pattern-matched
* for — a Slack grant that has stopped working, which the contract allows
* from any of these calls (Cross-cutting) and is recovered from by
* reconnecting rather than by retrying.
*/
code?: string | null;
}
interface SlackAuthorizeUrl {
@@ -169,19 +177,19 @@ const failureFrom = async (
};
}
return { error: slackErrorMessage(failure, fallback) };
return { error: slackErrorMessage(failure, fallback), code: failure.code };
};
/**
* `failureFrom` flattened to one line of copy, for the calls whose only
* outcome is "it did not work". Rate limiting keeps its own wording:
* `conversations.list` is Slack tier 2, so a `429` shows up here (contract,
* Errors) and the wait it names is the useful part.
* `failureFrom` flattened to one refusal, for the calls whose only outcome is
* "it did not work". Rate limiting keeps its own wording: `conversations.list`
* is Slack tier 2, so a `429` shows up here (contract, Errors) and the wait it
* names is the useful part.
*/
const errorMessageFrom = async (
const refusalFrom = async (
response: Response,
fallback: string,
): Promise<string> => {
): Promise<SlackActionError> => {
// Same 5xx handling as `failureFrom`, `503` excepted: here too it means Slack
// is unavailable. Must run before `readSlackFailure`: a body can only be read
// once.
@@ -191,9 +199,13 @@ const errorMessageFrom = async (
const failure = await readSlackFailure(response);
return failure.status === RATE_LIMITED_STATUS
? slackRateLimitMessage(failure.retryAfterSeconds)
: slackErrorMessage(failure, fallback);
return {
error:
failure.status === RATE_LIMITED_STATUS
? slackRateLimitMessage(failure.retryAfterSeconds)
: slackErrorMessage(failure, fallback),
code: failure.code,
};
};
/** Mint an OAuth state and get the consent URL. Creates no integration. */
@@ -296,6 +308,13 @@ interface SlackChannelsSuccess {
* the picker *and* the reason.
*/
incomplete?: string;
/**
* The `code` of the refusal that cut the read short, when it named one. A
* grant that has stopped working refuses the second cursor page exactly as it
* refuses the first, and a caller reading only the failure path would never
* hear about it.
*/
code?: string | null;
}
export type SlackChannelsResult = SlackChannelsSuccess | SlackActionError;
@@ -339,9 +358,9 @@ export const getSlackChannels = async (
});
if (!response.ok) {
let message: string;
let refusal: SlackActionError;
try {
message = await errorMessageFrom(
refusal = await refusalFrom(
response,
`Unable to read the workspace's channels: ${response.statusText}`,
);
@@ -353,8 +372,8 @@ export const getSlackChannels = async (
}
return channels.length > 0
? { channels, incomplete: message }
: { error: message };
? { channels, incomplete: refusal.error, code: refusal.code }
: refusal;
}
// A page that is not JSON reads as no channels, rather than throwing a
@@ -447,12 +466,12 @@ export const setSlackDefaultChannel = async (
});
if (!response.ok) {
return {
error: await errorMessageFrom(
response,
`Unable to save the destination channel: ${response.statusText}`,
),
};
// Awaited inside the `try`: unawaited, a 5xx's rejection would skip
// this `catch`.
return await refusalFrom(
response,
`Unable to save the destination channel: ${response.statusText}`,
);
}
const body = await response.json().catch(() => null);
@@ -473,3 +492,75 @@ export const setSlackDefaultChannel = async (
return handleApiError(error);
}
};
/**
* What the API reports about revoking Prowler's token at Slack: one boolean in
* `meta`, and nothing else — it sends no reason for a revocation that did not
* happen, so there is no field here to hold one.
*/
export interface SlackRevocation {
/**
* Whether Slack confirmed the token no longer grants Prowler anything, or
* `null` when the response carried no outcome. The contract says the outcome
* is always reported, so `null` means the response is wrong, not the
* revocation.
*/
revoked: boolean | 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 body is the whole point. 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 tell "gone and revoked" from "gone, but still installed in
* Slack".
*
* A body without the field (an empty `204`, say) yields `null`, not `false`: an
* unreported outcome must not send the user off to clean up Slack, nor be shown
* as access revoked.
*/
export const disconnectSlackIntegration = async (
integrationId: string,
): Promise<SlackDisconnectResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
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 await refusalFrom(
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,
},
};
} catch (error) {
return handleApiError(error);
}
};