diff --git a/ui/__tests__/msw/handlers/slack.fixtures.ts b/ui/__tests__/msw/handlers/slack.fixtures.ts
new file mode 100644
index 0000000000..0be80786bb
--- /dev/null
+++ b/ui/__tests__/msw/handlers/slack.fixtures.ts
@@ -0,0 +1,227 @@
+/**
+ * Fixture data for the Slack handlers. Shapes follow the API contract in
+ * `openspec/changes/add-slack-integration/design.md`.
+ */
+
+export interface SlackWorkspaceFixture {
+ teamId: string;
+ teamName: string;
+ botUserId: string;
+ /**
+ * Absent from the serialized configuration until a channel is chosen: the API
+ * omits the keys rather than sending nulls.
+ */
+ channelId?: string;
+ channelName?: string;
+}
+
+export interface SlackInstallFixture {
+ id: string;
+ /** `null` until the first connection check runs. */
+ connected: boolean | null;
+ connectionLastCheckedAt: string | null;
+ workspace: SlackWorkspaceFixture;
+}
+
+export const SLACK_EXCHANGE_OUTCOME = {
+ CREATED: "created",
+ /** Same workspace re-installed: the existing row keeps its id. */
+ REINSTALLED: "reinstalled",
+ REFUSED_STATE: "refused-state",
+ SLACK_REFUSED: "slack-refused",
+ /** A `409` named by its `code`: one workspace per tenant. */
+ DIFFERENT_WORKSPACE: "different-workspace",
+ /**
+ * The three below are `2xx`: the install happened, but the answer is
+ * unreadable, so nothing on the failure path sees them.
+ */
+ UNREADABLE_NO_CONTENT: "unreadable-no-content",
+ UNREADABLE_HTML: "unreadable-html",
+ UNREADABLE_NO_DATA: "unreadable-no-data",
+} as const;
+
+export type SlackExchangeOutcome =
+ (typeof SLACK_EXCHANGE_OUTCOME)[keyof typeof SLACK_EXCHANGE_OUTCOME];
+
+export interface SlackConnectionFixture {
+ connected: boolean;
+ error: string | null;
+}
+
+export interface SlackFixture {
+ /**
+ * The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` /
+ * `SLACK_REDIRECT_URI`. Without them every Slack OAuth call answers `503`.
+ */
+ appConfigured: boolean;
+ install: SlackInstallFixture | null;
+ exchangeWorkspace: SlackWorkspaceFixture;
+ exchangeOutcome: SlackExchangeOutcome;
+ connection: SlackConnectionFixture;
+ /** The Slack OAuth calls answer `429` with a `Retry-After`. */
+ rateLimited: boolean;
+ /**
+ * The shared `GET /integrations` read answers `500`, which the UI's own
+ * helper turns into a thrown error rather than a result.
+ */
+ listServerError: boolean;
+ /** The consent-URL call answers `200` with a proxy's HTML page, not JSON. */
+ authorizeUrlUnreadable: boolean;
+ /**
+ * Both Slack OAuth calls answer `502`, the contract's status for upstream and
+ * transport failures. Distinct from `appConfigured: false`, which is a `503`.
+ */
+ oauthUpstreamError: boolean;
+}
+
+export const SLACK_INTEGRATION_ID = "slack-integration-1";
+
+/** The scopes the channel picker and the posting need (design D2). */
+export const SLACK_BOT_SCOPES = [
+ "chat:write",
+ "chat:write.public",
+ "channels:read",
+ "groups:read",
+] as const;
+
+export const SLACK_REDIRECT_URI =
+ "https://cloud.prowler.com/integrations/slack/callback";
+
+/** Server-minted, single-use, bound to the tenant and user (design D5). */
+export const SLACK_OAUTH_STATE = "st-2f1c9d7a";
+export const SLACK_OAUTH_CODE = "slack-code-1f4a";
+
+export const SLACK_AUTHORIZE_URL =
+ "https://slack.com/oauth/v2/authorize" +
+ "?client_id=1234567890.0987654321" +
+ `&scope=${encodeURIComponent(SLACK_BOT_SCOPES.join(","))}` +
+ `&state=${SLACK_OAUTH_STATE}` +
+ `&redirect_uri=${encodeURIComponent(SLACK_REDIRECT_URI)}`;
+
+/**
+ * The `detail` strings the implementation sends. Human copy; the
+ * machine-readable reason travels in `code`, which is what the UI maps.
+ */
+export const SLACK_UNCONFIGURED_DETAIL =
+ "Slack integration is not configured or temporarily unavailable.";
+export const SLACK_REFUSED_STATE_DETAIL =
+ "OAuth state is invalid, expired, or already consumed.";
+export const SLACK_INVALID_CODE_DETAIL = "The Slack OAuth code is invalid.";
+export const SLACK_DIFFERENT_WORKSPACE_DETAIL =
+ "This tenant is already connected to a different Slack workspace.";
+export const SLACK_UPSTREAM_DETAIL = "Slack is temporarily unavailable.";
+/**
+ * The `code` on the contract's `502`. The UI maps no copy of its own to it, so
+ * the `detail` is what reaches the user.
+ */
+export const SLACK_UPSTREAM_ERROR_CODE = "service_unavailable";
+/**
+ * Raised as a `ValidationError({"channel_id": ...})` that still points at
+ * `/data` rather than at the attribute.
+ */
+export const SLACK_NO_CHANNEL_DETAIL =
+ "This Slack integration has no channel configured.";
+export const SLACK_RATE_LIMITED_DETAIL =
+ "Slack is rate limiting requests from Prowler.";
+/**
+ * What a `500` from the shared `GET /integrations` read carries. Nothing here
+ * is for the user to act on, so the UI answers a server error in its own words.
+ */
+export const INTEGRATIONS_SERVER_ERROR_DETAIL = "A server error occurred.";
+
+/**
+ * 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
+ * the UI's own detection (`HTML_ERROR_PATTERN`) cannot recognise it either.
+ */
+export const PROXY_CHALLENGE_PAGE = [
+ "",
+ "
Attention Required",
+ "
Checking your browser before you proceed.
",
+].join("\n");
+
+/**
+ * A wire value, spelled out rather than imported from the UI's own mapping, so
+ * a rename on our side fails these tests instead of agreeing with itself.
+ */
+export const SLACK_WORKSPACE_CONFLICT_CODE = "slack_workspace_conflict";
+
+export const SLACK_RETRY_AFTER_SECONDS = 30;
+
+export const SLACK_DEFAULT_CHANNEL = {
+ id: "C0123AB",
+ name: "security",
+} as const;
+
+const PROWLER_HQ: SlackWorkspaceFixture = {
+ teamId: "T01PROWLER",
+ teamName: "Prowler HQ",
+ botUserId: "U01PROWLERBOT",
+};
+
+export const slackFixture = (
+ overrides: Partial = {},
+): SlackFixture => ({
+ appConfigured: true,
+ install: null,
+ exchangeWorkspace: { ...PROWLER_HQ },
+ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.CREATED,
+ connection: { connected: true, error: null },
+ rateLimited: false,
+ listServerError: false,
+ authorizeUrlUnreadable: false,
+ oauthUpstreamError: false,
+ ...overrides,
+});
+
+/**
+ * A workspace approved with no destination channel yet. `connected` is `null`,
+ * not `true`: the check runs against the channel, so it has never run
+ * (design.md, "Connection state, in order").
+ */
+export const connectedSlackFixture = (
+ overrides: Partial = {},
+): SlackFixture =>
+ slackFixture({
+ install: {
+ id: SLACK_INTEGRATION_ID,
+ connected: null,
+ connectionLastCheckedAt: null,
+ workspace: { ...PROWLER_HQ },
+ },
+ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REINSTALLED,
+ ...overrides,
+ });
+
+const configuredInstall = (): SlackInstallFixture => ({
+ id: SLACK_INTEGRATION_ID,
+ connected: true,
+ connectionLastCheckedAt: "2026-08-10T09:30:00Z",
+ workspace: {
+ ...PROWLER_HQ,
+ channelId: SLACK_DEFAULT_CHANNEL.id,
+ channelName: SLACK_DEFAULT_CHANNEL.name,
+ },
+});
+
+/**
+ * A workspace connected *and* a channel on record. Anything the API refuses
+ * until a channel exists (the connection check) needs this fixture.
+ */
+export const configuredSlackFixture = (
+ overrides: Partial = {},
+): SlackFixture =>
+ connectedSlackFixture({ install: configuredInstall(), ...overrides });
+
+/**
+ * The same finished setup, with a check time no parser can read: a zero date
+ * from a bad write or a serializer change. The contract types the attribute as
+ * a string and rules nothing else out.
+ */
+export const unreadableCheckTimeSlackFixture = (): SlackFixture =>
+ connectedSlackFixture({
+ install: {
+ ...configuredInstall(),
+ connectionLastCheckedAt: "0000-00-00T00:00:00Z",
+ },
+ });
diff --git a/ui/__tests__/msw/handlers/slack.ts b/ui/__tests__/msw/handlers/slack.ts
new file mode 100644
index 0000000000..722331bc92
--- /dev/null
+++ b/ui/__tests__/msw/handlers/slack.ts
@@ -0,0 +1,254 @@
+/**
+ * MSW handlers for the Slack integration, derived from the API contract in
+ * `openspec/changes/add-slack-integration/design.md` (the API itself lives in
+ * the cloud repository). State is per-call: an exchange creates the install the
+ * subsequent `GET /integrations` returns.
+ *
+ * Wire them per test via `worker.use(...handlersForSlack(fx))`.
+ */
+
+import { http, HttpResponse } from "msw";
+
+import {
+ INTEGRATIONS_SERVER_ERROR_DETAIL,
+ PROXY_CHALLENGE_PAGE,
+ SLACK_AUTHORIZE_URL,
+ SLACK_DIFFERENT_WORKSPACE_DETAIL,
+ SLACK_EXCHANGE_OUTCOME,
+ SLACK_INTEGRATION_ID,
+ SLACK_INVALID_CODE_DETAIL,
+ SLACK_NO_CHANNEL_DETAIL,
+ SLACK_RATE_LIMITED_DETAIL,
+ SLACK_REFUSED_STATE_DETAIL,
+ SLACK_RETRY_AFTER_SECONDS,
+ SLACK_UNCONFIGURED_DETAIL,
+ SLACK_UPSTREAM_DETAIL,
+ SLACK_UPSTREAM_ERROR_CODE,
+ SLACK_WORKSPACE_CONFLICT_CODE,
+} from "./slack.fixtures";
+import type {
+ SlackExchangeOutcome,
+ SlackFixture,
+ SlackInstallFixture,
+} from "./slack.fixtures";
+
+const API = process.env.UI_API_BASE_URL;
+const TS = "2026-08-10T09:00:00Z";
+
+const CONNECTION_TASK_PREFIX = "slack-conn-task-";
+
+/**
+ * `status` is a string, per the JSON:API spec. `source.pointer` is `/data` even
+ * for a field-shaped `ValidationError`: the errors are about the request.
+ */
+const errorBody = (detail: string, status: number, code?: string) => ({
+ errors: [
+ {
+ status: String(status),
+ ...(code ? { code } : {}),
+ detail,
+ source: { pointer: "/data" },
+ },
+ ],
+});
+
+const configuration = (workspace: SlackInstallFixture["workspace"]) => ({
+ team_id: workspace.teamId,
+ team_name: workspace.teamName,
+ bot_user_id: workspace.botUserId,
+ // The API omits these keys until a channel is chosen, never sending nulls.
+ ...(workspace.channelId ? { channel_id: workspace.channelId } : {}),
+ ...(workspace.channelName ? { channel_name: workspace.channelName } : {}),
+});
+
+const integrationResource = (install: SlackInstallFixture) => ({
+ id: install.id,
+ type: "integrations",
+ attributes: {
+ inserted_at: TS,
+ updated_at: TS,
+ enabled: true,
+ connected: install.connected,
+ connection_last_checked_at: install.connectionLastCheckedAt,
+ integration_type: "slack",
+ // No credentials: the bot token is encrypted at rest and never serialized.
+ configuration: configuration(install.workspace),
+ },
+ links: { self: `${API}/integrations/${install.id}` },
+});
+
+const collection = (install: SlackInstallFixture | null) => ({
+ data: install ? [integrationResource(install)] : [],
+ meta: {
+ version: "v1",
+ pagination: {
+ page: 1,
+ pages: 1,
+ count: install ? 1 : 0,
+ },
+ },
+});
+
+const taskResource = (id: string, state: string, result: unknown) => ({
+ data: { id, type: "tasks", attributes: { state, result } },
+});
+
+/**
+ * All three are `2xx`: the first two make `response.json()` throw, the third
+ * parses into a body that names no resource.
+ */
+const unreadableExchange = (outcome: SlackExchangeOutcome): Response => {
+ switch (outcome) {
+ case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
+ return new HttpResponse(null, { status: 204 });
+ case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
+ return HttpResponse.html(PROXY_CHALLENGE_PAGE);
+ default:
+ return HttpResponse.json({ meta: { version: "v1" } });
+ }
+};
+
+export const handlersForSlack = (fx: SlackFixture) => {
+ // Mutable copy: the exchange must not write through to the caller's fixture.
+ let install: SlackInstallFixture | null = fx.install
+ ? { ...fx.install, workspace: { ...fx.install.workspace } }
+ : null;
+
+ const unconfigured = () =>
+ HttpResponse.json(errorBody(SLACK_UNCONFIGURED_DETAIL, 503), {
+ status: 503,
+ });
+
+ const rateLimited = () =>
+ HttpResponse.json(errorBody(SLACK_RATE_LIMITED_DETAIL, 429), {
+ status: 429,
+ headers: { "Retry-After": String(SLACK_RETRY_AFTER_SECONDS) },
+ });
+
+ /** A `502` per the contract's taxonomy: a server fault, not a Slack state. */
+ const upstreamError = () =>
+ HttpResponse.json(
+ errorBody(SLACK_UPSTREAM_DETAIL, 502, SLACK_UPSTREAM_ERROR_CODE),
+ { status: 502, statusText: "Bad Gateway" },
+ );
+
+ return [
+ // --- OAuth ------------------------------------------------------------
+ http.post(`${API}/integrations/slack/oauth/authorize-url`, () => {
+ if (!fx.appConfigured) return unconfigured();
+ if (fx.rateLimited) return rateLimited();
+ if (fx.oauthUpstreamError) return upstreamError();
+ if (fx.authorizeUrlUnreadable) {
+ return HttpResponse.html(PROXY_CHALLENGE_PAGE);
+ }
+ // The URL travels in `meta`; the call creates nothing.
+ return HttpResponse.json({
+ meta: { authorize_url: SLACK_AUTHORIZE_URL },
+ });
+ }),
+
+ http.post(`${API}/integrations/slack/oauth/exchange`, () => {
+ if (!fx.appConfigured) return unconfigured();
+ if (fx.rateLimited) return rateLimited();
+ if (fx.oauthUpstreamError) return upstreamError();
+
+ switch (fx.exchangeOutcome) {
+ case SLACK_EXCHANGE_OUTCOME.REFUSED_STATE:
+ return HttpResponse.json(errorBody(SLACK_REFUSED_STATE_DETAIL, 400), {
+ status: 400,
+ });
+ case SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED:
+ return HttpResponse.json(errorBody(SLACK_INVALID_CODE_DETAIL, 400), {
+ status: 400,
+ });
+ case SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE:
+ return HttpResponse.json(
+ errorBody(
+ SLACK_DIFFERENT_WORKSPACE_DETAIL,
+ 409,
+ SLACK_WORKSPACE_CONFLICT_CODE,
+ ),
+ { status: 409 },
+ );
+ case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
+ case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
+ case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA:
+ // The install still happened: the API upserts before it answers.
+ install = {
+ id: SLACK_INTEGRATION_ID,
+ connected: null,
+ connectionLastCheckedAt: null,
+ workspace: { ...fx.exchangeWorkspace },
+ };
+ return unreadableExchange(fx.exchangeOutcome);
+ case SLACK_EXCHANGE_OUTCOME.REINSTALLED:
+ install = {
+ id: install?.id ?? SLACK_INTEGRATION_ID,
+ connected: null,
+ connectionLastCheckedAt: null,
+ workspace: { ...fx.exchangeWorkspace },
+ };
+ return HttpResponse.json({ data: integrationResource(install) });
+ default:
+ install = {
+ id: SLACK_INTEGRATION_ID,
+ connected: null,
+ connectionLastCheckedAt: null,
+ workspace: { ...fx.exchangeWorkspace },
+ };
+ return HttpResponse.json(
+ { data: integrationResource(install) },
+ { status: 201 },
+ );
+ }
+ }),
+
+ // --- Generic integration endpoints the Slack UI reuses -----------------
+ http.get(`${API}/integrations`, ({ request }) => {
+ if (fx.listServerError) {
+ return HttpResponse.json(
+ errorBody(INTEGRATIONS_SERVER_ERROR_DETAIL, 500),
+ { status: 500 },
+ );
+ }
+
+ const type = new URL(request.url).searchParams.get(
+ "filter[integration_type]",
+ );
+ // An unfiltered read would pull every type into the Slack page.
+ return HttpResponse.json(collection(type === "slack" ? install : null));
+ }),
+
+ http.post<{ id: string }>(
+ `${API}/integrations/:id/connection`,
+ ({ params }) => {
+ // The check posts to the channel, so the API refuses until one exists.
+ if (!install?.workspace.channelId) {
+ return HttpResponse.json(errorBody(SLACK_NO_CHANNEL_DETAIL, 400), {
+ status: 400,
+ });
+ }
+
+ return HttpResponse.json(
+ taskResource(
+ `${CONNECTION_TASK_PREFIX}${params.id}`,
+ "executing",
+ null,
+ ),
+ { status: 202 },
+ );
+ },
+ ),
+
+ http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => {
+ const { connected, error } = fx.connection;
+ if (install && params.taskId.startsWith(CONNECTION_TASK_PREFIX)) {
+ install.connected = connected;
+ install.connectionLastCheckedAt = TS;
+ }
+ return HttpResponse.json(
+ taskResource(params.taskId, "completed", { connected, error }),
+ );
+ }),
+ ];
+};
diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts
index 8945842fe4..a9284a9fa7 100644
--- a/ui/actions/integrations/integrations.ts
+++ b/ui/actions/integrations/integrations.ts
@@ -341,6 +341,7 @@ export const testIntegrationConnection = async (
revalidatePath("/integrations/amazon-s3");
revalidatePath("/integrations/aws-security-hub");
revalidatePath("/integrations/jira");
+ revalidatePath("/integrations/slack");
if ("error" in pollResult) {
return { success: false, error: pollResult.error };
diff --git a/ui/actions/integrations/slack.test.ts b/ui/actions/integrations/slack.test.ts
new file mode 100644
index 0000000000..743ea2f5ea
--- /dev/null
+++ b/ui/actions/integrations/slack.test.ts
@@ -0,0 +1,286 @@
+/**
+ * Sentry reporting for the Slack OAuth actions. A capture leaves no mark on the
+ * DOM, so it cannot be covered from `slack-page.integration.test.tsx`.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { SLACK_UNREADABLE_RESULT_MESSAGE } from "@/lib/integrations/slack-errors";
+import { SentryErrorSource, SentryErrorType } from "@/sentry";
+
+const { captureExceptionMock, captureMessageMock, fetchMock } = vi.hoisted(
+ () => ({
+ /**
+ * The real SDK marks the exception `__sentry_captured__`, and
+ * `handleApiError` reads that mark to avoid reporting the same throw twice.
+ */
+ captureExceptionMock: vi.fn((exception: unknown, _options?: unknown) => {
+ if (exception !== null && typeof exception === "object") {
+ Object.defineProperty(exception, "__sentry_captured__", {
+ configurable: true,
+ value: true,
+ });
+ }
+ }),
+ captureMessageMock: vi.fn(),
+ fetchMock: vi.fn(),
+ }),
+);
+
+vi.mock("@sentry/nextjs", () => ({
+ captureException: captureExceptionMock,
+ captureMessage: captureMessageMock,
+}));
+
+vi.mock("next/cache", () => ({
+ revalidatePath: vi.fn(),
+}));
+
+// The real `handleApiResponse` reads its copy from `lib/helper`, which reaches
+// next-auth through `@/auth.config`; stubbing the session lets that copy load.
+vi.mock("@/auth.config", () => ({
+ auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })),
+}));
+
+vi.mock("@/lib", () => ({
+ apiBaseUrl: "https://api.test/api/v1",
+ getAuthHeaders: vi.fn(() =>
+ Promise.resolve({ Authorization: "Bearer test-token" }),
+ ),
+ parseStringify: (value: unknown) => value,
+}));
+
+import { exchangeSlackOAuthCode, getSlackAuthorizeUrl } from "./slack";
+
+/** The status the contract reserves for an upstream Slack failure. */
+const UPSTREAM_STATUS = 502;
+const UPSTREAM_DETAIL = "Slack is temporarily unavailable.";
+const GENERIC_SERVER_ERROR_MESSAGE =
+ "Server is temporarily unavailable. Please try again in a few minutes.";
+
+const errorResponse = (status: number, detail: string, code?: string) =>
+ new Response(
+ JSON.stringify({
+ errors: [
+ {
+ status: String(status),
+ ...(code ? { code } : {}),
+ detail,
+ source: { pointer: "/data" },
+ },
+ ],
+ }),
+ { status, headers: { "content-type": "application/vnd.api+json" } },
+ );
+
+const exchange = () =>
+ exchangeSlackOAuthCode({ code: "slack-code-1f4a", state: "st-2f1c9d7a" });
+
+beforeEach(() => {
+ vi.stubGlobal("fetch", fetchMock);
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe.each([
+ { action: getSlackAuthorizeUrl, name: "getSlackAuthorizeUrl" },
+ { action: exchange, name: "exchangeSlackOAuthCode" },
+])("$name", ({ action }) => {
+ it("reports an upstream Slack failure instead of only turning it into copy", async () => {
+ // 502 covers `internal_error`, `fatal_error`, `service_unavailable` and
+ // transport failures.
+ fetchMock.mockResolvedValue(
+ errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL, "service_unavailable"),
+ );
+
+ const result = await action();
+
+ // Once, not twice: `handleApiResponse` reports and throws, and the action's
+ // catch sees the mark.
+ expect(captureExceptionMock).toHaveBeenCalledTimes(1);
+ expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
+ tags: {
+ api_error: true,
+ error_source: SentryErrorSource.HANDLE_API_RESPONSE,
+ error_type: SentryErrorType.SERVER_ERROR,
+ status_code: String(UPSTREAM_STATUS),
+ },
+ });
+ expect(captureMessageMock).not.toHaveBeenCalled();
+
+ // The throw lands in the action's catch, so the page gets a result to
+ // render rather than a rejection that strands the callback on its spinner.
+ expect(result).toEqual({ error: UPSTREAM_DETAIL });
+ });
+
+ it("answers a 5xx the API described in HTML in Prowler's own words", async () => {
+ fetchMock.mockResolvedValue(
+ new Response("
502 Bad Gateway
", {
+ status: UPSTREAM_STATUS,
+ statusText: "Bad Gateway",
+ headers: { "content-type": "text/html" },
+ }),
+ );
+
+ const result = await action();
+
+ expect(result).toEqual({ error: GENERIC_SERVER_ERROR_MESSAGE });
+ expect(captureExceptionMock).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([503, 404])(
+ "reports nothing for a %s: that is the feature being dark, not a fault",
+ async (status) => {
+ // 503 means `SLACK_CLIENT_*` is unset; 404 means no Slack API is served
+ // in this deployment at all.
+ fetchMock.mockResolvedValue(
+ errorResponse(status, "Slack integration is not configured."),
+ );
+
+ const result = await action();
+
+ // Capturing this would report the deliberate ship-dark state from every
+ // tenant on every page load.
+ expect(result).toEqual({ unavailable: true });
+ expect(captureExceptionMock).not.toHaveBeenCalled();
+ },
+ );
+
+ it("reports nothing when Slack is rate limiting: it is a wait, not a fault", async () => {
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ errors: [{ status: "429", detail: "Slack is rate limiting." }],
+ }),
+ {
+ status: 429,
+ headers: {
+ "content-type": "application/vnd.api+json",
+ "Retry-After": "30",
+ },
+ },
+ ),
+ );
+
+ const result = await action();
+
+ expect(result).toMatchObject({ rateLimited: true, retryAfterSeconds: 30 });
+ expect(captureExceptionMock).not.toHaveBeenCalled();
+ });
+});
+
+/**
+ * The URL is rendered as the `Add to Slack` link's `href`, so a value the API
+ * got wrong must not become a redirect to somewhere that is not Slack.
+ */
+describe("getSlackAuthorizeUrl authorize URL", () => {
+ const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL.";
+ const CONSENT_SCREEN_URL =
+ "https://slack.com/oauth/v2/authorize" +
+ "?client_id=1234567890.0987654321&state=st-2f1c9d7a";
+
+ const authorizeUrlResponse = (authorizeUrl: unknown) =>
+ new Response(JSON.stringify({ meta: { authorize_url: authorizeUrl } }), {
+ status: 200,
+ headers: { "content-type": "application/vnd.api+json" },
+ });
+
+ it.each([
+ ["a hostile scheme", "javascript:alert(document.domain)"],
+ ["plain HTTP", "http://slack.com/oauth/v2/authorize?client_id=1"],
+ ["another origin", "https://evil.test/oauth/v2/authorize?client_id=1"],
+ ["a lookalike hostname", "https://slack.com.evil.test/oauth/v2/authorize"],
+ [
+ "another Slack path",
+ "https://slack.com/redirect?to=https%3A%2F%2Fevil.test",
+ ],
+ ["a value that is not a URL", "oauth/v2/authorize"],
+ ])(
+ "refuses %s instead of offering it as the install link",
+ async (_label, authorizeUrl) => {
+ // Given — a 2xx whose `meta.authorize_url` is not Slack's consent screen.
+ fetchMock.mockResolvedValue(authorizeUrlResponse(authorizeUrl));
+
+ // When
+ const result = await getSlackAuthorizeUrl();
+
+ // Then — the answer for no URL at all: nothing here is safe to link to.
+ expect(result).toEqual({ error: NO_AUTHORIZE_URL_MESSAGE });
+ },
+ );
+
+ it("hands over Slack's consent screen with its query untouched", async () => {
+ // Given
+ fetchMock.mockResolvedValue(authorizeUrlResponse(CONSENT_SCREEN_URL));
+
+ // When / Then
+ expect(await getSlackAuthorizeUrl()).toEqual({
+ authorizeUrl: CONSENT_SCREEN_URL,
+ });
+ });
+});
+
+/**
+ * The callback names the workspace and redirects on `integration` alone, so a
+ * `2xx` body it cannot read back as an integration must not reach it.
+ */
+describe("exchangeSlackOAuthCode result shape", () => {
+ const INTEGRATION = {
+ id: "9b1f4c22-5e7a-4c2e-8f0d-6a3b1c9d7e42",
+ type: "integrations",
+ attributes: {
+ integration_type: "slack",
+ configuration: { team_name: "Prowler HQ" },
+ },
+ };
+
+ const exchangeResponse = (data: unknown) =>
+ new Response(JSON.stringify({ data }), {
+ status: 200,
+ headers: { "content-type": "application/vnd.api+json" },
+ });
+
+ it.each<[string, unknown]>([
+ ["an empty object", {}],
+ ["an array", []],
+ ["a bare string", "invalid"],
+ ["a resource with no id", { type: "integrations", attributes: {} }],
+ ["a resource with an empty id", { ...INTEGRATION, id: "" }],
+ ["a resource of another type", { ...INTEGRATION, type: "tasks" }],
+ [
+ "a resource with no attributes",
+ { id: INTEGRATION.id, type: "integrations" },
+ ],
+ [
+ "another kind of integration",
+ {
+ ...INTEGRATION,
+ attributes: { ...INTEGRATION.attributes, integration_type: "jira" },
+ },
+ ],
+ ])("cannot confirm the install from %s", async (_label, data) => {
+ // Given — a 2xx whose `data` is truthy but is not an integration resource.
+ fetchMock.mockResolvedValue(exchangeResponse(data));
+
+ // When
+ const result = await exchange();
+
+ // Then — the answer for a body with no `data`: the install happened, only
+ // its result is unknown.
+ expect(result).toEqual({
+ unconfirmed: true,
+ message: SLACK_UNREADABLE_RESULT_MESSAGE,
+ });
+ });
+
+ it("hands over the workspace the API upserted", async () => {
+ // Given
+ fetchMock.mockResolvedValue(exchangeResponse(INTEGRATION));
+
+ // When / Then
+ expect(await exchange()).toEqual({ integration: INTEGRATION });
+ });
+});
diff --git a/ui/actions/integrations/slack.ts b/ui/actions/integrations/slack.ts
new file mode 100644
index 0000000000..fc7a17a850
--- /dev/null
+++ b/ui/actions/integrations/slack.ts
@@ -0,0 +1,249 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { z } from "zod";
+
+import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
+import {
+ readSlackFailure,
+ SLACK_GENERIC_ERROR_MESSAGE,
+ SLACK_UNREADABLE_RESULT_MESSAGE,
+ slackErrorMessage,
+ slackRateLimitMessage,
+} from "@/lib/integrations/slack-errors";
+import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
+import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
+
+interface SlackUnavailable {
+ unavailable: true;
+}
+
+interface SlackRateLimited {
+ rateLimited: true;
+ retryAfterSeconds: number | null;
+ message: string;
+}
+
+/**
+ * The API accepted the exchange (`2xx`) and the UI could not read the workspace
+ * back: the install happened, only its result is unknown.
+ */
+interface SlackUnconfirmed {
+ unconfirmed: true;
+ message: string;
+}
+
+interface SlackActionError {
+ error: string;
+}
+
+interface SlackAuthorizeUrl {
+ authorizeUrl: string;
+}
+
+export type SlackAuthorizeUrlResult =
+ | SlackAuthorizeUrl
+ | SlackUnavailable
+ | SlackRateLimited
+ | SlackActionError;
+
+interface SlackExchangeInput {
+ code: string;
+ state: string;
+}
+
+const slackExchangeInputSchema = z.object({
+ code: z.string().min(1),
+ state: z.string().min(1),
+});
+
+interface SlackExchangeSuccess {
+ integration: IntegrationProps;
+}
+
+export type SlackExchangeResult =
+ | SlackExchangeSuccess
+ | SlackUnavailable
+ | SlackRateLimited
+ | SlackUnconfirmed
+ | SlackActionError;
+
+/**
+ * `503`: no Slack app configured in this deployment. `404`: no Slack API at
+ * all. Both mean "not available here", unlike `429`/`502` which mean "not now".
+ */
+const isUnavailableStatus = (status: number): boolean =>
+ status === 503 || status === 404;
+
+const RATE_LIMITED_STATUS = 429;
+
+const SLACK_AUTHORIZE_HOSTNAME = "slack.com";
+const SLACK_AUTHORIZE_PATHNAME = "/oauth/v2/authorize";
+const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL.";
+
+/**
+ * The URL is rendered as the `Add to Slack` link's `href`, so anything that is
+ * not Slack's consent screen is a redirect to an origin the user did not choose.
+ */
+const isSlackAuthorizeUrl = (value: string): boolean => {
+ try {
+ const url = new URL(value);
+ return (
+ url.protocol === "https:" &&
+ url.hostname === SLACK_AUTHORIZE_HOSTNAME &&
+ url.pathname === SLACK_AUTHORIZE_PATHNAME
+ );
+ } catch {
+ return false;
+ }
+};
+
+const INTEGRATIONS_RESOURCE_TYPE = "integrations";
+
+/**
+ * The callback names the workspace and redirects on this value alone, so a `2xx`
+ * payload that is not a JSON:API resource (`{}`, `[]`, `"invalid"`) must read as
+ * unreadable rather than as a connected workspace. Identity too: a resource
+ * that is not a linkable Slack integration would be shown as the workspace
+ * just installed.
+ */
+const isIntegrationResource = (value: unknown): boolean => {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ return false;
+ }
+
+ const { id, type, attributes } = value as Record;
+
+ if (
+ typeof id !== "string" ||
+ id === "" ||
+ type !== INTEGRATIONS_RESOURCE_TYPE ||
+ typeof attributes !== "object" ||
+ attributes === null ||
+ Array.isArray(attributes)
+ ) {
+ return false;
+ }
+
+ return (
+ (attributes as Record).integration_type ===
+ INTEGRATION_TYPE.SLACK
+ );
+};
+
+const failureFrom = async (
+ response: Response,
+ fallback: string,
+): Promise => {
+ if (isUnavailableStatus(response.status)) return { unavailable: true };
+
+ // A 5xx (including the `502` the contract reserves for "Slack upstream
+ // broke") goes through the repo's 5xx handling, which reports to Sentry and
+ // throws, so the caller's catch answers the user. Must run before
+ // `readSlackFailure`: a body can only be read once.
+ if (response.status >= 500) await handleApiResponse(response);
+
+ const failure = await readSlackFailure(response);
+
+ if (failure.status === RATE_LIMITED_STATUS) {
+ return {
+ rateLimited: true,
+ retryAfterSeconds: failure.retryAfterSeconds,
+ message: slackRateLimitMessage(failure.retryAfterSeconds),
+ };
+ }
+
+ return { error: slackErrorMessage(failure, fallback) };
+};
+
+/** Mint an OAuth state and get the consent URL. Creates no integration. */
+export const getSlackAuthorizeUrl =
+ async (): Promise => {
+ const headers = await getAuthHeaders({ contentType: true });
+ const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/authorize-url`);
+
+ try {
+ const response = await fetch(url.toString(), { method: "POST", headers });
+
+ if (!response.ok) {
+ // Awaited inside the `try`: a returned promise's rejection would skip
+ // this `catch`, and a 5xx rejects.
+ return await failureFrom(
+ response,
+ `Unable to start the Slack install: ${response.statusText}`,
+ );
+ }
+
+ // The URL travels in JSON:API `meta`: the call creates no resource. A
+ // non-JSON `2xx` reads as "no URL" instead of throwing a parser message
+ // the user would be shown verbatim.
+ const body = await response.json().catch(() => null);
+ const authorizeUrl = body?.meta?.authorize_url;
+
+ // A URL that is not Slack's own is no more usable than a missing one.
+ if (
+ typeof authorizeUrl !== "string" ||
+ !isSlackAuthorizeUrl(authorizeUrl)
+ ) {
+ return { error: NO_AUTHORIZE_URL_MESSAGE };
+ }
+
+ return { authorizeUrl };
+ } catch (error) {
+ return handleApiError(error);
+ }
+ };
+
+/**
+ * Complete the install with what Slack put in the callback URL. The API
+ * consumes the `state`, exchanges the single-use `code`, and upserts the
+ * tenant's Slack integration.
+ */
+export const exchangeSlackOAuthCode = async (
+ input: SlackExchangeInput,
+): Promise => {
+ const parsed = slackExchangeInputSchema.safeParse(input);
+ if (!parsed.success) return { error: SLACK_GENERIC_ERROR_MESSAGE };
+
+ const { code, state } = parsed.data;
+ const headers = await getAuthHeaders({ contentType: true });
+ const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/exchange`);
+
+ try {
+ const response = await fetch(url.toString(), {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ data: {
+ type: "slack-oauth-exchanges",
+ attributes: { code, state },
+ },
+ }),
+ });
+
+ if (!response.ok) {
+ // Awaited inside the `try`: unawaited, a 5xx's rejection would skip this
+ // `catch` and leave the callback on its spinner.
+ return await failureFrom(
+ response,
+ `Unable to connect the Slack workspace: ${response.statusText}`,
+ );
+ }
+
+ const body = await response.json().catch(() => null);
+
+ // Before the guard and on both paths: the API upserted the integration
+ // before answering, so a cache filled when there was none would list the
+ // connected workspace as missing.
+ revalidatePath("/integrations");
+ revalidatePath("/integrations/slack");
+
+ if (!isIntegrationResource(body?.data)) {
+ return { unconfirmed: true, message: SLACK_UNREADABLE_RESULT_MESSAGE };
+ }
+
+ return { integration: parseStringify(body.data) as IntegrationProps };
+ } catch (error) {
+ return handleApiError(error);
+ }
+};
diff --git a/ui/app/(prowler)/integrations/integrations-content.tsx b/ui/app/(prowler)/integrations/integrations-content.tsx
new file mode 100644
index 0000000000..04726a8b1e
--- /dev/null
+++ b/ui/app/(prowler)/integrations/integrations-content.tsx
@@ -0,0 +1,44 @@
+import { ApiKeyLinkCard } from "@/components/integrations/api-key/api-key-link-card";
+import { JiraIntegrationCard } from "@/components/integrations/jira/jira-integration-card";
+import { S3IntegrationCard } from "@/components/integrations/s3/s3-integration-card";
+import { SecurityHubIntegrationCard } from "@/components/integrations/security-hub/security-hub-integration-card";
+import { SlackIntegrationCard } from "@/components/integrations/slack/slack-integration-card";
+import { SsoLinkCard } from "@/components/integrations/sso/sso-link-card";
+import { isCloud } from "@/lib/shared/env";
+
+/**
+ * Split out of `page.tsx` for the browser-mode tests: `ContentLayout`'s navbar
+ * streams async server children a client renderer can't resolve.
+ */
+export function IntegrationsContent() {
+ return (
+
+
+
+ Connect external services to enhance your security workflow and
+ automatically export your scan results.
+
+
);
}
diff --git a/ui/app/(prowler)/integrations/slack/callback/page.tsx b/ui/app/(prowler)/integrations/slack/callback/page.tsx
new file mode 100644
index 0000000000..ba7e168c20
--- /dev/null
+++ b/ui/app/(prowler)/integrations/slack/callback/page.tsx
@@ -0,0 +1,21 @@
+import { redirect } from "next/navigation";
+import { Suspense } from "react";
+
+import { SlackCallback } from "@/components/integrations/slack/slack-callback";
+import { ContentLayout } from "@/components/shadcn/content-layout";
+import { isCloud } from "@/lib/shared/env";
+
+export default async function SlackCallbackPage() {
+ if (!isCloud()) {
+ redirect("/");
+ }
+
+ return (
+
+ {/* `SlackCallback` reads the query string, so it needs a boundary. */}
+
+
+
+
+ );
+}
diff --git a/ui/app/(prowler)/integrations/slack/page.tsx b/ui/app/(prowler)/integrations/slack/page.tsx
new file mode 100644
index 0000000000..7f1aea2b65
--- /dev/null
+++ b/ui/app/(prowler)/integrations/slack/page.tsx
@@ -0,0 +1,26 @@
+import { redirect } from "next/navigation";
+
+import { ContentLayout } from "@/components/shadcn/content-layout";
+import { isCloud } from "@/lib/shared/env";
+
+import { SlackIntegrationContent } from "./slack-integration-content";
+
+export default async function SlackIntegrationPage() {
+ // The Slack API is cloud-only, so self-hosted has nothing behind this page.
+ // Mirrors `/alerts`.
+ if (!isCloud()) {
+ redirect("/");
+ }
+
+ return (
+
+
+
+ Connect a Slack workspace so Prowler can post to one of its channels.
+
+
+
+
+
+ );
+}
diff --git a/ui/app/(prowler)/integrations/slack/slack-callback-page.integration.test.tsx b/ui/app/(prowler)/integrations/slack/slack-callback-page.integration.test.tsx
new file mode 100644
index 0000000000..b3e9295abb
--- /dev/null
+++ b/ui/app/(prowler)/integrations/slack/slack-callback-page.integration.test.tsx
@@ -0,0 +1,246 @@
+/**
+ * Browser-mode tests for the Slack OAuth callback
+ * (`/integrations/slack/callback`), driven through `SlackIntegrationHarness`.
+ * MSW answers from handlers derived from the API contract in `design.md`.
+ */
+
+import { describe, expect } from "vitest";
+
+import { it } from "@/__tests__/fixtures";
+import {
+ SLACK_EXCHANGE_OUTCOME,
+ SLACK_OAUTH_CODE,
+ SLACK_OAUTH_STATE,
+ slackFixture,
+} from "@/__tests__/msw/handlers/slack.fixtures";
+
+import { SlackIntegrationHarness } from "./slack-integration.harness";
+
+/** The workspace the fixtures connect. */
+const WORKSPACE_NAME = "Prowler HQ";
+
+/**
+ * Callback headlines, spelled out rather than imported so a rename fails here.
+ * `FAILURE_TITLE` is for installs that connected nothing; `UNCONFIRMED_TITLE`
+ * for answers that arrive after the API already upserted the integration.
+ */
+const FAILURE_TITLE = "Slack workspace not connected";
+const UNCONFIRMED_TITLE = "Slack install not confirmed";
+
+describe("returning from Slack", () => {
+ it("completes the install and shows the connected workspace", async () => {
+ const harness = new SlackIntegrationHarness(slackFixture());
+
+ await harness.mountCallback({
+ code: SLACK_OAUTH_CODE,
+ state: SLACK_OAUTH_STATE,
+ });
+
+ expect(await harness.completedInstall()).toBe(true);
+ expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
+ // The code is single-use and the exchange runs from a render (design D4):
+ // without the once-guard, a second call burns it and reports a failure.
+ expect(harness.exchangeCallCount).toBe(1);
+ // A completed install invalidates the cached "none connected".
+ expect(harness.revalidatedPaths).toEqual(
+ expect.arrayContaining(["/integrations", "/integrations/slack"]),
+ );
+ }, 30000);
+
+ it("does not report an install the API completed as failed when it answers no content", async () => {
+ // Given — a `204`: the API consumed the code and upserted the integration,
+ // then answered with no body. `response.ok` is true, so this is no refusal.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({
+ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT,
+ }),
+ );
+
+ await harness.mountCallback({
+ code: SLACK_OAUTH_CODE,
+ state: SLACK_OAUTH_STATE,
+ });
+
+ const reason = await harness.installFailureReason();
+ expect(reason).toMatch(/could not read the result of the install/);
+ expect(reason).toMatch(/Slack integration page/);
+ expect(reason).not.toMatch(/JSON/i);
+ expect(harness.offersRetry()).toBe(true);
+ // The `204` says the workspace is connected; the headline cannot deny it.
+ expect(await harness.installFailureTitle()).toBe(UNCONFIRMED_TITLE);
+ // The install exists, so the cached "none connected" has to go with it.
+ expect(harness.revalidatedPaths).toEqual(
+ expect.arrayContaining(["/integrations", "/integrations/slack"]),
+ );
+ }, 30000);
+
+ it("shows Prowler's own wording when a proxy answers the completion with an HTML page", async () => {
+ // Given — a proxy answering `200` with a challenge page instead of JSON.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML }),
+ );
+
+ await harness.mountCallback({
+ code: SLACK_OAUTH_CODE,
+ state: SLACK_OAUTH_STATE,
+ });
+
+ // V8's parse message truncates before the word `html`, so the shared
+ // HTML-shaped-error filter cannot catch this one.
+ const reason = await harness.installFailureReason();
+ expect(reason).toMatch(/could not read the result of the install/);
+ expect(reason).not.toMatch(/DOCTYPE/i);
+ expect(reason).not.toMatch(/not valid JSON/i);
+ }, 30000);
+
+ it("says the result is unreadable, not that the workspace is unknown, when the answer names no resource", async () => {
+ // Given — a `200` carrying well-formed JSON:API with no `data` member.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({
+ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA,
+ }),
+ );
+
+ await harness.mountCallback({
+ code: SLACK_OAUTH_CODE,
+ state: SLACK_OAUTH_STATE,
+ });
+
+ const reason = await harness.installFailureReason();
+ expect(reason).toMatch(/could not read the result of the install/);
+ expect(reason).not.toMatch(/undefined/i);
+ expect(await harness.completedInstall()).toBe(false);
+ }, 30000);
+
+ it("connects nothing when the user declines in Slack, and offers to retry", async () => {
+ const harness = new SlackIntegrationHarness(slackFixture());
+
+ await harness.mountCallback({ error: "access_denied" });
+
+ expect(await harness.installFailureReason()).toMatch(
+ /not approved in Slack/,
+ );
+ expect(harness.offersRetry()).toBe(true);
+ // A declined consent carries no code, so there was nothing to exchange.
+ expect(harness.exchangeCallCount).toBe(0);
+ }, 30000);
+
+ it("surfaces the reason when Slack refuses to complete the install", async () => {
+ // Given — Slack rejects the code, and the API's own wording explains it.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED }),
+ );
+
+ await harness.mountCallback({
+ code: SLACK_OAUTH_CODE,
+ state: SLACK_OAUTH_STATE,
+ });
+
+ // A refusal Prowler has no wording of its own for falls back to the API's
+ // `detail`, not to a generic failure.
+ expect(await harness.installFailureReason()).toMatch(
+ /OAuth code is invalid/,
+ );
+ expect(harness.offersRetry()).toBe(true);
+ }, 30000);
+
+ it("surfaces a completion the API refuses, and connects nothing", async () => {
+ // Given — the state was minted for another session, or already consumed.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REFUSED_STATE }),
+ );
+
+ await harness.mountCallback({
+ code: SLACK_OAUTH_CODE,
+ state: "state-from-another-session",
+ });
+
+ expect(await harness.installFailureReason()).toMatch(
+ /state is invalid, expired, or already consumed/,
+ );
+ // The API refused before consuming anything, so nothing was created and the
+ // headline states that plainly.
+ expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
+ expect(await harness.completedInstall()).toBe(false);
+ expect(harness.offersRetry()).toBe(true);
+ // Refused once, not retried into a second burnt code.
+ expect(harness.exchangeCallCount).toBe(1);
+ }, 30000);
+
+ it("says how to resolve a workspace conflict, in Prowler's own words", async () => {
+ // Given — this tenant already has a different workspace connected, which
+ // the API refuses as a 409 naming the conflict in `code`.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({
+ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE,
+ }),
+ );
+
+ await harness.mountCallback({
+ code: SLACK_OAUTH_CODE,
+ state: SLACK_OAUTH_STATE,
+ });
+
+ // The copy comes from the error `code`: the API's `detail` states the
+ // conflict but not the way out of it.
+ const reason = await harness.installFailureReason();
+ expect(reason).toMatch(/already connected to a different Slack workspace/);
+ expect(reason).toMatch(/Disconnect it before connecting another/);
+ expect(reason).not.toMatch(/tenant/);
+ expect(await harness.completedInstall()).toBe(false);
+ expect(harness.offersRetry()).toBe(true);
+ }, 30000);
+
+ it("tells the user when to come back if Slack is rate limiting the install", async () => {
+ // Given — Slack answers 429 with a Retry-After.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({ rateLimited: true }),
+ );
+
+ await harness.mountCallback({
+ code: SLACK_OAUTH_CODE,
+ state: SLACK_OAUTH_STATE,
+ });
+
+ const reason = await harness.installFailureReason();
+ expect(reason).toMatch(/rate limiting/);
+ expect(reason).toMatch(/about 30 seconds/);
+ expect(reason).not.toMatch(/not available in this environment/);
+ // A 429 refuses the exchange outright, so nothing was connected: the plain
+ // headline, unlike the unreadable `2xx` that arrives after the upsert.
+ expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
+ expect(harness.offersRetry()).toBe(true);
+ }, 30000);
+
+ it("reports Slack being broken upstream, rather than leaving the callback spinning", async () => {
+ // Given — the completion answers `502`, the contract's status for a Slack
+ // upstream failure. The shared 5xx handling throws, so the callback only
+ // renders this if the action answers that rejection itself.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({ oauthUpstreamError: true }),
+ );
+
+ await harness.mountCallback({
+ code: SLACK_OAUTH_CODE,
+ state: SLACK_OAUTH_STATE,
+ });
+
+ // The API refused, so nothing was created: not the "could not confirm" the
+ // page falls back to when the action never answers at all.
+ const reason = await harness.installFailureReason();
+ expect(reason).toMatch(/temporarily unavailable/);
+ expect(reason).not.toMatch(/could not confirm/);
+ expect(await harness.completedInstall()).toBe(false);
+ expect(harness.offersRetry()).toBe(true);
+ }, 30000);
+
+ it("does not attempt an exchange when the completion carries no state", async () => {
+ const harness = new SlackIntegrationHarness(slackFixture());
+
+ await harness.mountCallback({ code: SLACK_OAUTH_CODE });
+
+ // Refused before the API is ever asked, so no code is spent.
+ expect(await harness.installFailureReason()).toMatch(/incomplete response/);
+ expect(harness.exchangeCallCount).toBe(0);
+ }, 30000);
+});
diff --git a/ui/app/(prowler)/integrations/slack/slack-integration-content.tsx b/ui/app/(prowler)/integrations/slack/slack-integration-content.tsx
new file mode 100644
index 0000000000..1d30a270a9
--- /dev/null
+++ b/ui/app/(prowler)/integrations/slack/slack-integration-content.tsx
@@ -0,0 +1,61 @@
+import { getIntegrations } from "@/actions/integrations/integrations";
+import { getSlackAuthorizeUrl } from "@/actions/integrations/slack";
+import { SlackIntegrationManager } from "@/components/integrations/slack/slack-integration-manager";
+import { GENERIC_SERVER_ERROR_MESSAGE } from "@/lib/helper";
+import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
+
+/**
+ * `getIntegrations` throws a `>= 500` answer past its own catch, which covers
+ * only transport. Uncaught it trips the route's error boundary and replaces a
+ * page that could still offer the install, so report it as `{ error }` and take
+ * the page's one error path.
+ */
+const readSlackIntegrations = async (searchParams: URLSearchParams) => {
+ try {
+ return await getIntegrations(searchParams);
+ } catch {
+ // The thrown message can carry the server's own wording; `handleApiResponse`
+ // already reported it to Sentry.
+ return { error: GENERIC_SERVER_ERROR_MESSAGE };
+ }
+};
+
+/**
+ * Split out of `page.tsx` so the browser-mode tests can render it without the
+ * surrounding `ContentLayout`.
+ */
+export async function SlackIntegrationContent() {
+ const searchParams = new URLSearchParams();
+ searchParams.set("filter[integration_type]", INTEGRATION_TYPE.SLACK);
+ // One workspace per tenant, so one row is the whole result set.
+ searchParams.set("page[size]", "1");
+
+ const integrations = await readSlackIntegrations(searchParams);
+ const loadError =
+ integrations && "error" in integrations
+ ? (integrations.error as string)
+ : null;
+ const integration: IntegrationProps | null =
+ (integrations?.data?.[0] as IntegrationProps | undefined) ?? null;
+
+ const authorize = integration ? null : await getSlackAuthorizeUrl();
+
+ return (
+
+ );
+}
diff --git a/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts b/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts
new file mode 100644
index 0000000000..2cb9cd7ee9
--- /dev/null
+++ b/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts
@@ -0,0 +1,291 @@
+/**
+ * Page-level test harness for the Slack integration (Vitest Browser Mode).
+ *
+ * A client renderer cannot render an async server component, so the component is
+ * called and the element it returns is what gets rendered.
+ */
+
+import { revalidatePath } from "next/cache";
+import { createElement } from "react";
+import { vi } from "vitest";
+
+import { BrowserHarness } from "@/__tests__/browser-harness";
+import { handlersForSlack } from "@/__tests__/msw/handlers/slack";
+import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
+import { worker } from "@/__tests__/msw/worker";
+import { render } from "@/__tests__/render-browser";
+import { SlackCallback } from "@/components/integrations/slack/slack-callback";
+
+import { IntegrationsContent } from "../integrations-content";
+
+import { SlackIntegrationContent } from "./slack-integration-content";
+
+interface CallbackParams {
+ code?: string;
+ state?: string;
+ /** Slack's own refusal code, e.g. `access_denied`. */
+ error?: string;
+}
+
+export class SlackIntegrationHarness extends BrowserHarness {
+ get exchangeCallCount(): number {
+ return this.countRequests("POST", "/slack/oauth/exchange");
+ }
+
+ get authorizeUrlCallCount(): number {
+ return this.countRequests("POST", "/slack/oauth/authorize-url");
+ }
+
+ /** Paths the actions asked Next to refresh (`next/cache` is stubbed in this lane). */
+ get revalidatedPaths(): string[] {
+ return vi.mocked(revalidatePath).mock.calls.map(([path]) => path);
+ }
+
+ // --- Mounting -----------------------------------------------------------
+
+ private wireHandlers(): void {
+ // The stub is module-level and shared, so clearing it here is what makes
+ // `revalidatedPaths` mean "since this mount".
+ vi.mocked(revalidatePath).mockClear();
+ worker.use(...handlersForSlack(this.fixture));
+ this.trackRequests(worker);
+ }
+
+ async mount(): Promise {
+ window.history.replaceState(null, "", "/integrations/slack");
+ this.wireHandlers();
+
+ render(await SlackIntegrationContent());
+ }
+
+ async mountCallback({ code, state, error }: CallbackParams): Promise {
+ const params = new URLSearchParams();
+ if (code) params.set("code", code);
+ if (state) params.set("state", state);
+ if (error) params.set("error", error);
+ window.history.replaceState(
+ null,
+ "",
+ `/integrations/slack/callback?${params.toString()}`,
+ );
+ this.wireHandlers();
+
+ render(createElement(SlackCallback));
+ }
+
+ /** Mount the integrations catalogue. No handlers: every card there is static. */
+ mountCatalogue(): void {
+ window.history.replaceState(null, "", "/integrations");
+
+ render(createElement(IntegrationsContent));
+ }
+
+ // --- The integrations catalogue ------------------------------------------
+
+ async listedIntegrations(): Promise {
+ const headings = await this.waitFor(
+ () => {
+ const found = Array.from(
+ this.container.querySelectorAll("h4"),
+ );
+ return found.length > 0 ? found : null;
+ },
+ 5000,
+ "the integrations catalogue",
+ );
+ return headings.map((heading) => (heading.textContent ?? "").trim());
+ }
+
+ offersSlackManagement(): boolean {
+ return this.q('a[href="/integrations/slack"]') !== null;
+ }
+
+ // --- Starting the install -----------------------------------------------
+
+ private connectLink(): HTMLAnchorElement | null {
+ return (
+ Array.from(this.container.querySelectorAll("a")).find((anchor) =>
+ /Add to Slack/.test(anchor.textContent ?? ""),
+ ) ?? null
+ );
+ }
+
+ async authorizeUrl(): Promise {
+ const link = await this.waitFor(
+ () => this.connectLink(),
+ 5000,
+ "the Add to Slack link",
+ );
+ return link.href;
+ }
+
+ /**
+ * Clicks the install affordance and reports where it points. The default
+ * action is cancelled: following the link navigates the test frame off the app.
+ */
+ async connect(): Promise {
+ const link = await this.waitFor(
+ () => this.connectLink(),
+ 5000,
+ "the Add to Slack link",
+ );
+
+ let destination = "";
+ const intercept = (event: MouseEvent) => {
+ event.preventDefault();
+ destination = link.href;
+ };
+ link.addEventListener("click", intercept);
+ try {
+ await this.clickElement(link, { fallbackToDomClick: true });
+ } finally {
+ link.removeEventListener("click", intercept);
+ }
+
+ return destination;
+ }
+
+ offersInstall(): boolean {
+ return this.connectLink() !== null;
+ }
+
+ async waitForUnavailable(): Promise {
+ await this.waitForText(/Slack is not available in this environment yet/);
+ }
+
+ saysUnavailable(): boolean {
+ return this.containsText(/Slack is not available in this environment yet/);
+ }
+
+ saysLoadFailed(): boolean {
+ return this.containsText(/Could not load your Slack integration/);
+ }
+
+ async rateLimitNotice(): Promise {
+ await this.waitForText(/Slack is busy right now/, 10000);
+ const description = await this.waitFor(
+ () => this.q('[data-slot="alert-description"]'),
+ 5000,
+ "the rate limit notice",
+ );
+ return (description.textContent ?? "").trim();
+ }
+
+ async loadErrorNotice(): Promise {
+ await this.waitForText(/Could not load your Slack integration/, 10000);
+ const description = await this.waitFor(
+ () => this.q('[data-slot="alert-description"]'),
+ 5000,
+ "the load error notice",
+ );
+ return (description.textContent ?? "").trim();
+ }
+
+ // --- Connected state ----------------------------------------------------
+
+ /**
+ * Read from the heading element, not the page text: in `textContent`
+ * "Connected to " runs straight into the copy that follows it.
+ */
+ async connectedWorkspaceName(): Promise {
+ const heading = await this.waitFor(
+ () => this.deepestElementMatching(/^Connected to \S/),
+ 5000,
+ "the connected workspace name",
+ );
+ return (heading.textContent ?? "").trim().replace(/^Connected to /, "");
+ }
+
+ /** Last match in document order: every ancestor of a match matches too. */
+ private deepestElementMatching(pattern: RegExp): HTMLElement | null {
+ return (
+ Array.from(this.container.querySelectorAll("*"))
+ .reverse()
+ .find((element) => pattern.test((element.textContent ?? "").trim())) ??
+ null
+ );
+ }
+
+ /**
+ * Keyed on the badge's state attribute, not its copy: the heading beside it
+ * also starts "Connected to …".
+ */
+ async connectionBadge(): Promise {
+ const badge = await this.waitFor(
+ () => this.q("[data-connection-status]"),
+ 5000,
+ "the connection badge",
+ );
+ return (badge.textContent ?? "").trim();
+ }
+
+ /** Absent, or there but disabled: both read as "not offered". */
+ offersConnectionTest(): boolean {
+ const button = this.buttonByText(/Test connection/);
+ return button !== null && !button.disabled;
+ }
+
+ saysChannelIsNextStep(): boolean {
+ return this.containsText(/Choosing a destination channel is the next step/);
+ }
+
+ /**
+ * The "last checked" line as rendered, or null when the page shows none —
+ * which is what a workspace whose connection was never checked shows.
+ */
+ lastCheckedLine(): string | null {
+ const line = Array.from(
+ this.container.querySelectorAll("p"),
+ ).find((p) => /^Last checked:/.test((p.textContent ?? "").trim()));
+ return line ? (line.textContent ?? "").trim() : null;
+ }
+
+ // --- Returning from Slack -----------------------------------------------
+
+ /**
+ * The one element every non-success outcome renders. Keyed on it rather than
+ * the alert title, which is not the same claim on every outcome.
+ */
+ private backLink(): HTMLAnchorElement | null {
+ return (
+ Array.from(this.container.querySelectorAll("a")).find(
+ (anchor) =>
+ anchor.getAttribute("href") === "/integrations/slack" &&
+ /Back to Slack integration/.test(anchor.textContent ?? ""),
+ ) ?? null
+ );
+ }
+
+ async completedInstall(): Promise {
+ const outcome = await this.waitFor(
+ () => this.containsText(/Connected to /) || this.backLink() !== null,
+ 10000,
+ "the callback outcome",
+ );
+ return outcome && this.containsText(/Connected to /);
+ }
+
+ async installFailureReason(): Promise {
+ await this.waitFor(() => this.backLink(), 10000, "the failed callback");
+ const description = await this.waitFor(
+ () => this.q('[data-slot="alert-description"]'),
+ 5000,
+ "the failure reason",
+ );
+ return (description.textContent ?? "").trim();
+ }
+
+ async installFailureTitle(): Promise {
+ await this.waitFor(() => this.backLink(), 10000, "the failed callback");
+ const title = await this.waitFor(
+ () => this.q('[data-slot="alert-title"]'),
+ 5000,
+ "the failure title",
+ );
+ return (title.textContent ?? "").trim();
+ }
+
+ offersRetry(): boolean {
+ return this.backLink() !== null || this.offersInstall();
+ }
+}
diff --git a/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx b/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx
new file mode 100644
index 0000000000..4e00bc4941
--- /dev/null
+++ b/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx
@@ -0,0 +1,217 @@
+/**
+ * Browser-mode tests for the Slack integration page (`/integrations/slack`),
+ * driven through `SlackIntegrationHarness`. MSW answers from handlers derived
+ * from the API contract in `design.md`. The OAuth callback is its own route,
+ * covered in `slack-callback-page.integration.test.tsx`.
+ */
+
+import { describe, expect } from "vitest";
+
+import { it } from "@/__tests__/fixtures";
+import {
+ configuredSlackFixture,
+ connectedSlackFixture,
+ INTEGRATIONS_SERVER_ERROR_DETAIL,
+ slackFixture,
+ unreadableCheckTimeSlackFixture,
+} from "@/__tests__/msw/handlers/slack.fixtures";
+
+import { SlackIntegrationHarness } from "./slack-integration.harness";
+
+/** The workspace the fixtures connect. */
+const WORKSPACE_NAME = "Prowler HQ";
+
+/** The only scopes Prowler asks a workspace for (design D2). */
+const REQUIRED_SCOPES = [
+ "chat:write",
+ "chat:write.public",
+ "channels:read",
+ "groups:read",
+];
+
+describe("starting the install", () => {
+ it("sends the user to Slack's consent screen for the access Prowler needs", async () => {
+ // Given — a tenant with no workspace connected yet.
+ const harness = new SlackIntegrationHarness(slackFixture());
+ await harness.mount();
+
+ const consentScreen = new URL(await harness.connect());
+
+ expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
+ "https://slack.com/oauth/v2/authorize",
+ );
+ const scopes = (consentScreen.searchParams.get("scope") ?? "").split(",");
+ expect(scopes).toHaveLength(REQUIRED_SCOPES.length);
+ expect(scopes).toEqual(expect.arrayContaining(REQUIRED_SCOPES));
+ // The state is server-minted, binding this install to the session
+ // (design D5).
+ expect(consentScreen.searchParams.get("state")).toBeTruthy();
+ }, 30000);
+
+ it("says so when the deployment has no Slack app, instead of offering an install", async () => {
+ // Given — no SLACK_CLIENT_ID/SECRET/REDIRECT_URI, which the API answers
+ // with a 503.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({ appConfigured: false }),
+ );
+
+ await harness.mount();
+
+ // The read itself succeeded: an empty collection is what a deployment with
+ // no Slack app has, so nothing claims it failed.
+ await harness.waitForUnavailable();
+ expect(harness.offersInstall()).toBe(false);
+ expect(harness.saysLoadFailed()).toBe(false);
+ }, 30000);
+
+ it("still says the read failed when the deployment also has no Slack app", async () => {
+ // Given — both states, which coincide during rollout and rollback
+ // (design.md, Migration Plan §2-3 and §5).
+ const harness = new SlackIntegrationHarness(
+ slackFixture({ appConfigured: false, listServerError: true }),
+ );
+
+ await harness.mount();
+
+ // Both notices: the read's is the actionable half (a retry may still show a
+ // workspace this tenant has connected).
+ const notice = await harness.loadErrorNotice();
+ expect(notice).toMatch(/temporarily unavailable/);
+ expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL);
+ expect(harness.saysUnavailable()).toBe(true);
+ // The install is still not on offer: there is no Slack app to install into.
+ expect(harness.offersInstall()).toBe(false);
+ }, 30000);
+
+ it("says Slack is busy, not that the deployment has no Slack app, when it is rate limiting", async () => {
+ // Given — the app is configured; Slack rate limits (429) the call that
+ // mints the consent URL.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({ rateLimited: true }),
+ );
+
+ await harness.mount();
+
+ expect(await harness.rateLimitNotice()).toMatch(/about 30 seconds/);
+ expect(harness.saysUnavailable()).toBe(false);
+ }, 30000);
+
+ it("keeps the page usable when reading the install fails on the server", async () => {
+ // Given — the shared `GET /integrations` read answers 500. The action
+ // throws instead of returning a result, so the page has to catch it:
+ // uncaught, the route's error boundary replaces the Slack page.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({ listServerError: true }),
+ );
+
+ await harness.mount();
+
+ const notice = await harness.loadErrorNotice();
+ expect(notice).toMatch(/temporarily unavailable/);
+ expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL);
+ // The install stays on offer: one read failed, the Slack app is fine.
+ expect(harness.offersInstall()).toBe(true);
+ }, 30000);
+
+ it("keeps the page usable when Slack's own side is broken upstream", async () => {
+ // Given — the `502` the contract reserves for a Slack upstream failure.
+ // The UI's shared 5xx handling throws, so this is the page's other
+ // rejection path.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({ oauthUpstreamError: true }),
+ );
+
+ await harness.mount();
+
+ const notice = await harness.loadErrorNotice();
+ expect(notice).toMatch(/temporarily unavailable/);
+ // 502 is not 503: the app is configured, Slack is down.
+ expect(harness.saysUnavailable()).toBe(false);
+ }, 30000);
+
+ it("names the missing consent URL when a proxy answers that call with an HTML page", async () => {
+ // Given — a 200 carrying a challenge page instead of JSON. Nothing refused
+ // the call, so the action reaches its success path with no URL.
+ const harness = new SlackIntegrationHarness(
+ slackFixture({ authorizeUrlUnreadable: true }),
+ );
+
+ await harness.mount();
+
+ // V8 truncates the parse message to `" {
+ it("identifies the workspace and reports the connection as healthy", async () => {
+ // Given — a finished setup: workspace approved and a destination channel
+ // recorded, which the API requires before it will check a connection.
+ const harness = new SlackIntegrationHarness(configuredSlackFixture());
+ await harness.mount();
+
+ expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
+ expect(await harness.connectionBadge()).toBe("Connected");
+ // One workspace per tenant (design D10): no second install on offer, and no
+ // consent URL minted for a page that would never use it.
+ expect(harness.offersInstall()).toBe(false);
+ expect(harness.authorizeUrlCallCount).toBe(0);
+ }, 30000);
+
+ it("still identifies the workspace before a destination channel is chosen", async () => {
+ // Given — the state the OAuth exchange leaves behind.
+ const harness = new SlackIntegrationHarness(connectedSlackFixture());
+
+ await harness.mount();
+
+ // The configuration carries no channel keys at all, which is "nothing
+ // chosen yet", not a broken install.
+ expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
+ expect(harness.offersInstall()).toBe(false);
+ }, 30000);
+
+ it("reports the connection as never checked, not as broken, before the first check", async () => {
+ // Given — the state the OAuth exchange leaves behind: `connected` is null,
+ // neither true nor false (design.md, "Connection state, in order").
+ const harness = new SlackIntegrationHarness(connectedSlackFixture());
+
+ await harness.mount();
+
+ const badge = await harness.connectionBadge();
+ expect(badge).toBe("Not checked yet");
+ expect(badge).not.toMatch(/Disconnected/);
+ }, 30000);
+
+ it("keeps the page usable when the recorded check time is one no parser can read", async () => {
+ // Given — a finished setup whose `connection_last_checked_at` is a zero
+ // date. `date-fns` throws a RangeError on it, which would replace the whole
+ // page with the route's error boundary.
+ const harness = new SlackIntegrationHarness(
+ unreadableCheckTimeSlackFixture(),
+ );
+
+ await harness.mount();
+
+ expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
+ expect(await harness.connectionBadge()).toBe("Connected");
+ // Nothing to show, so nothing is shown: the same line a workspace that was
+ // never checked renders.
+ expect(harness.lastCheckedLine()).toBeNull();
+ }, 30000);
+
+ it("does not offer a connection check the API is bound to refuse", async () => {
+ // Given — a workspace connected and no destination channel recorded.
+ const harness = new SlackIntegrationHarness(connectedSlackFixture());
+
+ await harness.mount();
+
+ // The check posts to the destination channel, so with none recorded the API
+ // answers 400. Nothing here records one, so it is not offered at all.
+ expect(harness.offersConnectionTest()).toBe(false);
+ expect(harness.saysChannelIsNextStep()).toBe(true);
+ }, 30000);
+});
diff --git a/ui/app/(prowler)/providers/providers-tab-content.tsx b/ui/app/(prowler)/providers/providers-tab-content.tsx
index 22bcd47eb9..c55f8730c6 100644
--- a/ui/app/(prowler)/providers/providers-tab-content.tsx
+++ b/ui/app/(prowler)/providers/providers-tab-content.tsx
@@ -32,13 +32,6 @@ export const ProvidersTabContent = async ({
}: {
searchParams: SearchParamsProps;
}) => {
- // The React Compiler (`reactCompiler: true`) otherwise instruments this as a
- // client component and injects `useMemoCache`, which needs a React dispatcher.
- // An async server component renders once per request, so there is nothing to
- // memoize — and the injected hook makes it uncallable outside a render, which
- // is exactly how the browser-mode tests mount it.
- "use no memo";
-
const isCloudEnvironment = isCloud();
const [providersView, scanConfigsState] = await Promise.all([
loadProvidersAccountsViewData({
diff --git a/ui/changelog.d/slack-integration-connect-workspace.added.md b/ui/changelog.d/slack-integration-connect-workspace.added.md
new file mode 100644
index 0000000000..8352e887ef
--- /dev/null
+++ b/ui/changelog.d/slack-integration-connect-workspace.added.md
@@ -0,0 +1 @@
+Slack integration: connect a Slack workspace from the Integrations page (Prowler Cloud only)
diff --git a/ui/components/icons/services/IconServices.tsx b/ui/components/icons/services/IconServices.tsx
index 77158d8b07..7b84973038 100644
--- a/ui/components/icons/services/IconServices.tsx
+++ b/ui/components/icons/services/IconServices.tsx
@@ -752,6 +752,51 @@ export const JiraIcon: React.FC = ({
);
+export const SlackIcon: React.FC = ({
+ size = 32,
+ width,
+ height,
+ className = "rounded-md",
+ ...props
+}) => (
+
+);
+
export const AWSSecurityHubIcon: React.FC = ({
size = 32,
width,
diff --git a/ui/components/integrations/shared/integration-card-header.tsx b/ui/components/integrations/shared/integration-card-header.tsx
index 6b14ee482e..e78f12ed0d 100644
--- a/ui/components/integrations/shared/integration-card-header.tsx
+++ b/ui/components/integrations/shared/integration-card-header.tsx
@@ -6,18 +6,47 @@ import { ReactNode } from "react";
import { Badge } from "@/components/shadcn";
import { cn } from "@/lib/utils";
+// `null` means never checked, not disconnected: it must not get the fail tokens.
+const CONNECTION_BADGE = {
+ connected: {
+ label: "Connected",
+ className:
+ "bg-bg-pass-secondary text-text-success-primary border-transparent",
+ },
+ disconnected: {
+ label: "Disconnected",
+ className:
+ "bg-bg-fail-secondary text-text-error-primary border-transparent",
+ },
+ unchecked: {
+ label: "Not checked yet",
+ className: "border-border-tag bg-bg-tag text-text-neutral-secondary",
+ },
+} as const;
+
+type ConnectionBadgeState = keyof typeof CONNECTION_BADGE;
+
+const connectionBadgeState = (
+ connected: boolean | null,
+): ConnectionBadgeState =>
+ connected === null ? "unchecked" : connected ? "connected" : "disconnected";
+
+interface IntegrationCardChip {
+ label: string;
+ className?: string;
+}
+
+interface IntegrationConnectionStatus {
+ connected: boolean | null;
+ label?: string;
+}
+
interface IntegrationCardHeaderProps {
icon: ReactNode;
title: string;
subtitle?: string;
- chips?: Array<{
- label: string;
- className?: string;
- }>;
- connectionStatus?: {
- connected: boolean;
- label?: string;
- };
+ chips?: IntegrationCardChip[];
+ connectionStatus?: IntegrationConnectionStatus;
navigationUrl?: string;
}
@@ -29,6 +58,11 @@ export const IntegrationCardHeader = ({
connectionStatus,
navigationUrl,
}: IntegrationCardHeaderProps) => {
+ const badgeState = connectionStatus
+ ? connectionBadgeState(connectionStatus.connected)
+ : null;
+ const badge = badgeState ? CONNECTION_BADGE[badgeState] : null;
+
return (
diff --git a/ui/components/integrations/slack/slack-callback.test.tsx b/ui/components/integrations/slack/slack-callback.test.tsx
new file mode 100644
index 0000000000..4212a001c6
--- /dev/null
+++ b/ui/components/integrations/slack/slack-callback.test.tsx
@@ -0,0 +1,156 @@
+/**
+ * The cases `slack-page.integration.test.tsx` cannot express: it runs the Server
+ * Action as a plain function, so there is no client→server transport to reject,
+ * and its handler only answers the contract's shapes. React error boundaries
+ * cannot see a rejection awaited in an effect, so an uncaught one leaves the
+ * user on the spinner with no error and no way out.
+ */
+
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { IntegrationProps } from "@/types/integrations";
+
+import { SlackCallback } from "./slack-callback";
+
+const COMPLETED_QUERY = "code=slack-code-1f4a&state=st-2f1c9d7a";
+
+const { exchangeSlackOAuthCode, callbackQuery, routerReplace } = vi.hoisted(
+ () => ({
+ exchangeSlackOAuthCode: vi.fn(),
+ callbackQuery: { value: "" },
+ routerReplace: vi.fn(),
+ }),
+);
+
+vi.mock("@/actions/integrations/slack", () => ({ exchangeSlackOAuthCode }));
+
+// One router across renders, so the redirect off the spent code is assertable.
+const router = { replace: routerReplace };
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => router,
+ useSearchParams: () => new URLSearchParams(callbackQuery.value),
+}));
+
+beforeEach(() => {
+ callbackQuery.value = COMPLETED_QUERY;
+ routerReplace.mockClear();
+});
+
+const SPINNER_COPY = /Connecting your Slack workspace/;
+
+/**
+ * Literals, not imports: a rename on the component's side has to fail here.
+ * `FAILURE_TITLE` claims nothing was connected, which only holds for outcomes
+ * that happen before the API consumed the code.
+ */
+const FAILURE_TITLE = "Slack workspace not connected";
+const UNCONFIRMED_TITLE = "Slack install not confirmed";
+
+describe("returning from Slack when the completion answers unexpectedly", () => {
+ it("reports an unconfirmed result instead of spinning forever when the exchange call never comes back", async () => {
+ // The client→server POST itself fails (dropped connection, action id
+ // invalidated by a deploy), so the action's own error handling never runs.
+ exchangeSlackOAuthCode.mockRejectedValue(new TypeError("Failed to fetch"));
+
+ render();
+
+ // The API consumes the single-use code before answering, so the workspace
+ // may well be connected: unknown, not failed.
+ expect(
+ await screen.findByText(/could not confirm whether the workspace/i),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: /Back to Slack integration/ }),
+ ).toHaveAttribute("href", "/integrations/slack");
+ expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
+
+ expect(screen.getByText(UNCONFIRMED_TITLE)).toBeInTheDocument();
+ expect(screen.queryByText(FAILURE_TITLE)).not.toBeInTheDocument();
+ });
+
+ it("still reports the workspace as connected when the created integration carries no configuration", async () => {
+ // The install already succeeded; `configuration` only goes missing on the
+ // client, where the callback reads the workspace name off it.
+ exchangeSlackOAuthCode.mockResolvedValue({
+ integration: {
+ type: "integrations",
+ id: "slack-integration-1",
+ attributes: {
+ inserted_at: "2026-08-10T09:00:00Z",
+ updated_at: "2026-08-10T09:00:00Z",
+ enabled: true,
+ connected: null,
+ connection_last_checked_at: null,
+ integration_type: "slack",
+ },
+ links: { self: "/api/v1/integrations/slack-integration-1" },
+ // Cast: the shape is the one the contract rules out.
+ } as unknown as IntegrationProps,
+ });
+
+ render();
+
+ expect(
+ await screen.findByText(/Connected to your Slack workspace/),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
+ // Keyed on the escape link, the only element unique to the failure branch,
+ // so this holds whichever headline that branch would have carried.
+ expect(
+ screen.queryByRole("link", { name: /Back to Slack integration/ }),
+ ).not.toBeInTheDocument();
+ // `replace`, not `push`: a back navigation must not remount onto the code.
+ expect(routerReplace).toHaveBeenCalledWith("/integrations/slack");
+ });
+});
+
+describe("returning from Slack with an error on the callback URL", () => {
+ it("says the install was declined when Slack reports the approval was refused", async () => {
+ // The one code Slack reliably sends to this redirect.
+ callbackQuery.value = "error=access_denied&state=st-2f1c9d7a";
+
+ render();
+
+ expect(
+ await screen.findByText(/was not approved in Slack/),
+ ).toBeInTheDocument();
+ expect(exchangeSlackOAuthCode).not.toHaveBeenCalled();
+
+ // Slack refused before issuing a code, so the flat "not connected" is a
+ // fact here, unlike in the outcomes that follow an exchange.
+ expect(screen.getByText(FAILURE_TITLE)).toBeInTheDocument();
+ expect(screen.queryByText(UNCONFIRMED_TITLE)).not.toBeInTheDocument();
+ });
+
+ it("names a Slack code it does not recognise, so a new failure reason is still diagnosable", async () => {
+ // Slack publishes no closed set of codes for this redirect, so the guard is
+ // on the shape of the value rather than on an allowlist.
+ callbackQuery.value = "error=invalid_scope&state=st-2f1c9d7a";
+
+ render();
+
+ expect(
+ await screen.findByText(
+ "Slack could not complete the install (invalid_scope).",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("drops a sentence smuggled into the error parameter instead of rendering it as Prowler's own copy", async () => {
+ // The balancing punctuation is the point: it closes Prowler's parenthetical
+ // and reopens it, so the payload would read as Prowler's own sentence.
+ const payload =
+ "). Slack has flagged this workspace. Contact Prowler support at +1-555-0100 to restore alerting (";
+ callbackQuery.value = `error=${encodeURIComponent(payload)}&state=st-2f1c9d7a`;
+
+ render();
+
+ expect(
+ await screen.findByText("Slack could not complete the install."),
+ ).toBeInTheDocument();
+ expect(document.body.textContent).not.toContain("+1-555-0100");
+ expect(document.body.textContent).not.toContain("flagged this workspace");
+ });
+});
diff --git a/ui/components/integrations/slack/slack-callback.tsx b/ui/components/integrations/slack/slack-callback.tsx
new file mode 100644
index 0000000000..7ece84226f
--- /dev/null
+++ b/ui/components/integrations/slack/slack-callback.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { AlertCircle, CircleCheck, Loader2 } from "lucide-react";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useRef, useState } from "react";
+
+import { exchangeSlackOAuthCode } from "@/actions/integrations/slack";
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+ Button,
+} from "@/components/shadcn";
+
+const SLACK_INTEGRATION_PATH = "/integrations/slack";
+
+const STATUS = {
+ CONNECTING: "connecting",
+ CONNECTED: "connected",
+ FAILED: "failed",
+} as const;
+
+type Status = (typeof STATUS)[keyof typeof STATUS];
+
+const UNCONFIRMED_COMPLETION_MESSAGE =
+ "Prowler could not confirm whether the workspace was connected. Open the Slack integration page to check — if none is listed there, start the install again.";
+
+const FAILURE_TITLE = "Slack workspace not connected";
+
+/**
+ * The API consumes the code and upserts the integration before it answers, so an
+ * unreadable or missing answer can still mean a connected workspace. Kept short:
+ * `AlertTitle` clamps to one line.
+ */
+const UNCONFIRMED_TITLE = "Slack install not confirmed";
+
+// `error` comes straight off the URL and is interpolated into Prowler's own copy,
+// so gate on the shape of a code: Slack publishes no closed set of values.
+const REASON_TOKEN = /^[a-z0-9_]{1,48}$/;
+
+const describeSlackError = (reason: string): string => {
+ if (reason === "access_denied") {
+ return "The install was not approved in Slack, so no workspace was connected.";
+ }
+ return REASON_TOKEN.test(reason)
+ ? `Slack could not complete the install (${reason}).`
+ : "Slack could not complete the install.";
+};
+
+/**
+ * Slack's `code` is single-use: `hasStarted` holds the exchange to one run per
+ * mount, and `router.replace` (not `push`) keeps a back navigation from
+ * remounting onto a spent code.
+ */
+export const SlackCallback = () => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [status, setStatus] = useState(STATUS.CONNECTING);
+ const [workspaceName, setWorkspaceName] = useState(null);
+ const [failure, setFailure] = useState("");
+ const [failureTitle, setFailureTitle] = useState(FAILURE_TITLE);
+ const hasStarted = useRef(false);
+
+ useEffect(() => {
+ if (hasStarted.current) return;
+ hasStarted.current = true;
+
+ const slackError = searchParams.get("error");
+ const code = searchParams.get("code");
+ const state = searchParams.get("state");
+
+ // Slack answers a declined install with `error` and no code, so there is
+ // nothing to exchange.
+ if (slackError) {
+ setFailure(describeSlackError(slackError));
+ setStatus(STATUS.FAILED);
+ return;
+ }
+
+ if (!code || !state) {
+ setFailure(
+ "Slack sent an incomplete response back, so the install could not be completed.",
+ );
+ setStatus(STATUS.FAILED);
+ return;
+ }
+
+ const complete = async () => {
+ const result = await exchangeSlackOAuthCode({ code, state });
+
+ if ("integration" in result) {
+ setWorkspaceName(
+ result.integration.attributes?.configuration?.team_name ?? null,
+ );
+ setStatus(STATUS.CONNECTED);
+ router.replace(SLACK_INTEGRATION_PATH);
+ return;
+ }
+
+ if ("unavailable" in result) {
+ setFailure("Slack is not available in this environment yet.");
+ } else if ("rateLimited" in result) {
+ setFailure(result.message);
+ } else if ("unconfirmed" in result) {
+ setFailure(result.message);
+ setFailureTitle(UNCONFIRMED_TITLE);
+ } else {
+ setFailure(result.error);
+ }
+ setStatus(STATUS.FAILED);
+ };
+
+ // A rejection here means the call never came back (stale action id after a
+ // deploy, HTML 502): error boundaries cannot see a rejection awaited inside
+ // an effect, and the once-guard blocks a retry, so the page would spin.
+ void complete().catch(() => {
+ setFailure(UNCONFIRMED_COMPLETION_MESSAGE);
+ setFailureTitle(UNCONFIRMED_TITLE);
+ setStatus(STATUS.FAILED);
+ });
+ }, [router, searchParams]);
+
+ if (status === STATUS.CONNECTING) {
+ return (
+
+
+ Connecting your Slack workspace...
+
+ );
+ }
+
+ if (status === STATUS.CONNECTED) {
+ return (
+
+
+
+ Connected to {workspaceName ?? "your Slack workspace"}
+
+
+ Taking you back to the Slack integration, where you can choose the
+ channel Prowler posts to.
+
+
+ );
+ }
+
+ return (
+
+
+
+ {failureTitle}
+ {failure}
+
+
+
+ );
+};
diff --git a/ui/components/integrations/slack/slack-integration-card.tsx b/ui/components/integrations/slack/slack-integration-card.tsx
new file mode 100644
index 0000000000..005790e071
--- /dev/null
+++ b/ui/components/integrations/slack/slack-integration-card.tsx
@@ -0,0 +1,55 @@
+import { SettingsIcon } from "lucide-react";
+import Link from "next/link";
+
+import { SlackIcon } from "@/components/icons/services/IconServices";
+import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
+import { CustomLink } from "@/components/shadcn/custom/custom-link";
+
+// Placeholder slug: the docs slice writes the page and confirms it.
+const SLACK_DOCS_URL =
+ "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-slack-integration/";
+
+export const SlackIntegrationCard = () => {
+ return (
+
+
+
+
+
+
+
+ Slack
+
+
+
+ Send Prowler messages to your Slack workspace.
+
+
+ Learn more
+
+
+
+
+
+
+
+
+
+
+
+ Connect a Slack workspace and pick the channel Prowler posts to, so
+ your team gets security updates where it already works.
+
+
+
+ );
+};
diff --git a/ui/components/integrations/slack/slack-integration-manager.tsx b/ui/components/integrations/slack/slack-integration-manager.tsx
new file mode 100644
index 0000000000..4580f680b7
--- /dev/null
+++ b/ui/components/integrations/slack/slack-integration-manager.tsx
@@ -0,0 +1,144 @@
+"use client";
+
+import { format, isValid, parseISO } from "date-fns";
+
+import { SlackIcon } from "@/components/icons/services/IconServices";
+import { IntegrationCardHeader } from "@/components/integrations/shared";
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+} from "@/components/shadcn";
+import type { IntegrationProps } from "@/types/integrations";
+
+interface SlackIntegrationManagerProps {
+ /** At most one exists per tenant (one workspace). */
+ integration: IntegrationProps | null;
+ authorizeUrl: string | null;
+ /** This deployment has no Prowler Slack app, so no install can be started. */
+ unavailable: boolean;
+ rateLimitMessage: string | null;
+ loadError: string | null;
+}
+
+export const SlackIntegrationManager = ({
+ integration,
+ authorizeUrl,
+ unavailable,
+ rateLimitMessage,
+ loadError,
+}: SlackIntegrationManagerProps) => {
+ const configuration = integration?.attributes.configuration;
+ const workspaceName = configuration?.team_name;
+ // Absent until a channel is chosen, never present-and-null.
+ const channelId = configuration?.channel_id ?? null;
+
+ const checkedAt = integration?.attributes.connection_last_checked_at;
+ const checkedOn = checkedAt ? parseISO(checkedAt) : null;
+ // `format` throws a RangeError on an unreadable value, which would replace
+ // the page with the route's error boundary: show nothing instead, as for a
+ // connection that was never checked.
+ const lastCheckedOn =
+ checkedOn && isValid(checkedOn) ? format(checkedOn, "yyyy/MM/dd") : null;
+
+ return (
+
+ {rateLimitMessage && (
+
+ Slack is busy right now
+ {rateLimitMessage}
+
+ )}
+
+ {loadError && (
+
+ Could not load your Slack integration
+ {loadError}
+
+ )}
+
+ {/* Replaces the cards, not the whole page: an early return here would
+ swallow the rate-limit and load-error notices above. */}
+ {unavailable ? (
+
+
+ Slack is not available in this environment yet
+
+
+ The Prowler Slack app is not configured here, so no workspace can be
+ connected. Nothing to do on your side — this page starts working as
+ soon as it is.
+
+
+ ) : integration ? (
+
+
+ }
+ title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
+ subtitle="Prowler posts to this workspace only."
+ connectionStatus={{
+ connected: integration.attributes.connected,
+ }}
+ />
+
+
+
+ {/* No check here: it needs a destination channel, and nothing on
+ this page records one yet. */}
+
+ {lastCheckedOn && (
+
+ Last checked:{" "}
+ {lastCheckedOn}
+
+ )}
+ {!channelId && (
+
+ Choosing a destination channel is the next step — the
+ connection is checked against it.
+
+ )}
+
+
+
+ ) : (
+
+
+ }
+ title="No workspace connected"
+ subtitle="Approve Prowler in Slack to connect a workspace. No tokens to copy."
+ />
+
+
+
+
+
+ Prowler asks for permission to post messages and to read the
+ workspace's channel list.
+
+ {authorizeUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+
+ );
+};
diff --git a/ui/lib/integrations/slack-errors.test.ts b/ui/lib/integrations/slack-errors.test.ts
new file mode 100644
index 0000000000..234fb17168
--- /dev/null
+++ b/ui/lib/integrations/slack-errors.test.ts
@@ -0,0 +1,152 @@
+/**
+ * Unit-tested because most of the codes in the mapping belong to flows this
+ * layer does not have yet: the channel picker, the test message, the disconnect.
+ */
+
+import { describe, expect, it } from "vitest";
+
+import {
+ isSlackTokenErrorCode,
+ SLACK_ERROR_CODE,
+ SLACK_ERROR_MESSAGES,
+ SLACK_GENERIC_ERROR_MESSAGE,
+ SLACK_RATE_LIMITED_MESSAGE,
+ SLACK_TOKEN_ERROR_CODES,
+ readSlackFailure,
+ slackErrorMessage,
+ slackRateLimitMessage,
+} from "./slack-errors";
+
+describe("slackErrorMessage", () => {
+ it("prefers the code's own copy over the API's wording", () => {
+ // `detail` states the condition; the code's copy states the fix.
+ const failure = {
+ code: SLACK_ERROR_CODE.WORKSPACE_CONFLICT,
+ detail:
+ "This tenant is already connected to a different Slack workspace.",
+ };
+
+ expect(slackErrorMessage(failure)).toBe(
+ SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.WORKSPACE_CONFLICT],
+ );
+ expect(slackErrorMessage(failure)).toMatch(/Disconnect it/);
+ });
+
+ it("tells the user how to grant a scope Prowler is missing", () => {
+ // A missing scope is fixable by the reader, so the copy names the fix.
+ const message = slackErrorMessage({
+ code: SLACK_ERROR_CODE.MISSING_SCOPE,
+ detail: "missing_scope",
+ });
+
+ expect(message).toMatch(/Connect the workspace again/);
+ expect(message).not.toMatch(/missing_scope/);
+ });
+
+ it("says what to do about each channel refusal", () => {
+ expect(
+ slackErrorMessage({ code: SLACK_ERROR_CODE.CHANNEL_NOT_FOUND }),
+ ).toMatch(/Choose another one/);
+ expect(
+ slackErrorMessage({ code: SLACK_ERROR_CODE.NOT_IN_CHANNEL }),
+ ).toMatch(/Invite @Prowler/);
+ expect(slackErrorMessage({ code: SLACK_ERROR_CODE.NO_PERMISSION })).toMatch(
+ /Choose another channel/,
+ );
+ });
+
+ it("points every dead-credential code at reconnecting, not at retrying", () => {
+ for (const code of SLACK_TOKEN_ERROR_CODES) {
+ // Revoked, invalid, inactive or expired: no retry helps for any of them.
+ expect(slackErrorMessage({ code })).toMatch(
+ /Connect the workspace again to restore access/,
+ );
+ expect(isSlackTokenErrorCode(code)).toBe(true);
+ }
+ });
+
+ it("does not treat an actionable refusal as a dead credential", () => {
+ expect(isSlackTokenErrorCode(SLACK_ERROR_CODE.MISSING_SCOPE)).toBe(false);
+ expect(isSlackTokenErrorCode(null)).toBe(false);
+ expect(isSlackTokenErrorCode(undefined)).toBe(false);
+ });
+
+ it("falls back to the API's detail for a code it does not know", () => {
+ expect(
+ slackErrorMessage({
+ code: "some_future_slack_reason",
+ detail: "Slack said no.",
+ }),
+ ).toBe("Slack said no.");
+ });
+
+ it("falls back to the generic line when there is neither", () => {
+ expect(slackErrorMessage({ code: null, detail: null })).toBe(
+ SLACK_GENERIC_ERROR_MESSAGE,
+ );
+ expect(slackErrorMessage(null)).toBe(SLACK_GENERIC_ERROR_MESSAGE);
+ expect(
+ slackErrorMessage({ detail: " " }, "Could not read channels."),
+ ).toBe("Could not read channels.");
+ });
+});
+
+describe("slackRateLimitMessage", () => {
+ it("names the wait Slack asked for", () => {
+ expect(slackRateLimitMessage(30)).toMatch(/about 30 seconds/);
+ expect(slackRateLimitMessage(1)).toMatch(/about 1 second\b/);
+ expect(slackRateLimitMessage(90)).toMatch(/about 2 minutes/);
+ });
+
+ it("still says to come back when Slack named no wait", () => {
+ expect(slackRateLimitMessage(null)).toBe(SLACK_RATE_LIMITED_MESSAGE);
+ expect(slackRateLimitMessage(0)).toBe(SLACK_RATE_LIMITED_MESSAGE);
+ });
+});
+
+describe("readSlackFailure", () => {
+ it("reads the code, the detail and the wait off a JSON:API refusal", async () => {
+ const response = new Response(
+ JSON.stringify({
+ errors: [
+ {
+ status: "429",
+ detail: "Slack is rate limiting requests from Prowler.",
+ source: { pointer: "/data" },
+ },
+ ],
+ }),
+ { status: 429, headers: { "Retry-After": "30" } },
+ );
+
+ const failure = await readSlackFailure(response);
+
+ expect(failure).toEqual({
+ status: 429,
+ code: null,
+ detail: "Slack is rate limiting requests from Prowler.",
+ retryAfterSeconds: 30,
+ });
+ });
+
+ it("survives a body that is not JSON:API at all", async () => {
+ const failure = await readSlackFailure(
+ new Response("Bad gateway", { status: 502 }),
+ );
+
+ expect(failure).toEqual({
+ status: 502,
+ code: null,
+ detail: null,
+ retryAfterSeconds: null,
+ });
+ });
+
+ it("ignores a Retry-After it cannot use", async () => {
+ const failure = await readSlackFailure(
+ new Response("{}", { status: 429, headers: { "Retry-After": "soon" } }),
+ );
+
+ expect(failure.retryAfterSeconds).toBeNull();
+ });
+});
diff --git a/ui/lib/integrations/slack-errors.ts b/ui/lib/integrations/slack-errors.ts
new file mode 100644
index 0000000000..8799907ae2
--- /dev/null
+++ b/ui/lib/integrations/slack-errors.ts
@@ -0,0 +1,135 @@
+export const SLACK_ERROR_CODE = {
+ MISSING_SCOPE: "missing_scope",
+ CHANNEL_NOT_FOUND: "channel_not_found",
+ NOT_IN_CHANNEL: "not_in_channel",
+ NO_PERMISSION: "no_permission",
+ TOKEN_REVOKED: "token_revoked",
+ INVALID_AUTH: "invalid_auth",
+ ACCOUNT_INACTIVE: "account_inactive",
+ TOKEN_EXPIRED: "token_expired",
+ /** One workspace per tenant. */
+ WORKSPACE_CONFLICT: "slack_workspace_conflict",
+} as const;
+
+export type SlackErrorCode =
+ (typeof SLACK_ERROR_CODE)[keyof typeof SLACK_ERROR_CODE];
+
+/**
+ * The grant itself is dead: reconnecting is the only way out, not retrying. The
+ * API answers these with `400`, not `401`, so they are not mistaken for an
+ * expired Prowler session.
+ */
+export const SLACK_TOKEN_ERROR_CODES = [
+ SLACK_ERROR_CODE.TOKEN_REVOKED,
+ SLACK_ERROR_CODE.INVALID_AUTH,
+ SLACK_ERROR_CODE.ACCOUNT_INACTIVE,
+ SLACK_ERROR_CODE.TOKEN_EXPIRED,
+] as const;
+
+export type SlackTokenErrorCode = (typeof SLACK_TOKEN_ERROR_CODES)[number];
+
+export const isSlackTokenErrorCode = (
+ code: string | null | undefined,
+): code is SlackTokenErrorCode =>
+ SLACK_TOKEN_ERROR_CODES.includes(code as SlackTokenErrorCode);
+
+export const SLACK_GENERIC_ERROR_MESSAGE =
+ "Slack could not complete that request. Try again in a moment.";
+
+export const SLACK_RATE_LIMITED_MESSAGE =
+ "Slack is rate limiting Prowler right now. Try again in a few moments.";
+
+/**
+ * For a `2xx` the UI could not read. Not phrased as a failure: the install
+ * happened, only the workspace cannot be named.
+ */
+export const SLACK_UNREADABLE_RESULT_MESSAGE =
+ "Prowler could not read the result of the install. Open the Slack integration page to see the workspace — if none is listed there, start the install again.";
+
+const RECONNECT = "Connect the workspace again to restore access.";
+
+export const SLACK_ERROR_MESSAGES = {
+ [SLACK_ERROR_CODE.MISSING_SCOPE]:
+ "Prowler is missing a permission it needs in Slack. Connect the workspace again and approve the access Prowler asks for.",
+ [SLACK_ERROR_CODE.CHANNEL_NOT_FOUND]:
+ "That channel no longer exists in the workspace. Choose another one.",
+ [SLACK_ERROR_CODE.NOT_IN_CHANNEL]:
+ "Prowler is not in that channel. Invite @Prowler to it in Slack, or choose a channel it can already post to.",
+ [SLACK_ERROR_CODE.NO_PERMISSION]:
+ "Slack did not allow Prowler to post there. Choose another channel, or ask a workspace admin to allow it.",
+ [SLACK_ERROR_CODE.TOKEN_REVOKED]: `Prowler's access to Slack was revoked. ${RECONNECT}`,
+ [SLACK_ERROR_CODE.INVALID_AUTH]: `Slack no longer accepts Prowler's credential. ${RECONNECT}`,
+ [SLACK_ERROR_CODE.ACCOUNT_INACTIVE]: `The Slack account Prowler was installed with is no longer active. ${RECONNECT}`,
+ [SLACK_ERROR_CODE.TOKEN_EXPIRED]: `Prowler's Slack credential has expired. ${RECONNECT}`,
+ [SLACK_ERROR_CODE.WORKSPACE_CONFLICT]:
+ "Prowler is already connected to a different Slack workspace. Disconnect it before connecting another one.",
+} as const satisfies Record;
+
+/** The parts of a JSON:API error this mapping reads. */
+export interface SlackErrorSource {
+ code?: string | null;
+ detail?: string | null;
+}
+
+export interface SlackApiFailure extends SlackErrorSource {
+ status: number;
+ retryAfterSeconds: number | null;
+}
+
+const isKnownCode = (code: string | null | undefined): code is SlackErrorCode =>
+ typeof code === "string" &&
+ Object.prototype.hasOwnProperty.call(SLACK_ERROR_MESSAGES, code);
+
+/**
+ * Copy for a refusal: Prowler's wording for a known `code`, else the API's
+ * `detail`, else `fallback`.
+ */
+export const slackErrorMessage = (
+ error: SlackErrorSource | null | undefined,
+ fallback: string = SLACK_GENERIC_ERROR_MESSAGE,
+): string => {
+ if (isKnownCode(error?.code)) return SLACK_ERROR_MESSAGES[error.code];
+ return error?.detail?.trim() || fallback;
+};
+
+const describeWait = (seconds: number): string => {
+ if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
+ const minutes = Math.ceil(seconds / 60);
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
+};
+
+export const slackRateLimitMessage = (
+ retryAfterSeconds: number | null,
+): string => {
+ if (retryAfterSeconds === null || retryAfterSeconds <= 0) {
+ return SLACK_RATE_LIMITED_MESSAGE;
+ }
+ return `Slack is rate limiting Prowler right now. Try again in about ${describeWait(
+ Math.ceil(retryAfterSeconds),
+ )}.`;
+};
+
+const retryAfterFrom = (response: Response): number | null => {
+ const header = response.headers.get("retry-after");
+ if (!header) return null;
+ const seconds = Number(header.trim());
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
+};
+
+/**
+ * Read a non-OK Slack response into the failure it describes. Never throws: a
+ * body that is not JSON:API still yields a failure carrying the status.
+ */
+export const readSlackFailure = async (
+ response: Response,
+): Promise => {
+ const body = await response.json().catch(() => null);
+ const error = Array.isArray(body?.errors) ? body.errors[0] : null;
+
+ return {
+ status: response.status,
+ code: typeof error?.code === "string" ? error.code : null,
+ detail: typeof error?.detail === "string" ? error.detail : null,
+ retryAfterSeconds: retryAfterFrom(response),
+ };
+};
diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts
index 77ee623ad3..dc2e4b5c2e 100644
--- a/ui/types/integrations.ts
+++ b/ui/types/integrations.ts
@@ -2,7 +2,15 @@ import { z } from "zod";
import type { TaskState } from "@/types/tasks";
-export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira";
+export const INTEGRATION_TYPE = {
+ AMAZON_S3: "amazon_s3",
+ AWS_SECURITY_HUB: "aws_security_hub",
+ JIRA: "jira",
+ SLACK: "slack",
+} as const;
+
+export type IntegrationType =
+ (typeof INTEGRATION_TYPE)[keyof typeof INTEGRATION_TYPE];
export const JIRA_DISPATCH_MODE = {
INDIVIDUAL: "individual",
@@ -68,7 +76,10 @@ export interface IntegrationProps {
inserted_at: string;
updated_at: string;
enabled: boolean;
- connected: boolean;
+ // `null` until a connection check has run: never verified, neither working
+ // nor broken. A Slack install starts here, and returns here on a channel
+ // change.
+ connected: boolean | null;
connection_last_checked_at: string | null;
integration_type: IntegrationType;
configuration: {
@@ -87,6 +98,13 @@ export interface IntegrationProps {
domain?: string;
projects?: { [key: string]: string };
issue_types?: { [key: string]: string[] };
+ // Slack specific configuration, server-owned. The channel keys are absent
+ // until one is chosen, not present and null: read them with `?? null`.
+ team_id?: string;
+ team_name?: string;
+ bot_user_id?: string;
+ channel_id?: string;
+ channel_name?: string;
[key: string]: unknown;
};
url?: string;
diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts
index dac9e8a762..81b21dbd70 100644
--- a/ui/vitest.config.ts
+++ b/ui/vitest.config.ts
@@ -1,9 +1,34 @@
-import react from "@vitejs/plugin-react";
+import react, { type BabelOptions } from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
+import fs from "fs";
import path from "path";
import type { TestProjectConfiguration } from "vitest/config";
import { defineConfig } from "vitest/config";
+/**
+ * Next runs the React Compiler on the client compilation only — its
+ * `getReactCompilerPlugins` returns nothing when `isServer` — so a Server
+ * Component ships uncompiled. Mirror that: compiled, it calls `useMemoCache`
+ * on the active dispatcher, which a harness invoking the component as a
+ * function has none of, and `react/compiler-runtime` reads the client
+ * internals the `react-server` build does not export anyway.
+ */
+const isServerModule = (id: string): boolean => {
+ const file = id.split("?")[0];
+ if (!file.includes("/app/")) return false;
+ try {
+ return !/^\s*["']use client["']/.test(fs.readFileSync(file, "utf8"));
+ } catch {
+ return false;
+ }
+};
+
+const reactCompilerBabel = (id: string): BabelOptions => ({
+ plugins: isServerModule(id)
+ ? []
+ : [["babel-plugin-react-compiler", { target: "19" }]],
+});
+
export default defineConfig(() => {
const apiBaseUrl = process.env.UI_API_BASE_URL ?? "http://localhost/api/v1";
@@ -57,13 +82,7 @@ export default defineConfig(() => {
},
{
extends: true,
- plugins: [
- react({
- babel: {
- plugins: [["babel-plugin-react-compiler", { target: "19" }]],
- },
- }),
- ],
+ plugins: [react({ babel: reactCompilerBabel })],
test: {
name: "integration",
setupFiles: ["./vitest.integration.setup.ts"],
@@ -109,6 +128,9 @@ export default defineConfig(() => {
// React runtime (pre-bundle so a cold run doesn't re-optimize and
// reload mid-test — see the on-demand-reload note above).
"react-dom/client",
+ // What the compiler's output imports. `@vitejs/plugin-react` adds it
+ // itself only when `babel` is a plain object, and ours is a function.
+ "react/compiler-runtime",
// Next runtime
"next/headers",