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:
Pablo F.G
2026-08-18 17:46:57 +02:00
parent ff3e4a4d24
commit 00d2aacf26
6 changed files with 246 additions and 138 deletions
+36 -18
View File
@@ -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,
});
+4 -7
View File
@@ -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 } });
}),
];
};
+55 -44
View File
@@ -43,6 +43,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 {
@@ -172,19 +181,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.
@@ -194,9 +205,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. */
@@ -342,14 +357,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
@@ -437,12 +452,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);
@@ -496,12 +509,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
@@ -528,14 +539,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." };
@@ -547,7 +561,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
@@ -556,8 +575,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 {
@@ -593,12 +610,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(() => ({}));
@@ -611,10 +626,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&apos;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&apos;s Slack app settings.
the revocation, so the Prowler app may still be installed in{" "}
{workspaceName ?? "the workspace"} remove it from that
workspace&apos;s Slack app settings.
</AlertDescription>
</Alert>
)}
@@ -477,13 +517,18 @@ export const SlackIntegrationManager = ({
{credentialFailure && (
<Alert variant="error">
<AlertTitle>
Prowler&apos;s access to {workspaceName ?? "this workspace"} has
been revoked
Slack no longer accepts Prowler&apos;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">