mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
feat(ui): add Slack disconnect and revoked-credential recovery (#12437)
This commit is contained in:
@@ -69,6 +69,24 @@ 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`.
|
||||
*
|
||||
* One boolean is the whole of it: the API sends no reason for a revocation that
|
||||
* did not happen, so modelling one would let a test prove copy the real
|
||||
* deployment can never produce.
|
||||
*/
|
||||
export interface SlackRevocationFixture {
|
||||
/**
|
||||
* Slack confirmed the token no longer grants Prowler anything. `null` when the
|
||||
* answer reports nothing at all — the plain `204` a deployment without a
|
||||
* `destroy` override sends, which is what the UI meets today.
|
||||
*/
|
||||
revoked: boolean | null;
|
||||
}
|
||||
|
||||
export interface SlackFixture {
|
||||
/**
|
||||
* The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` /
|
||||
@@ -111,6 +129,13 @@ export interface SlackFixture {
|
||||
* listing itself answered fine.
|
||||
*/
|
||||
channelSaveRefusal: SlackRefusalFixture | null;
|
||||
/**
|
||||
* The API refused the disconnect itself, so the row is still there
|
||||
* afterwards. Distinct from `revocation`, which is about a removal that
|
||||
* happened.
|
||||
*/
|
||||
disconnectRefusal: SlackRefusalFixture | null;
|
||||
revocation: SlackRevocationFixture;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,6 +198,12 @@ export const SLACK_RATE_LIMITED_DETAIL =
|
||||
export const INTEGRATIONS_SERVER_ERROR_DETAIL = "A server error occurred.";
|
||||
export const SLACK_MISSING_SCOPE_DETAIL =
|
||||
"Slack refused the request: missing_scope.";
|
||||
/**
|
||||
* Names the raw reason, as the missing-scope wording does: what lets a test tell
|
||||
* copy the UI mapped from `code` apart from an echoed `detail`.
|
||||
*/
|
||||
export const SLACK_TOKEN_EXPIRED_DETAIL =
|
||||
"Slack refused the request: token_expired.";
|
||||
/**
|
||||
* The same sentence for "it is gone" and "the app was removed from it": only
|
||||
* `code` separates them, which is why a client must read `code`.
|
||||
@@ -180,6 +211,14 @@ export const SLACK_MISSING_SCOPE_DETAIL =
|
||||
export const SLACK_UNKNOWN_CHANNEL_DETAIL =
|
||||
"That channel is not one Prowler can post to.";
|
||||
|
||||
/**
|
||||
* A `403` from the shared destroy route: the role can read the integration but
|
||||
* not remove it. Names no Slack reason, so the API's own wording is what the
|
||||
* user is shown.
|
||||
*/
|
||||
export const SLACK_DISCONNECT_FORBIDDEN_DETAIL =
|
||||
"You do not have permission to disconnect this integration.";
|
||||
|
||||
/**
|
||||
* A `200` challenge page from a proxy or WAF that took the call instead of the
|
||||
* API. V8 truncates the parser message for this body before the word `html`, so
|
||||
@@ -205,6 +244,13 @@ export const SLACK_NOT_IN_CHANNEL_CODE = "not_in_channel";
|
||||
* open-ended, so having no copy for one is the ordinary case.
|
||||
*/
|
||||
export const SLACK_UNMAPPED_REASON_CODE = "is_archived";
|
||||
/**
|
||||
* Two of the four dead-grant codes the contract lists. Whichever call surfaces
|
||||
* one, the integration is disconnected and the only way out is connecting the
|
||||
* workspace again (contract, Cross-cutting).
|
||||
*/
|
||||
export const SLACK_TOKEN_REVOKED_CODE = "token_revoked";
|
||||
export const SLACK_TOKEN_EXPIRED_CODE = "token_expired";
|
||||
|
||||
export const SLACK_RETRY_AFTER_SECONDS = 30;
|
||||
|
||||
@@ -227,6 +273,18 @@ export const SLACK_RATE_LIMITED_REFUSAL: SlackRefusalFixture = {
|
||||
retryAfterSeconds: SLACK_RETRY_AFTER_SECONDS,
|
||||
};
|
||||
|
||||
/**
|
||||
* The stored grant is no longer usable: a `400` like any other actionable
|
||||
* refusal, deliberately not the `401` that would read as an expired Prowler
|
||||
* session (contract, Errors).
|
||||
*/
|
||||
export const SLACK_TOKEN_EXPIRED_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_TOKEN_EXPIRED_CODE,
|
||||
detail: SLACK_TOKEN_EXPIRED_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/** Slack-side or transport failure — a `502` naming no reason at all. */
|
||||
export const SLACK_UPSTREAM_REFUSAL: SlackRefusalFixture = {
|
||||
status: 502,
|
||||
@@ -254,6 +312,14 @@ export const SLACK_NOT_IN_CHANNEL_REFUSAL: SlackRefusalFixture = {
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/** The disconnect is refused before Slack is asked anything: a `403`. */
|
||||
export const SLACK_DISCONNECT_FORBIDDEN_REFUSAL: SlackRefusalFixture = {
|
||||
status: 403,
|
||||
code: null,
|
||||
detail: SLACK_DISCONNECT_FORBIDDEN_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Two public channels and one private the Prowler app was invited to, ordered
|
||||
* so the private one lands on the second cursor page.
|
||||
@@ -313,6 +379,8 @@ export const slackFixture = (
|
||||
channelsPageSize: SLACK_CHANNELS_PAGE_SIZE,
|
||||
channelsRefusal: null,
|
||||
channelSaveRefusal: null,
|
||||
disconnectRefusal: null,
|
||||
revocation: { revoked: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -392,3 +460,51 @@ 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 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A connected tenant whose disconnect answers a plain `204` with no body: the
|
||||
* row is gone and the revocation is unreported.
|
||||
*/
|
||||
export const unreportedRevocationSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({
|
||||
revocation: { revoked: null },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A connected tenant whose disconnect the API refuses: nothing is removed and
|
||||
* nothing is revoked, so the workspace is still connected afterwards.
|
||||
*/
|
||||
export const disconnectRefusalSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
configuredSlackFixture({
|
||||
disconnectRefusal: SLACK_DISCONNECT_FORBIDDEN_REFUSAL,
|
||||
...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.
|
||||
*/
|
||||
export const revokedTokenSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
configuredSlackFixture({
|
||||
connection: { connected: false, error: SLACK_TOKEN_REVOKED_CODE },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -351,5 +351,22 @@ export const handlersForSlack = (fx: SlackFixture) => {
|
||||
install.workspace.channelName = channel.name;
|
||||
return HttpResponse.json({ data: integrationResource(install) });
|
||||
}),
|
||||
|
||||
// Disconnect. Revocation at Slack is best-effort: the row is removed either
|
||||
// way and the outcome travels in `meta` — or nowhere at all, in the plain
|
||||
// `204` a deployment with no `destroy` override sends.
|
||||
http.delete<{ id: string }>(`${API}/integrations/:id`, ({ params }) => {
|
||||
if (!install || install.id !== params.id) {
|
||||
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
|
||||
}
|
||||
// Checked first: a refused disconnect removes nothing.
|
||||
if (fx.disconnectRefusal) return refuse(fx.disconnectRefusal);
|
||||
|
||||
install = null;
|
||||
if (fx.revocation.revoked === null) {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}
|
||||
return HttpResponse.json({ meta: { revoked: fx.revocation.revoked } });
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,6 +32,22 @@ export type ConnectionOutcome =
|
||||
/** Sentinel: the page settled on "no channel recorded", rather than not yet. */
|
||||
const NO_DEFAULT_CHANNEL = "<no channel recorded>";
|
||||
|
||||
export const REVOCATION_OUTCOME = {
|
||||
REVOKED: "revoked",
|
||||
NOT_REVOKED: "not-revoked",
|
||||
/** The answer said nothing either way, so the page claims neither. */
|
||||
UNREPORTED: "unreported",
|
||||
} as const;
|
||||
|
||||
export type RevocationOutcome =
|
||||
(typeof REVOCATION_OUTCOME)[keyof typeof REVOCATION_OUTCOME];
|
||||
|
||||
/** The alert shown when Slack never confirmed the revocation. */
|
||||
const REVOCATION_NOTICE = /revocation/i;
|
||||
|
||||
/** The alert shown when Slack has stopped accepting the credential. */
|
||||
const REVOKED_CREDENTIAL_NOTICE = /no longer accepts Prowler's access/;
|
||||
|
||||
interface CallbackParams {
|
||||
code?: string;
|
||||
state?: string;
|
||||
@@ -686,4 +702,184 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
).find((element) => /invites? @Prowler/.test(element.textContent ?? ""));
|
||||
return hint ? (hint.textContent ?? "").trim() : null;
|
||||
}
|
||||
|
||||
// --- 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 outcomes are mutually
|
||||
* exclusive, so asking for one also checks the others are absent.
|
||||
*
|
||||
* The revoked and unreported outcomes share a toast title, so each is read
|
||||
* from its own description: a title match would agree with either.
|
||||
*/
|
||||
async disconnect(): Promise<RevocationOutcome> {
|
||||
await this.openDisconnectConfirmation();
|
||||
return this.confirmDisconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the confirmation and reports what it asks the user to accept, before
|
||||
* anything is accepted.
|
||||
*/
|
||||
async openDisconnectConfirmation(): Promise<string> {
|
||||
// The dialog's own button carries the noun too, hence the exact match on
|
||||
// the card's action.
|
||||
await this.clickButton(/^\s*Disconnect\s*$/);
|
||||
|
||||
const description = await this.waitFor(
|
||||
() => this.q('[data-slot="dialog-description"]'),
|
||||
10000,
|
||||
"the disconnect confirmation",
|
||||
);
|
||||
return (description.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Accepts the open confirmation and reports the revocation outcome. */
|
||||
async confirmDisconnect(): Promise<RevocationOutcome> {
|
||||
await this.clickButton(/Disconnect workspace/);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
const outcomes = [
|
||||
this.alertMatching(REVOCATION_NOTICE)
|
||||
? REVOCATION_OUTCOME.NOT_REVOKED
|
||||
: null,
|
||||
this.containsText(/has been revoked/)
|
||||
? REVOCATION_OUTCOME.REVOKED
|
||||
: null,
|
||||
this.containsText(/is no longer connected to Prowler/)
|
||||
? REVOCATION_OUTCOME.UNREPORTED
|
||||
: null,
|
||||
].filter((outcome): outcome is RevocationOutcome => outcome !== null);
|
||||
|
||||
if (outcomes.length > 1) {
|
||||
throw new Error(
|
||||
`confirmDisconnect: the page shows ${outcomes.join(" and ")} at once`,
|
||||
);
|
||||
}
|
||||
return outcomes[0] ?? null;
|
||||
},
|
||||
15000,
|
||||
"the disconnect outcome",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try a disconnect the API refuses and hand back what the user is told. One
|
||||
* that goes through fails the test rather than timing out.
|
||||
*/
|
||||
async refusedDisconnect(): Promise<string> {
|
||||
await this.openDisconnectConfirmation();
|
||||
await this.clickButton(/Disconnect workspace/);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
if (this.containsText(/No workspace connected/)) {
|
||||
throw new Error(
|
||||
"refusedDisconnect: the workspace was disconnected, not refused",
|
||||
);
|
||||
}
|
||||
return this.toastText(/Disconnect failed/);
|
||||
},
|
||||
15000,
|
||||
"the refused disconnect",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the page is asking the user to remove the access in Slack. */
|
||||
showsRevocationNotice(): boolean {
|
||||
return this.alertMatching(REVOCATION_NOTICE) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_NOTICE),
|
||||
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 credential. */
|
||||
async revokedCredentialNotice(): Promise<string> {
|
||||
const notice = await this.waitFor(
|
||||
() => this.alertMatching(REVOKED_CREDENTIAL_NOTICE),
|
||||
10000,
|
||||
"the revoked-credential notice",
|
||||
);
|
||||
return (notice.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Whether the page is saying Slack has stopped accepting the credential. */
|
||||
showsRevokedCredentialNotice(): boolean {
|
||||
return this.alertMatching(REVOKED_CREDENTIAL_NOTICE) !== null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits, unlike `offersReconnect`: the affordance needs a consent URL the
|
||||
* page mints only once it knows it needs one, so it lands a beat after the
|
||||
* notice that explains it.
|
||||
*/
|
||||
async waitForReconnect(): Promise<void> {
|
||||
await this.waitFor(() => this.reconnectLink(), 10000, "the reconnect link");
|
||||
}
|
||||
|
||||
/** 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,13 @@ import { it } from "@/__tests__/fixtures";
|
||||
import {
|
||||
configuredSlackFixture,
|
||||
connectedSlackFixture,
|
||||
disconnectRefusalSlackFixture,
|
||||
INTEGRATIONS_SERVER_ERROR_DETAIL,
|
||||
partiallyReadSlackFixture,
|
||||
revokedTokenSlackFixture,
|
||||
revokeFailureSlackFixture,
|
||||
SLACK_CHANNEL_NOT_FOUND_REFUSAL,
|
||||
SLACK_DISCONNECT_FORBIDDEN_DETAIL,
|
||||
SLACK_MISSING_SCOPE_CODE,
|
||||
SLACK_MISSING_SCOPE_REFUSAL,
|
||||
SLACK_NOT_IN_CHANNEL_CODE,
|
||||
@@ -22,15 +26,20 @@ import {
|
||||
SLACK_PUBLIC_CHANNEL,
|
||||
SLACK_RATE_LIMITED_REFUSAL,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL,
|
||||
SLACK_TOKEN_EXPIRED_CODE,
|
||||
SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
SLACK_TOKEN_REVOKED_CODE,
|
||||
SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
SLACK_UPSTREAM_REFUSAL,
|
||||
slackFixture,
|
||||
slackFixtureWithDefaultChannel,
|
||||
unreadableCheckTimeSlackFixture,
|
||||
unreportedRevocationSlackFixture,
|
||||
} from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
|
||||
import {
|
||||
CONNECTION_OUTCOME,
|
||||
REVOCATION_OUTCOME,
|
||||
SlackIntegrationHarness,
|
||||
} from "./slack-integration.harness";
|
||||
|
||||
@@ -595,3 +604,219 @@ describe("choosing a destination channel", () => {
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
}, 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 opens the confirmation. Only the removal is Prowler's to
|
||||
// promise: the revocation happens at Slack, which can refuse it or report
|
||||
// nothing at all.
|
||||
expect(await harness.openDisconnectConfirmation()).toBe(
|
||||
"Prowler will remove the integration, stop posting to Prowler HQ, and " +
|
||||
"attempt to revoke its access at Slack. Connecting again means " +
|
||||
"approving Prowler in Slack.",
|
||||
);
|
||||
|
||||
// And — the user confirms; Slack confirms the revocation.
|
||||
expect(await harness.confirmDisconnect()).toBe(REVOCATION_OUTCOME.REVOKED);
|
||||
|
||||
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; the row goes either way.
|
||||
const harness = new SlackIntegrationHarness(revokeFailureSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.NOT_REVOKED);
|
||||
|
||||
// And — the disconnect revalidates, so the copy below is read from props
|
||||
// that no longer carry an integration at all.
|
||||
await harness.refreshPageData();
|
||||
|
||||
// Then — 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 in Prowler HQ/);
|
||||
expect(notice).toMatch(
|
||||
/remove it from that workspace's Slack app settings/,
|
||||
);
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("says only that the workspace is no longer connected when nothing reports the revocation", async () => {
|
||||
// Given — the plain `204` a deployment that overrides nothing answers: no
|
||||
// body, so no `meta` to read the outcome from. The case users really meet.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
unreportedRevocationSlackFixture(),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.UNREPORTED);
|
||||
|
||||
// Then — nothing sends the user to Slack to finish a job no answer said
|
||||
// was unfinished.
|
||||
expect(harness.showsRevocationNotice()).toBe(false);
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("says why the disconnect failed and leaves the workspace connected", async () => {
|
||||
// Given — a finished setup whose disconnect the API refuses outright, so
|
||||
// nothing is removed and Slack is never asked to revoke anything.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
disconnectRefusalSlackFixture(),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
const refusal = await harness.refusedDisconnect();
|
||||
|
||||
// Then — the API's own reason reaches the user, and nothing claims a
|
||||
// revocation that never ran.
|
||||
expect(refusal).toMatch(SLACK_DISCONNECT_FORBIDDEN_DETAIL);
|
||||
expect(harness.showsRevocationNotice()).toBe(false);
|
||||
// And — the card is untouched: the integration the user still has is the
|
||||
// one to try again on.
|
||||
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("a credential Slack no longer accepts", () => {
|
||||
it("says the connection check found a dead credential, 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 — a way forward rather than only an error: a revoked token is fixed
|
||||
// by approving Prowler again, not by checking a second time.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/no longer accepts Prowler's access to Prowler HQ/);
|
||||
expect(notice).toMatch(/Prowler's access to Slack was revoked/);
|
||||
expect(notice).toMatch(/Connect the workspace again to restore access/);
|
||||
// Slack's reason is a protocol token: it is what the UI switched on, never
|
||||
// what it showed.
|
||||
expect(notice).not.toMatch(new RegExp(SLACK_TOKEN_REVOKED_CODE));
|
||||
|
||||
const consentScreen = new URL(await harness.reconnectUrl());
|
||||
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
|
||||
"https://slack.com/oauth/v2/authorize",
|
||||
);
|
||||
await harness.waitForReconnect();
|
||||
}, 30000);
|
||||
|
||||
it("offers the same recovery when the channel listing is what finds the credential dead", async () => {
|
||||
// Given — a finished setup whose credential expired. The listing runs on
|
||||
// arrival, so it meets Slack before any check does, and the contract says
|
||||
// any call can be the one that surfaces this.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
configuredSlackFixture({ channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL }),
|
||||
);
|
||||
|
||||
// When — nothing but opening the page.
|
||||
await harness.mount();
|
||||
|
||||
// Then — the same answer the connection check gives, worded for how this
|
||||
// credential died rather than left as a channel problem.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(notice).toMatch(/Connect the workspace again to restore access/);
|
||||
await harness.waitForReconnect();
|
||||
|
||||
// And — the picker says the same, in the same words: `detail` names the raw
|
||||
// reason, and it is `code` the UI answered from.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(message).not.toMatch(new RegExp(SLACK_TOKEN_EXPIRED_CODE));
|
||||
}, 30000);
|
||||
|
||||
it("offers it too when only a later cursor page is what Slack refuses", async () => {
|
||||
// Given — a two-page workspace whose second page is refused by a credential
|
||||
// Slack no longer accepts: the read stops short rather than failing.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
partiallyReadSlackFixture({
|
||||
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When — nothing but opening the page.
|
||||
await harness.mount();
|
||||
|
||||
// Then — what was read stays on offer, as it does for any short list.
|
||||
// Alphabetically, as the picker sorts what it offers.
|
||||
expect(await harness.channelOptions()).toEqual([
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
|
||||
// And — the dead credential is reported all the same: a picker that still
|
||||
// works is no reason to leave the user without the one fix there is.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/Prowler's Slack credential has expired/);
|
||||
await harness.waitForReconnect();
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
}, 60000);
|
||||
|
||||
it("keeps saying so when a later check fails without Slack naming a reason", async () => {
|
||||
// Given — the listing found the credential dead on arrival, and a later
|
||||
// check that fails naming no reason at all.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
configuredSlackFixture({
|
||||
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
connection: { connected: false, error: null },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
expect(await harness.revokedCredentialNotice()).toMatch(
|
||||
/Prowler's Slack credential has expired/,
|
||||
);
|
||||
|
||||
// When
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
|
||||
|
||||
// Then — a failure Slack never answered is no evidence the grant works
|
||||
// again, so the dead credential is still what the page reports.
|
||||
expect(await harness.revokedCredentialNotice()).toMatch(
|
||||
/Prowler's Slack credential has expired/,
|
||||
);
|
||||
await harness.waitForReconnect();
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
}, 60000);
|
||||
|
||||
it("stops saying so once a save Slack validated goes through", async () => {
|
||||
// Given — a finished setup whose connection check found the grant revoked.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
configuredSlackFixture({
|
||||
connection: { connected: false, error: SLACK_TOKEN_REVOKED_CODE },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
|
||||
expect(harness.showsRevokedCredentialNotice()).toBe(true);
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
|
||||
// When — the access is approved again in Slack and the user saves a
|
||||
// destination: both the save and the check it runs answer for the grant.
|
||||
harness.fixture.connection = { connected: true, error: null };
|
||||
await harness.chooseChannel(SLACK_SECOND_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — Slack answered, so the notice about a credential it no longer
|
||||
// accepts goes, and the card is back to what it reported on arrival.
|
||||
expect(harness.showsRevokedCredentialNotice()).toBe(false);
|
||||
expect(harness.offersReconnect()).toBe(false);
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { format, isValid, parseISO } from "date-fns";
|
||||
import { TestTube } from "lucide-react";
|
||||
import { TestTube, Unplug } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { testIntegrationConnection } from "@/actions/integrations/integrations";
|
||||
import {
|
||||
disconnectSlackIntegration,
|
||||
getSlackAuthorizeUrl,
|
||||
getSlackChannels,
|
||||
setSlackDefaultChannel,
|
||||
} from "@/actions/integrations/slack";
|
||||
@@ -22,6 +24,13 @@ import {
|
||||
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,
|
||||
SlackChannelOption,
|
||||
@@ -53,6 +62,15 @@ type ChannelsState = ChannelsLoading | ChannelsFailed | ChannelsLoaded;
|
||||
|
||||
const CHECK_BLOCKED_REASON_ID = "slack-connection-check-blocked";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// The name may be missing: the id decides what the UI can do with it.
|
||||
interface SlackChannelRef {
|
||||
id: string;
|
||||
@@ -64,6 +82,14 @@ const channelRefEquals = (
|
||||
b: SlackChannelRef | null,
|
||||
) => a?.id === b?.id && a?.name === b?.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;
|
||||
@@ -82,6 +108,24 @@ export const SlackIntegrationManager = ({
|
||||
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;
|
||||
@@ -127,6 +171,47 @@ export const SlackIntegrationManager = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -145,6 +230,12 @@ export const SlackIntegrationManager = ({
|
||||
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;
|
||||
@@ -178,6 +269,9 @@ export const SlackIntegrationManager = ({
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
// The API validates the channel against Slack, so the save can
|
||||
// discover the credential is gone.
|
||||
recordRefusal(result.code);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not save the destination channel",
|
||||
@@ -193,6 +287,7 @@ export const SlackIntegrationManager = ({
|
||||
channels.find((channel) => channel.id === selectedChannelId)?.name ??
|
||||
null;
|
||||
|
||||
provedCredentialAlive();
|
||||
setDefaultChannel({ id: selectedChannelId, name: savedName });
|
||||
saved = true;
|
||||
toast({
|
||||
@@ -222,16 +317,25 @@ export const SlackIntegrationManager = ({
|
||||
const result = await testIntegrationConnection(id);
|
||||
|
||||
if (result.success) {
|
||||
provedCredentialAlive();
|
||||
toast({
|
||||
title: "Connection test successful!",
|
||||
description:
|
||||
result.message || "Prowler can reach your Slack workspace.",
|
||||
});
|
||||
} 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));
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Connection test failed",
|
||||
description: result.error || "Failed to reach your Slack workspace.",
|
||||
description: reason
|
||||
? slackErrorMessage({ code: reason, detail: reason })
|
||||
: "Failed to reach your Slack workspace.",
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
@@ -245,7 +349,61 @@ export const SlackIntegrationManager = ({
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -264,6 +422,36 @@ export const SlackIntegrationManager = ({
|
||||
</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>
|
||||
@@ -271,6 +459,45 @@ export const SlackIntegrationManager = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{unconfirmedRevocation && (
|
||||
<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, so the Prowler app may still be installed in{" "}
|
||||
{unconfirmedRevocation.workspace ?? "the workspace"} — remove it
|
||||
from that workspace's Slack app settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{credentialFailure && (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>
|
||||
Slack no longer accepts Prowler'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 ? (
|
||||
@@ -284,7 +511,7 @@ export const SlackIntegrationManager = ({
|
||||
soon as it is.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : integration ? (
|
||||
) : integration && !disconnected ? (
|
||||
<Card variant="base">
|
||||
<CardHeader>
|
||||
<IntegrationCardHeader
|
||||
@@ -292,7 +519,11 @@ export const SlackIntegrationManager = ({
|
||||
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
|
||||
subtitle="Prowler posts to this workspace only."
|
||||
connectionStatus={{
|
||||
connected: integration.attributes.connected,
|
||||
// A dead token outranks the state the page was loaded with.
|
||||
connected:
|
||||
credentialFailure === null
|
||||
? integration.attributes.connected
|
||||
: false,
|
||||
}}
|
||||
/>
|
||||
</CardHeader>
|
||||
@@ -308,22 +539,33 @@ export const SlackIntegrationManager = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
{/* The check posts to the destination channel: the API answers
|
||||
400 when none is recorded yet. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isTesting || !defaultChannel}
|
||||
// The reason travels with the control: a disabled button
|
||||
// whose explanation sits across the row reads as broken.
|
||||
aria-describedby={
|
||||
defaultChannel ? undefined : CHECK_BLOCKED_REASON_ID
|
||||
}
|
||||
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}
|
||||
// The reason travels with the control: a disabled button
|
||||
// whose explanation sits across the row reads as broken.
|
||||
aria-describedby={
|
||||
defaultChannel ? undefined : CHECK_BLOCKED_REASON_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>
|
||||
{!defaultChannel && (
|
||||
<p
|
||||
id={CHECK_BLOCKED_REASON_ID}
|
||||
@@ -397,9 +639,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