mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
fix(ui): say when Slack has stopped accepting Prowler's access
- Recognise a dead credential through the shared Slack error vocabulary, so the channel listing, the channel save, the test message and the connection check all offer a reconnect rather than a retry - Word the notice from the refusal's code instead of showing Slack's raw reason, which is a protocol token and not user-facing copy - Report a failed revocation as the API states it: the integration is gone from Prowler, and access may still need removing by hand
This commit is contained in:
@@ -82,12 +82,14 @@ export interface SlackRefusalFixture {
|
||||
* 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.
|
||||
*
|
||||
* One boolean is the whole of it. The API sends no reason for a revocation that
|
||||
* did not happen, so modelling one here would let a test prove copy the real
|
||||
* deployment can never produce.
|
||||
*/
|
||||
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 {
|
||||
@@ -197,6 +199,13 @@ 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.";
|
||||
/**
|
||||
* The API wording a dead grant arrives with. It names the raw reason, the same
|
||||
* way the missing-scope one does — which is what lets a test tell "the UI read
|
||||
* `code` and used its own copy" apart from "the UI 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`.
|
||||
@@ -234,6 +243,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";
|
||||
/**
|
||||
* The two dead-grant codes the tests drive with, out of the four 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;
|
||||
|
||||
@@ -256,6 +272,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 a `401`, which would read as "your Prowler session
|
||||
* expired" (contract, Errors) — and the integration is marked disconnected.
|
||||
*/
|
||||
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,
|
||||
@@ -320,18 +348,6 @@ 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",
|
||||
@@ -355,7 +371,7 @@ export const slackFixture = (
|
||||
channelsRefusal: null,
|
||||
channelSaveRefusal: null,
|
||||
testMessage: { accepted: true, error: null },
|
||||
revocation: { revoked: true, error: null },
|
||||
revocation: { revoked: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -438,13 +454,15 @@ export const configuredSlackFixture = (
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Slack — the outcome the user has to finish by hand in the workspace. The API
|
||||
* reports it as `revoked: false` and says no more than that, which is exactly
|
||||
* as much as the UI can honestly tell them.
|
||||
*/
|
||||
export const revokeFailureSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({
|
||||
revocation: { revoked: false, error: SLACK_REVOKE_FAILURE_REASON },
|
||||
revocation: { revoked: false },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -458,6 +476,6 @@ export const revokedTokenSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
configuredSlackFixture({
|
||||
connection: { connected: false, error: SLACK_TOKEN_REVOKED_REASON },
|
||||
connection: { connected: false, error: SLACK_TOKEN_REVOKED_CODE },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -371,15 +371,12 @@ export const handlersForSlack = (fx: SlackFixture) => {
|
||||
// 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.
|
||||
// `revoked` is the entire outcome. The API sends no reason for a revocation
|
||||
// that failed, and a handler that invented one would let the page grow copy
|
||||
// around a field the deployment never sends.
|
||||
http.delete(`${API}/integrations/:id`, () => {
|
||||
install = null;
|
||||
const { revoked, error } = fx.revocation;
|
||||
return HttpResponse.json({
|
||||
meta: { revoked, ...(error ? { revocation_error: error } : {}) },
|
||||
});
|
||||
return HttpResponse.json({ meta: { revoked: fx.revocation.revoked } });
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -42,6 +42,15 @@ 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 says can surface from any of these calls (Cross-cutting) and which
|
||||
* is recovered from by reconnecting rather than by retrying.
|
||||
*/
|
||||
code?: string | null;
|
||||
}
|
||||
|
||||
interface SlackAuthorizeUrl {
|
||||
@@ -158,19 +167,21 @@ 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" — with the `code` carried alongside, unworded, for the
|
||||
* caller that has to act on the class rather than show the sentence. 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.
|
||||
@@ -180,9 +191,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. */
|
||||
@@ -328,14 +343,14 @@ export const getSlackChannels = async (
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await errorMessageFrom(
|
||||
const refusal = await refusalFrom(
|
||||
response,
|
||||
`Unable to read the workspace's channels: ${response.statusText}`,
|
||||
);
|
||||
|
||||
return channels.length > 0
|
||||
? { channels, incomplete: message }
|
||||
: { error: message };
|
||||
? { channels, incomplete: refusal.error }
|
||||
: refusal;
|
||||
}
|
||||
|
||||
// A page that is not JSON reads as no channels, rather than throwing a
|
||||
@@ -423,12 +438,10 @@ export const setSlackDefaultChannel = async (
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: await errorMessageFrom(
|
||||
response,
|
||||
`Unable to save the destination channel: ${response.statusText}`,
|
||||
),
|
||||
};
|
||||
return refusalFrom(
|
||||
response,
|
||||
`Unable to save the destination channel: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
@@ -482,12 +495,10 @@ export const sendSlackTestMessage = async (
|
||||
const response = await fetch(url.toString(), { method: "POST", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: await errorMessageFrom(
|
||||
response,
|
||||
`Unable to send the test message: ${response.statusText}`,
|
||||
),
|
||||
};
|
||||
return refusalFrom(
|
||||
response,
|
||||
`Unable to send the test message: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
// As above: an unreadable `202` is "no task to follow", not a parser
|
||||
@@ -514,14 +525,17 @@ export const sendSlackTestMessage = async (
|
||||
// is shown as it arrived.
|
||||
const reason = settled.result?.error?.trim();
|
||||
if (reason) {
|
||||
return {
|
||||
error: SLACK_REASON_TOKEN.test(reason)
|
||||
? slackErrorMessage(
|
||||
return SLACK_REASON_TOKEN.test(reason)
|
||||
? {
|
||||
error: slackErrorMessage(
|
||||
{ code: reason },
|
||||
slackUnknownReasonMessage(reason),
|
||||
)
|
||||
: reason,
|
||||
};
|
||||
),
|
||||
// A dead grant can surface here as much as anywhere else, so the
|
||||
// reason travels on as the class it is, not only as its sentence.
|
||||
code: reason,
|
||||
}
|
||||
: { error: reason };
|
||||
}
|
||||
if (settled.state !== "completed") {
|
||||
return { error: "Slack did not accept the test message." };
|
||||
@@ -533,7 +547,12 @@ export const sendSlackTestMessage = async (
|
||||
}
|
||||
};
|
||||
|
||||
/** What the API reports about revoking Prowler's token at Slack. */
|
||||
/**
|
||||
* What the API reports about revoking Prowler's token at Slack: one boolean in
|
||||
* `meta`, and nothing else. The API sends no reason for a revocation that did
|
||||
* not happen, so there is none to report — and a UI that invented a place to
|
||||
* put one would be promising the user an explanation it can never fill in.
|
||||
*/
|
||||
export interface SlackRevocation {
|
||||
/**
|
||||
* Whether Slack confirmed the token no longer grants Prowler anything, or
|
||||
@@ -542,8 +561,6 @@ export interface SlackRevocation {
|
||||
* 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 {
|
||||
@@ -579,12 +596,10 @@ export const disconnectSlackIntegration = async (
|
||||
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}`,
|
||||
),
|
||||
};
|
||||
return refusalFrom(
|
||||
response,
|
||||
`Unable to disconnect the Slack workspace: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
@@ -597,10 +612,6 @@ export const disconnectSlackIntegration = async (
|
||||
disconnected: true,
|
||||
revocation: {
|
||||
revoked: typeof meta.revoked === "boolean" ? meta.revoked : null,
|
||||
error:
|
||||
typeof meta.revocation_error === "string"
|
||||
? meta.revocation_error
|
||||
: null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -748,14 +748,14 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
|
||||
// --- A credential Slack no longer accepts --------------------------------
|
||||
|
||||
/** What the user is told when Slack has stopped accepting the token. */
|
||||
/** What the user is told when Slack has stopped accepting the credential. */
|
||||
async revokedCredentialNotice(): Promise<string> {
|
||||
const notice = await this.waitFor(
|
||||
() => this.alertMatching(/has been revoked/),
|
||||
() => this.alertMatching(/no longer accepts Prowler's access/),
|
||||
10000,
|
||||
"the revoked-credential notice",
|
||||
);
|
||||
return (notice.textContent ?? "").trim();
|
||||
return (notice.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
private reconnectLink(): HTMLAnchorElement | null {
|
||||
|
||||
@@ -23,10 +23,11 @@ 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_TOKEN_EXPIRED_CODE,
|
||||
SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
SLACK_TOKEN_REVOKED_CODE,
|
||||
SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
SLACK_UNMAPPED_REASON_CODE,
|
||||
SLACK_UPSTREAM_REFUSAL,
|
||||
@@ -664,7 +665,8 @@ describe("disconnecting a workspace", () => {
|
||||
|
||||
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`.
|
||||
// the row goes either way and the outcome travels in `meta` as the single
|
||||
// boolean the API sends — there is no reason alongside it.
|
||||
const harness = new SlackIntegrationHarness(revokeFailureSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
@@ -672,12 +674,16 @@ describe("disconnecting a workspace", () => {
|
||||
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.
|
||||
// Prowler to retry, and the app may still be installed at Slack. Saying
|
||||
// "there is nothing to retry here" is the point — the one thing a user
|
||||
// reaches for after a failure is the thing that cannot help.
|
||||
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));
|
||||
expect(notice).toMatch(/may still be installed in Prowler HQ/);
|
||||
expect(notice).toMatch(
|
||||
/remove it from that workspace's Slack app settings/,
|
||||
);
|
||||
// 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);
|
||||
@@ -685,7 +691,7 @@ describe("disconnecting a workspace", () => {
|
||||
});
|
||||
|
||||
describe("a credential Slack no longer accepts", () => {
|
||||
it("reports the revoked token and offers to connect the workspace again", async () => {
|
||||
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());
|
||||
@@ -694,16 +700,47 @@ describe("a credential Slack no longer accepts", () => {
|
||||
// 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
|
||||
// Then — what died, in Prowler's words, 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 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",
|
||||
);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
}, 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, not the connection check, is what meets Slack first —
|
||||
// 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 as the connection check gives, worded for the way
|
||||
// this credential died, and not left as a channel problem the user would
|
||||
// go looking for a channel fix for.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(notice).toMatch(/Connect the workspace again to restore access/);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
|
||||
// And the picker says the same thing, in the same words: the API's own
|
||||
// `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);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
useToast,
|
||||
} from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import {
|
||||
isSlackTokenErrorCode,
|
||||
slackErrorMessage,
|
||||
} from "@/lib/integrations/slack-errors";
|
||||
import type { SlackTokenErrorCode } from "@/lib/integrations/slack-errors";
|
||||
import type {
|
||||
IntegrationProps,
|
||||
SlackChannelOption,
|
||||
@@ -97,22 +102,6 @@ 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;
|
||||
@@ -137,14 +126,16 @@ export const SlackIntegrationManager = ({
|
||||
// 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 disconnect that removed the row without Slack confirming the revocation. */
|
||||
const [revocationUnconfirmed, setRevocationUnconfirmed] = useState(false);
|
||||
/**
|
||||
* The `code` of the last refusal any Slack-backed call on this page ran into,
|
||||
* or `null` when the last answer was not a refusal. Every call records it
|
||||
* here — the contract says a dead grant can surface from any of them
|
||||
* (Cross-cutting), so none of them gets to decide on its own what that looks
|
||||
* like.
|
||||
*/
|
||||
const [lastRefusalCode, setLastRefusalCode] = 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.
|
||||
@@ -197,6 +188,46 @@ export const SlackIntegrationManager = ({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether that last refusal proves the Slack grant itself is dead, rather
|
||||
* than a channel unreachable or Slack busy.
|
||||
*
|
||||
* Derived, not stored, and recognised through the shared vocabulary — this
|
||||
* page keeps no list of codes of its own. Derived also means self-clearing: a
|
||||
* later call that 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;
|
||||
|
||||
// The consent URL, minted when the page turns out to need one: after a
|
||||
// disconnect, or once the credential is known to be dead. Both answers are a
|
||||
// reconnect, and it should be a click away by the time the user has read why.
|
||||
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 still offers everything it did before, minus
|
||||
// a shortcut. The catalogue's own install path is unaffected.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [needsInstallUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!integrationId) return;
|
||||
|
||||
@@ -215,6 +246,9 @@ export const SlackIntegrationManager = ({
|
||||
notice: result.incomplete ?? null,
|
||||
},
|
||||
);
|
||||
// The listing is the call a dead credential shows up on first: it
|
||||
// runs on arrival, before the user has touched anything.
|
||||
setLastRefusalCode("error" in result ? (result.code ?? null) : null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
@@ -247,6 +281,9 @@ export const SlackIntegrationManager = ({
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
// The API validates the channel against Slack, so the save is one of
|
||||
// the calls that can discover the credential is gone.
|
||||
setLastRefusalCode(result.code ?? null);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not save the destination channel",
|
||||
@@ -289,6 +326,10 @@ export const SlackIntegrationManager = ({
|
||||
try {
|
||||
const result = await sendSlackTestMessage(integrationId);
|
||||
|
||||
// The post happens in a task, so this is where a credential that died
|
||||
// between the check and the send actually surfaces.
|
||||
setLastRefusalCode("error" in result ? (result.code ?? null) : null);
|
||||
|
||||
setTestMessageState(
|
||||
"sent" in result
|
||||
? {
|
||||
@@ -307,34 +348,35 @@ 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);
|
||||
setLastRefusalCode(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();
|
||||
}
|
||||
// The check reports Slack's own stable reason, which is a protocol
|
||||
// token and not something to show anyone: it is mapped to Prowler's
|
||||
// wording, and only falls back to what arrived when it names a reason
|
||||
// this UI has nothing better to say about. A dead credential named here
|
||||
// is not a failure checking again can fix, which is what recording the
|
||||
// reason — rather than only reporting it — is for.
|
||||
const reason = result.error?.trim() || null;
|
||||
|
||||
setLastRefusalCode(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) {
|
||||
@@ -365,20 +407,18 @@ export const SlackIntegrationManager = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const { revoked, error } = result.revocation;
|
||||
const { revoked } = 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.
|
||||
// to its unconnected state either way — which is what puts a consent URL
|
||||
// on the way, so connecting again is a click rather than a reload. A dead
|
||||
// credential is moot once the row it belonged to is gone.
|
||||
setDisconnected(true);
|
||||
setCredentialFailure(null);
|
||||
setLastRefusalCode(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();
|
||||
setRevocationUnconfirmed(revoked === false);
|
||||
|
||||
if (revoked !== false) {
|
||||
toast({
|
||||
@@ -459,7 +499,7 @@ export const SlackIntegrationManager = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{revocationFailure && (
|
||||
{revocationUnconfirmed && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
Slack disconnected — remove Prowler's access in Slack
|
||||
@@ -467,9 +507,9 @@ export const SlackIntegrationManager = ({
|
||||
<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.
|
||||
the revocation, so the Prowler app may still be installed in{" "}
|
||||
{workspaceName ?? "the workspace"} — remove it from that
|
||||
workspace's Slack app settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -477,13 +517,18 @@ export const SlackIntegrationManager = ({
|
||||
{credentialFailure && (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>
|
||||
Prowler's access to {workspaceName ?? "this workspace"} has
|
||||
been revoked
|
||||
Slack no longer accepts Prowler's access to{" "}
|
||||
{workspaceName ?? "this workspace"}
|
||||
</AlertTitle>
|
||||
{/*
|
||||
The wording is the code's own, from the shared mapping: the four
|
||||
ways a grant dies are four different sentences, and each already
|
||||
ends in the one thing that fixes it. Slack's raw reason is a
|
||||
protocol token and stays out of the copy.
|
||||
*/}
|
||||
<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.
|
||||
{slackErrorMessage({ code: credentialFailure })} Until then, nothing
|
||||
Prowler sends will reach the workspace.
|
||||
</AlertDescription>
|
||||
{installUrl && (
|
||||
<div className="col-start-2 mt-3">
|
||||
|
||||
Reference in New Issue
Block a user