diff --git a/docs/images/prowler-app/alerts/create-alert-modal.png b/docs/images/prowler-app/alerts/create-alert-modal.png index 57f7bd32c8..910c4b30d6 100644 Binary files a/docs/images/prowler-app/alerts/create-alert-modal.png and b/docs/images/prowler-app/alerts/create-alert-modal.png differ diff --git a/docs/images/prowler-app/alerts/edit-alert-test.png b/docs/images/prowler-app/alerts/edit-alert-test.png index ecbf038dd1..e36b86113e 100644 Binary files a/docs/images/prowler-app/alerts/edit-alert-test.png and b/docs/images/prowler-app/alerts/edit-alert-test.png differ diff --git a/docs/images/prowler-app/slack/channel-picker.png b/docs/images/prowler-app/slack/channel-picker.png index 5b02a986ae..bb1bf33bc0 100644 Binary files a/docs/images/prowler-app/slack/channel-picker.png and b/docs/images/prowler-app/slack/channel-picker.png differ diff --git a/docs/images/prowler-app/slack/connected-workspace.png b/docs/images/prowler-app/slack/connected-workspace.png index ec99ccb880..ae3437d84e 100644 Binary files a/docs/images/prowler-app/slack/connected-workspace.png and b/docs/images/prowler-app/slack/connected-workspace.png differ diff --git a/docs/images/prowler-app/slack/disconnect-confirmation.png b/docs/images/prowler-app/slack/disconnect-confirmation.png index 830847e7f7..77e732a846 100644 Binary files a/docs/images/prowler-app/slack/disconnect-confirmation.png and b/docs/images/prowler-app/slack/disconnect-confirmation.png differ diff --git a/docs/images/prowler-app/slack/no-workspace-connected.png b/docs/images/prowler-app/slack/no-workspace-connected.png index de0c47f4b7..513eff04ad 100644 Binary files a/docs/images/prowler-app/slack/no-workspace-connected.png and b/docs/images/prowler-app/slack/no-workspace-connected.png differ diff --git a/docs/user-guide/tutorials/prowler-app-slack-integration.mdx b/docs/user-guide/tutorials/prowler-app-slack-integration.mdx index 65be6a94ac..f9ae3f99b1 100644 --- a/docs/user-guide/tutorials/prowler-app-slack-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-slack-integration.mdx @@ -58,12 +58,14 @@ Two of these read more broadly than they behave, and both are worth understandin On the consent screen, `chat:write.public` reads as permission to post in any public channel. Prowler never uses it that way: **Prowler only ever posts to the channels authorized on the integration.** The scope exists so that authorizing a public channel does not also require someone to invite the Prowler app to it first. +{/* The Prowler UI deep-links to this heading's anchor, so rewording the heading breaks that link. */} + ### Why a Private Channel Is Missing From the Channel List -`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler` to it in Slack: +`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler Cloud` to it in Slack: ```text -/invite @Prowler +/invite @Prowler Cloud ``` That invite is issued in Slack, by that channel's own members, and **the invite itself is the permission grant** — no scope bypasses it. Prowler ships no in-product flow to get the app invited, because the decision belongs to the channel's members. After inviting the app, click **Refresh channels** to re-read the list. @@ -100,14 +102,14 @@ Prowler posts to the channels authorized on the integration. Several channels ca ![Destination channels selection listing public channels and an invited private channel marked Private](/images/prowler-app/slack/channel-picker.png) -2. Select one or more channels. A selected private channel keeps its lock and **Private** identification with the list closed, so the authorized set stays readable at a glance. +2. Select one or more channels. A selected private channel keeps the same **Private** marking with the list closed, so the authorized set stays readable at a glance. 3. Click **Save channels**. Prowler validates the selection against Slack and derives each channel name itself, so a recorded name can never drift from the channel it belongs to. Once the set is saved, the page reports where Prowler posts and runs the connection check over it. -If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler` to a private one, then click **Refresh channels**. +If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler Cloud` to a private one, then click **Refresh channels**. -A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out. +A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler Cloud` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out. Saving a new selection replaces the authorized set: channels left out of it stop being authorized, and channels added to it are authorized but not yet confirmed. Changing which channels are in the set also resets the integration's connection state, so the check runs again over the new set — reordering the same channels does not. Saving an empty selection leaves the integration with no authorized channels, and **Test connection** cannot be run again until at least one channel is authorized. @@ -159,7 +161,7 @@ The Slack management page reports the state of the connection and offers these a | Button | Purpose | Notes | |--------|---------|-------| | **Test connection** | Verify the credential and every authorized channel, and confirm the ones not confirmed yet | Posts the confirmation message once per channel and updates the last-checked time. Cannot be run until at least one channel is authorized | -| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler` to a private channel | +| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler Cloud` to a private channel | | **Save channels** | Record the selected channels as the integration's authorized set | Enabled once the selection differs from the authorized set | | **Disconnect** | Remove the integration and attempt to revoke access at Slack | ⚠️ **Cannot be undone** — confirm before disconnecting | @@ -171,7 +173,7 @@ The Prowler Slack app is not configured for the deployment being used, so no wor ### A Private Channel Does Not Appear in the Channel List -The Prowler app has not been invited to it. In Slack, run `/invite @Prowler` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in. +The Prowler app has not been invited to it. In Slack, run `/invite @Prowler Cloud` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in. ### Connection Test Fails diff --git a/ui/__tests__/msw/handlers/slack.fixtures.ts b/ui/__tests__/msw/handlers/slack.fixtures.ts index bdb8e3220a..ec267fed5b 100644 --- a/ui/__tests__/msw/handlers/slack.fixtures.ts +++ b/ui/__tests__/msw/handlers/slack.fixtures.ts @@ -73,7 +73,7 @@ export interface SlackConnectionFixture { export interface SlackChannelFixture { id: string; name: string; - /** Private channels are listed only where `@Prowler` has been invited. */ + /** Private channels are listed only where `@Prowler Cloud` has been invited. */ isPrivate: boolean; } @@ -332,7 +332,7 @@ export const SLACK_CHANNEL_NOT_FOUND_REFUSAL: SlackRefusalFixture = { /** * The channel is fine, the Prowler app is simply not in it — fixed with - * `/invite @Prowler`. Identical `detail` to the refusal above, deliberately. + * `/invite @Prowler Cloud`. Identical `detail` to the refusal above, deliberately. */ export const SLACK_NOT_IN_CHANNEL_REFUSAL: SlackRefusalFixture = { status: 400, diff --git a/ui/app/(prowler)/alerts/alerts-page.harness.ts b/ui/app/(prowler)/alerts/alerts-page.harness.ts index aab52fefa2..870a207434 100644 --- a/ui/app/(prowler)/alerts/alerts-page.harness.ts +++ b/ui/app/(prowler)/alerts/alerts-page.harness.ts @@ -384,7 +384,7 @@ export class AlertsPageHarness extends BrowserHarness { ).map((chip) => (chip.textContent ?? "").replace(/\s+/g, " ").trim()); } - /** The chip renders `#name` with an sr-only "Private" marker. */ + /** The chip renders `#name` followed by a "Private" badge when it is one. */ private static toChannelChip(text: string): SelectedChannelChip { return { isPrivate: /Private/.test(text), diff --git a/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts b/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts index b3b735cad0..3504f72ef5 100644 --- a/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts +++ b/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts @@ -15,6 +15,10 @@ import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures"; import { worker } from "@/__tests__/msw/worker"; import { render } from "@/__tests__/render-browser"; import { setSlackAuthorizedChannels } from "@/actions/integrations/slack"; +import { + CHECK_STATUS, + type CheckStatus, +} from "@/components/integrations/slack/slack-connection-check-status"; import { SLACK_CONNECT_PARAMS } from "@/lib/integrations/slack-connect-status"; import { IntegrationsContent } from "../integrations-content"; @@ -335,6 +339,69 @@ export class SlackIntegrationHarness extends BrowserHarness { ); } + async connectionSuccessToast(): Promise { + return this.waitFor( + () => this.toastText(/Connection test successful/), + 15000, + "the connection success toast", + ); + } + + /** + * The check's standing state, read from the card's own marker. The status is + * the card's alone: the toast reports a check that just ran and then goes, + * where this retires on its own once the channels it covered have moved. + */ + async connectionCheckStatus(): Promise { + const region = await this.waitFor( + () => this.q("[data-connection-check-status]"), + 10000, + "the connection check status", + ); + return region.getAttribute("data-connection-check-status") as CheckStatus; + } + + /** + * What the card says the last check found, or null when it shows no outcome + * at all. The element is always mounted — `sr-only` while empty — so an empty + * one has to read as "no outcome", not as an outcome that says nothing. + */ + connectionCheckOutcome(): string | null { + const outcome = this.q("[data-connection-check-outcome]"); + return (outcome?.textContent ?? "").replace(/\s+/g, " ").trim() || null; + } + + /** The same copy, waited for: the outcome lands a render after the answer. */ + async connectionCheckOutcomeText(): Promise { + return this.waitFor( + () => this.connectionCheckOutcome(), + 15000, + "the connection check outcome", + ); + } + + /** + * Wait until the card has taken its finding back — no outcome, and resting. + * Both, together: a status that says nothing while the line it decorated is + * still on screen would be the half-retired state the derivation exists to + * make impossible. + */ + async waitForRetiredConnectionCheck(): Promise { + await this.waitFor( + () => { + const status = this.q("[data-connection-check-status]")?.getAttribute( + "data-connection-check-status", + ); + return status === CHECK_STATUS.IDLE && + this.connectionCheckOutcome() === null + ? true + : null; + }, + 10000, + "the connection check finding to be retired", + ); + } + /** * The "last checked" line as rendered, or null when the page shows none — * which is what a workspace whose connection was never checked shows. @@ -342,7 +409,7 @@ export class SlackIntegrationHarness extends BrowserHarness { lastCheckedLine(): string | null { const line = Array.from( this.container.querySelectorAll("p"), - ).find((p) => /^Last checked:/.test((p.textContent ?? "").trim())); + ).find((p) => /^Last checked\b/.test((p.textContent ?? "").trim())); return line ? (line.textContent ?? "").trim() : null; } @@ -350,16 +417,24 @@ export class SlackIntegrationHarness extends BrowserHarness { return this.countRequests("POST", "/connection"); } - /** The outcome of a check under way, started by the button or by a save. */ + /** + * The outcome of a check under way, started by the button or by a save, read + * from the card's status. + * + * Deliberately not read from the copy: the toast titles the page has always + * raised are the only text saying "Connection test succeeded/failed", and + * they are portaled outside the card — so a reader going by text would settle + * on the toast and report an outcome the card never showed. The toasts have + * assertions of their own, which is what keeps the two surfaces independent. + */ async connectionOutcome(): Promise { return this.waitFor( () => { - if (this.containsText(/Connection test successful/)) { - return CONNECTION_OUTCOME.SUCCESS; - } - if (this.containsText(/Connection test failed/)) { - return CONNECTION_OUTCOME.FAILURE; - } + const status = this.q("[data-connection-check-status]")?.getAttribute( + "data-connection-check-status", + ); + if (status === CHECK_STATUS.PASSED) return CONNECTION_OUTCOME.SUCCESS; + if (status === CHECK_STATUS.FAILED) return CONNECTION_OUTCOME.FAILURE; return null; }, 15000, @@ -508,8 +583,8 @@ export class SlackIntegrationHarness extends BrowserHarness { /** * Re-read the workspace's channels, the way a user does after inviting - * `@Prowler` to one in Slack. Waits for the read to have settled, not for the - * click alone. + * `@Prowler Cloud` to one in Slack. Waits for the read to have settled, not + * for the click alone. */ async refreshChannels(): Promise { const readsBefore = this.channelListCallCount; @@ -783,11 +858,16 @@ export class SlackIntegrationHarness extends BrowserHarness { "the authorized channel chips", ); + // Order-insensitive: where the chip puts its "Private" marker relative to + // the name is a presentation choice, not something to assert through. return chips.map((chip) => { const text = (chip.textContent ?? "").trim(); return { - name: text.replace(/^Private/, "").replace(/^#/, ""), - isPrivate: /^Private/.test(text), + name: text + .replace(/Private/g, "") + .replace(/^#/, "") + .trim(), + isPrivate: /Private/.test(text), }; }); } @@ -825,14 +905,27 @@ export class SlackIntegrationHarness extends BrowserHarness { return this.containsText(/Could not read the workspace/); } + private channelInviteHintParagraph(): HTMLElement | null { + return ( + Array.from(this.container.querySelectorAll("p")).find( + (element) => /invites? @Prowler Cloud/.test(element.textContent ?? ""), + ) ?? null + ); + } + /** The invite copy that says how to make a private channel appear. */ channelInviteHint(): string | null { - const hint = Array.from( - this.container.querySelectorAll("p"), - ).find((element) => /invites? @Prowler/.test(element.textContent ?? "")); + const hint = this.channelInviteHintParagraph(); return hint ? (hint.textContent ?? "").trim() : null; } + /** Where the invite hint sends a user stuck on a missing private channel. */ + channelInviteHintDocsUrl(): string | null { + const link = + this.channelInviteHintParagraph()?.querySelector("a"); + return link ? link.href : null; + } + // --- Disconnecting ------------------------------------------------------ get disconnectCallCount(): number { diff --git a/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx b/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx index 05235da60c..96292509e1 100644 --- a/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx +++ b/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx @@ -205,8 +205,24 @@ describe("a connected workspace", () => { expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME); expect(await harness.connectionBadge()).toBe("Connected"); + // Read positively as well as negatively (:254): without this, a reworded + // line the harness stops recognising would leave that null-only check + // passing vacuously. + expect(harness.lastCheckedLine()).toMatch(/2026\/08\/10/); expect(await harness.offersConnectionTest()).toBe(true); expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS); + // And — the card says what the check reached, naming the one channel it + // covered. Asserted off the card's own region, not the page's text: the + // toast below says "Connection test successful" too, so a text match would + // stand with this line never rendered. + expect(await harness.connectionCheckOutcomeText()).toBe( + `#${SLACK_PUBLIC_CHANNEL.name} is reachable.`, + ); + // And — the toast, which is the other half and goes on its own. Read + // separately so neither surface can vouch for the other. + expect(await harness.connectionSuccessToast()).toMatch( + /Connection test successful/, + ); // 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); @@ -356,8 +372,8 @@ describe("authorizing destination channels", () => { }, 60000); it("offers a private channel the app was invited to, marked as private, and authorizes it", async () => { - // Given — `@Prowler` was invited to one private channel; `groups:read` is - // membership-gated (D2). + // Given — `@Prowler Cloud` was invited to one private channel; + // `groups:read` is membership-gated (D2). const harness = new SlackIntegrationHarness(connectedSlackFixture()); await harness.mount(); @@ -381,7 +397,7 @@ describe("authorizing destination channels", () => { ]); }, 60000); - it("offers a private channel once @Prowler is invited to it and the list is refreshed", async () => { + it("offers a private channel once @Prowler Cloud is invited to it and the list is refreshed", async () => { // Given — a workspace whose only channels are public: `groups:read` is // membership-gated (design D2). const harness = new SlackIntegrationHarness( @@ -397,8 +413,8 @@ describe("authorizing destination channels", () => { SLACK_PRIVATE_CHANNEL.name, ); - // When — `@Prowler` is invited to a private channel, and the user refreshes - // instead of reconnecting the workspace. + // When — `@Prowler Cloud` is invited to a private channel, and the user + // refreshes instead of reconnecting the workspace. harness.fixture.channels.push({ ...SLACK_PRIVATE_CHANNEL }); await harness.refreshChannels(); @@ -423,7 +439,7 @@ describe("authorizing destination channels", () => { // Then — the user is told what to do, not merely that the list is empty. const message = await harness.channelPickerMessage(); expect(message).toMatch(/No channels available yet/); - expect(message).toMatch(/invite @Prowler/); + expect(message).toMatch(/invite @Prowler Cloud/); expect(await harness.authorizedChannels()).toEqual([]); expect(await harness.offersConnectionTest()).toBe(false); }, 30000); @@ -443,6 +459,12 @@ describe("authorizing destination channels", () => { // Then expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS); expect(harness.connectionCheckCallCount).toBe(1); + // And — the card names the channel the chained check covered, which the + // save hands it directly: the set the check ran against is not on record + // as state yet when the summary is built. + expect(await harness.connectionCheckOutcomeText()).toBe( + `#${SLACK_PUBLIC_CHANNEL.name} is reachable.`, + ); // And — everything waiting on a destination moves with the save, in the // same paint: no reload to find the check on offer for later. expect(await harness.offersConnectionTest()).toBe(true); @@ -474,6 +496,11 @@ describe("authorizing destination channels", () => { // Then — only the check failed, so the destinations stay on record, and // the failure names the one channel Slack refused. expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.FAILURE); + // The card and the toast each name it, and each is read where it lives: + // the toast goes, and what the card keeps is what the user comes back to. + expect(await harness.connectionCheckOutcomeText()).toMatch( + new RegExp(`^Slack refused #${SLACK_PRIVATE_CHANNEL.name}\\b`), + ); expect(await harness.connectionFailureToast()).toMatch( new RegExp(`Slack refused #${SLACK_PRIVATE_CHANNEL.name}`), ); @@ -516,6 +543,41 @@ describe("authorizing destination channels", () => { expect(harness.offersChannelsSave()).toBe(false); }, 60000); + it("takes back a passing result once the set it vouched for is no longer the set on record", async () => { + // Given — a check that passed against the one channel then authorized, said + // in the card in those terms. + const harness = new SlackIntegrationHarness(configuredSlackFixture()); + await harness.mount(); + expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS); + expect(await harness.connectionCheckOutcomeText()).toBe( + `#${SLACK_PUBLIC_CHANNEL.name} is reachable.`, + ); + + // When — a channel nothing has checked joins the record, from elsewhere, so + // no new check runs to overwrite the standing one. + await harness.channelsRecordedElsewhere([ + SLACK_PUBLIC_CHANNEL.name, + SLACK_SECOND_PUBLIC_CHANNEL.name, + ]); + await harness.refreshPageData(); + + // Then — the result is withdrawn rather than left standing: a line saying + // the channels are reachable, over a set one of them was never tried + // against, would be a claim the check never made. + await harness.waitForRetiredConnectionCheck(); + // And — the badge steps back with it, because the record does too: the API + // clears its own verdict on a changed set for the same reason the card + // clears the finding, so the two never disagree about what was checked. + expect(await harness.connectionBadge()).toBe("Not checked yet"); + // And — only the finding is withdrawn, not the setup: the wider set is on + // record, with the check still there to be run over it. + expect(await harness.authorizedChannels()).toEqual([ + SLACK_PUBLIC_CHANNEL.name, + SLACK_SECOND_PUBLIC_CHANNEL.name, + ]); + expect(await harness.offersConnectionTest()).toBe(true); + }, 60000); + it("says which permission is missing when Slack refuses the channel listing, leaving the authorized set alone", async () => { // Given — an authorized set, and an install missing a scope the listing // needs. The API names it in `code` (contract, Errors), not in `detail`. @@ -536,7 +598,7 @@ describe("authorizing destination channels", () => { // Slack's reason is a protocol token: it travels in `code` and is never // shown. expect(message).not.toMatch(SLACK_MISSING_SCOPE_CODE); - expect(harness.channelInviteHint()).toMatch(/invites @Prowler/); + expect(harness.channelInviteHint()).toMatch(/invites @Prowler Cloud/); // And — a listing Prowler could not read says nothing about the channels // already authorized. @@ -546,6 +608,20 @@ describe("authorizing destination channels", () => { expect(await harness.offersConnectionTest()).toBe(true); }, 30000); + it("sends a user stuck on a missing private channel to the docs section about it", async () => { + // Given — a connected workspace, so the picker's invite copy is on screen. + const harness = new SlackIntegrationHarness(connectedSlackFixture()); + + // When + await harness.mount(); + + // Then — the whole anchored URL: the anchor is the point of the link, and + // it is derived from a docs heading that a rewording would silently move. + expect(harness.channelInviteHintDocsUrl()).toBe( + "https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration#why-a-private-channel-is-missing-from-the-channel-list", + ); + }, 30000); + it("names the wait Slack asked for when it rate limits the channel listing", async () => { // Given — `conversations.list` is Slack tier 2 and paginated (contract, // Errors); the `429` carries the wait in `Retry-After`. @@ -632,7 +708,7 @@ describe("authorizing destination channels", () => { ]); }, 30000); - it("says to invite @Prowler when Slack refuses a channel because the app is not in it", async () => { + it("says to invite @Prowler Cloud when Slack refuses a channel because the app is not in it", async () => { // Given — a private channel the app was removed from. The API validates the // set against Slack on the way in and refuses with `not_in_channel`. const harness = new SlackIntegrationHarness( @@ -649,7 +725,7 @@ describe("authorizing destination channels", () => { // Then — the one fix the user can carry out themselves, in Slack. expect(refusal).toMatch(/Prowler is not in that channel/); - expect(refusal).toMatch(/Invite @Prowler to it in Slack/); + expect(refusal).toMatch(/Invite @Prowler Cloud to it in Slack/); expect(refusal).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE); // And — nothing was recorded, so there is still nothing to check against. @@ -657,7 +733,7 @@ describe("authorizing destination channels", () => { expect(await harness.offersConnectionTest()).toBe(false); }, 60000); - it("says the channel is gone, not that @Prowler needs inviting, when Slack no longer has it", async () => { + it("says the channel is gone, not that @Prowler Cloud needs inviting, when Slack no longer has it", async () => { // Given — a channel archived since the listing was read. The API's `detail` // is word-for-word the one for `not_in_channel`, so only `code` tells them // apart. @@ -677,7 +753,7 @@ describe("authorizing destination channels", () => { // channel that no longer exists. expect(refusal).toMatch(/no longer exists in the workspace/); expect(refusal).toMatch(/Choose another one/); - expect(refusal).not.toMatch(/Invite @Prowler/); + expect(refusal).not.toMatch(/Invite @Prowler Cloud/); expect(refusal).not.toMatch(SLACK_UNKNOWN_CHANNEL_DETAIL); expect(await harness.authorizedChannels()).toEqual([]); }, 60000); @@ -689,7 +765,7 @@ describe("authorizing destination channels", () => { // Then — checking again posts nothing: the confirmation is one-time // (design D7), not a fresh message every run. - expect(harness.connectionCheckHint()).toMatch(/nothing is posted/); + expect(harness.connectionCheckHint()).toMatch(/Nothing is posted/); expect(harness.connectionCheckHint()).not.toMatch(/test message/i); // When — a second channel is authorized. @@ -711,7 +787,7 @@ describe("authorizing destination channels", () => { expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS); await harness.refreshPageData(); expect( - await harness.connectionCheckHintMatching(/nothing is posted/), + await harness.connectionCheckHintMatching(/Nothing is posted/), ).toMatch(/every authorized channel/); }, 60000); @@ -831,7 +907,7 @@ describe("authorizing destination channels", () => { // Given — a finished setup whose channel an earlier check confirmed. const harness = new SlackIntegrationHarness(configuredSlackFixture()); await harness.mount(); - expect(harness.connectionCheckHint()).toMatch(/nothing is posted/); + expect(harness.connectionCheckHint()).toMatch(/Nothing is posted/); // When — the same workspace is approved again. The exchange is the // callback route's doing (covered in `callback/route.test.ts`); here it diff --git a/ui/changelog.d/slack-connection-check-result.added.md b/ui/changelog.d/slack-connection-check-result.added.md new file mode 100644 index 0000000000..f671f90151 --- /dev/null +++ b/ui/changelog.d/slack-connection-check-result.added.md @@ -0,0 +1 @@ +Slack integration: the connection check leaves its result on the card instead of only in a toast, naming the channel Slack refused or the channels it reached (Prowler Cloud only) diff --git a/ui/changelog.d/slack-private-channel-marker.changed.md b/ui/changelog.d/slack-private-channel-marker.changed.md new file mode 100644 index 0000000000..0c25848b5a --- /dev/null +++ b/ui/changelog.d/slack-private-channel-marker.changed.md @@ -0,0 +1 @@ +Slack integration: a selected private channel carries the same visible `Private` badge as the channel list, replacing the lock icon on the chip (Prowler Cloud only) diff --git a/ui/changelog.d/slack-ui-feedback.changed.md b/ui/changelog.d/slack-ui-feedback.changed.md new file mode 100644 index 0000000000..33708de1b0 --- /dev/null +++ b/ui/changelog.d/slack-ui-feedback.changed.md @@ -0,0 +1 @@ +Slack integration: the bot is referred to as `@Prowler Cloud`, identifiers are set as inline code, and the private-channel hint links to its docs (Prowler Cloud only) diff --git a/ui/components/integrations/shared/integration-card-header.tsx b/ui/components/integrations/shared/integration-card-header.tsx index e78f12ed0d..b7f247a41b 100644 --- a/ui/components/integrations/shared/integration-card-header.tsx +++ b/ui/components/integrations/shared/integration-card-header.tsx @@ -10,17 +10,18 @@ import { cn } from "@/lib/utils"; const CONNECTION_BADGE = { connected: { label: "Connected", - className: - "bg-bg-pass-secondary text-text-success-primary border-transparent", + variant: "success", + dotClassName: "bg-bg-pass", }, disconnected: { label: "Disconnected", - className: - "bg-bg-fail-secondary text-text-error-primary border-transparent", + variant: "error", + dotClassName: "bg-bg-fail", }, unchecked: { label: "Not checked yet", - className: "border-border-tag bg-bg-tag text-text-neutral-secondary", + variant: "tag", + dotClassName: "bg-bg-data-muted", }, } as const; @@ -45,30 +46,57 @@ interface IntegrationCardHeaderProps { icon: ReactNode; title: string; subtitle?: string; + /** + * A quiet third line under the subtitle, for a fact about the integration + * rather than a claim about it — when its connection was last checked, say. + */ + meta?: ReactNode; chips?: IntegrationCardChip[]; connectionStatus?: IntegrationConnectionStatus; navigationUrl?: string; + /** The integration's own controls, pinned to the end of the row. */ + actions?: ReactNode; } export const IntegrationCardHeader = ({ icon, title, subtitle, + meta, chips = [], connectionStatus, navigationUrl, + actions, }: IntegrationCardHeaderProps) => { const badgeState = connectionStatus ? connectionBadgeState(connectionStatus.connected) : null; const badge = badgeState ? CONNECTION_BADGE[badgeState] : null; + // The end of the row belongs to the controls wherever there are any, so the + // status travels with the name it qualifies instead of across the card. + const statusBesideTitle = Boolean(actions); + + const statusBadge = + badge && badgeState ? ( + + + ) : null; + + const hasAside = + chips.length > 0 || Boolean(actions) || (statusBadge && !statusBesideTitle); + return (
{icon} -
-
+
+

{title}

{navigationUrl && ( )} + {statusBesideTitle && statusBadge}
{subtitle && ( -

- {subtitle} -

+

{subtitle}

)} + {meta}
- {(chips.length > 0 || badge) && ( + {hasAside && (
{chips.map((chip, index) => ( {chip.label} ))} - {badge && badgeState && ( - - {connectionStatus?.label || badge.label} - - )} + {!statusBesideTitle && statusBadge} + {actions}
)}
diff --git a/ui/components/integrations/slack/slack-channel-multi-select.tsx b/ui/components/integrations/slack/slack-channel-multi-select.tsx index 08d07f67b6..02d4d4f88e 100644 --- a/ui/components/integrations/slack/slack-channel-multi-select.tsx +++ b/ui/components/integrations/slack/slack-channel-multi-select.tsx @@ -1,7 +1,8 @@ "use client"; -import { Lock, RefreshCw } from "lucide-react"; +import { RefreshCw } from "lucide-react"; +import { SlackInlineCode } from "@/components/integrations/slack/slack-inline-code"; import { Alert, AlertDescription, @@ -10,6 +11,7 @@ import { Button, Label, } from "@/components/shadcn"; +import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { MultiSelect, MultiSelectContent, @@ -17,10 +19,23 @@ import { MultiSelectTrigger, MultiSelectValue, } from "@/components/shadcn/select/multiselect"; +import { DOCS_URLS } from "@/lib/external-urls"; import type { SlackChannelOption } from "@/types/integrations"; -const INVITE_HINT = - "A private channel only appears here after someone invites @Prowler to it in Slack. Invite it, then refresh."; +const INVITE_HINT = ( + <> + A private channel only appears here after someone invites{" "} + @Prowler Cloud to it in Slack. Invite it, + then refresh.{" "} + + Learn more + + +); interface SlackChannelMultiSelectProps { options: SlackChannelOption[]; @@ -38,15 +53,15 @@ interface SlackChannelMultiSelectProps { describedBy?: string; } +/** Marked exactly as the listing marks it, so the chip is the row it came from. */ const chipLabel = (option: SlackChannelOption) => ( - {option.is_private && ( - <> - ); @@ -104,8 +119,10 @@ export const SlackChannelMultiSelect = ({ No channels available yet Prowler cannot see a single channel in this workspace. Create a - public channel, or invite @Prowler to a private one in Slack with - /invite @Prowler, then + public channel, or invite{" "} + @Prowler Cloud to a private one + in Slack with{" "} + /invite @Prowler Cloud, then refresh. diff --git a/ui/components/integrations/slack/slack-connection-check-status.tsx b/ui/components/integrations/slack/slack-connection-check-status.tsx new file mode 100644 index 0000000000..ea925db602 --- /dev/null +++ b/ui/components/integrations/slack/slack-connection-check-status.tsx @@ -0,0 +1,124 @@ +import { Check, CircleAlert, Loader2, type LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +// Read by the page harness off `data-connection-check-status`. +export const CHECK_STATUS = { + /** No check has run against the channels currently on record. */ + IDLE: "idle", + RUNNING: "running", + PASSED: "passed", + FAILED: "failed", +} as const; + +export type CheckStatus = (typeof CHECK_STATUS)[keyof typeof CHECK_STATUS]; + +interface CheckStatusStyle { + /** `null` for the resting state, which shows no outcome to decorate. */ + icon: LucideIcon | null; + className: string; +} + +const CHECK_STATUS_STYLES = { + [CHECK_STATUS.IDLE]: { + icon: null, + className: "", + }, + [CHECK_STATUS.RUNNING]: { + icon: Loader2, + className: + "border-border-neutral-secondary bg-bg-neutral-tertiary text-text-neutral-secondary", + }, + // The dark pass tokens are sized for a whole card, and at this width they + // read as an alert rather than a note, so they are taken down a shade there. + [CHECK_STATUS.PASSED]: { + icon: Check, + className: + "border-bg-pass bg-bg-pass-secondary text-text-success-primary dark:border-bg-pass/40 dark:bg-bg-pass-secondary/40", + }, + [CHECK_STATUS.FAILED]: { + icon: CircleAlert, + className: + "border-border-error bg-bg-fail-secondary text-text-error-primary dark:border-border-error/50", + }, +} as const satisfies Record; + +interface SlackConnectionCheckStatusProps { + status: CheckStatus; + /** What the last check found. Absent while no check covers the record. */ + outcome?: ReactNode; + /** + * Id the control points at with `aria-describedby`. The caption alone carries + * it: an outcome read out as the control's description would say what the + * last check did, where the description has to say what pressing it does. + */ + captionId: string; + /** What the check does, or why it cannot run. */ + children: ReactNode; +} + +/** + * The connection check's standing state: what a check last found, over the + * caption explaining what running one does. + * + * The two are kept in separate registers — the outcome boxed, the caption plain + * underneath — because they are answers to different questions and can honestly + * disagree. A refused channel is still a channel the next check will try, so + * reading the caption as a continuation of the outcome ("Slack refused #ops" … + * "posts the confirmation to #ops") would turn a true pair of statements into a + * contradiction. + */ +export const SlackConnectionCheckStatus = ({ + status, + outcome, + captionId, + children, +}: SlackConnectionCheckStatusProps) => { + const { icon: Icon, className } = CHECK_STATUS_STYLES[status]; + + return ( +
+
+ {Icon && outcome && ( +
+

+ {children} +

+
+ ); +}; diff --git a/ui/components/integrations/slack/slack-inline-code.tsx b/ui/components/integrations/slack/slack-inline-code.tsx new file mode 100644 index 0000000000..a197148d47 --- /dev/null +++ b/ui/components/integrations/slack/slack-inline-code.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; + +interface SlackInlineCodeProps { + children: ReactNode; +} + +/** + * A Slack identifier — a channel name, the bot mention, a slash command — set + * apart from the prose around it. + */ +export const SlackInlineCode = ({ children }: SlackInlineCodeProps) => ( + + {children} + +); diff --git a/ui/components/integrations/slack/slack-integration-card.tsx b/ui/components/integrations/slack/slack-integration-card.tsx index a4e8397afb..619e9242c9 100644 --- a/ui/components/integrations/slack/slack-integration-card.tsx +++ b/ui/components/integrations/slack/slack-integration-card.tsx @@ -4,9 +4,7 @@ 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"; - -const SLACK_DOCS_URL = - "https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration"; +import { DOCS_URLS } from "@/lib/external-urls"; export const SlackIntegrationCard = () => { return ( @@ -24,7 +22,7 @@ export const SlackIntegrationCard = () => { Send Prowler messages to your Slack workspace.

diff --git a/ui/components/integrations/slack/slack-integration-manager.tsx b/ui/components/integrations/slack/slack-integration-manager.tsx index 66c6e9c9f7..fdbfbcc502 100644 --- a/ui/components/integrations/slack/slack-integration-manager.tsx +++ b/ui/components/integrations/slack/slack-integration-manager.tsx @@ -2,7 +2,7 @@ import { format, isValid, parseISO } from "date-fns"; import { TestTube, Unplug } from "lucide-react"; -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { testIntegrationConnection } from "@/actions/integrations/integrations"; import { @@ -14,6 +14,12 @@ import { import { SlackIcon } from "@/components/icons/services/IconServices"; import { IntegrationCardHeader } from "@/components/integrations/shared"; import { SlackChannelMultiSelect } from "@/components/integrations/slack/slack-channel-multi-select"; +import { + CHECK_STATUS, + type CheckStatus, + SlackConnectionCheckStatus, +} from "@/components/integrations/slack/slack-connection-check-status"; +import { SlackInlineCode } from "@/components/integrations/slack/slack-inline-code"; import { Alert, AlertDescription, @@ -76,6 +82,20 @@ interface UnconfirmedRevocation { workspace: string | null; } +/** + * What the last check found, together with what it was measured against. Both + * ride with the finding so it can never outlive them: a green line vouching for + * a channel nothing ever reached is worse than no line at all, and so is one + * standing over a notice saying the credential is dead. + */ +interface ConnectionCheckOutcome { + reachable: boolean; + summary: string; + channelIds: string[]; + /** The credential verdict the check was run under. */ + credentialFailure: SlackTokenErrorCode | null; +} + /** Order-insensitive: the mirror must not re-seed on a mere reordering. */ const sameChannelIds = (a: string[], b: string[]) => a.length === b.length && new Set([...a, ...b]).size === a.length; @@ -129,10 +149,39 @@ const recordedFromSelection = ( ]; }); +const CHANNEL_LIST_FORMAT = new Intl.ListFormat("en", { + style: "long", + type: "conjunction", +}); + +const channelTokens = (names: string[]): string[] => + names.map((name) => `#${name}`); + +/** For the copy that travels as plain text: toasts, the deauthorize warning. */ const channelList = (names: string[]): string => - new Intl.ListFormat("en", { style: "long", type: "conjunction" }).format( - names.map((name) => `#${name}`), - ); + CHANNEL_LIST_FORMAT.format(channelTokens(names)); + +interface StyledChannelListProps { + names: string[]; +} + +/** + * The same list as `channelList()`, each channel set apart. `formatToParts` + * emits exactly the literals `format` joins, so the rendered text is unchanged + * (design D3) — the copy is read as text content, here and by the tests. + */ +const StyledChannelList = ({ names }: StyledChannelListProps) => ( + <> + {CHANNEL_LIST_FORMAT.formatToParts(channelTokens(names)).map( + (part, index) => + part.type === "element" ? ( + {part.value} + ) : ( + part.value + ), + )} + +); /** * Slack's own reason, when the string is one: the connection check reports a @@ -160,6 +209,10 @@ export const SlackIntegrationManager = ({ loadError, }: SlackIntegrationManagerProps) => { const [isTesting, setIsTesting] = useState(false); + // A check's finding outlives its toast: the card keeps saying where the + // workspace stands until a change to the channels makes the finding moot. + const [checkOutcome, setCheckOutcome] = + useState(null); const [isDisconnectOpen, setIsDisconnectOpen] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false); // The row is gone the moment the API says so; the server component's @@ -317,15 +370,55 @@ export const SlackIntegrationManager = ({ (channel) => !selectedChannelIds.includes(channel.id), ); - const checkHint = (): string => { + const authorizedChannelIds = authorizedChannels.map((channel) => channel.id); + + /** + * Derived, not stored, so a finding retires on its own the moment anything it + * was measured against moves: a channel authorized or dropped leaves a set no + * check has covered, and a credential verdict that has since changed leaves a + * finding taken under conditions that no longer hold. + */ + const coveredOutcome = + checkOutcome && + sameChannelIds(checkOutcome.channelIds, authorizedChannelIds) && + checkOutcome.credentialFailure === credentialFailure + ? checkOutcome + : null; + + const checkStatus = (): CheckStatus => { + if (isTesting) return CHECK_STATUS.RUNNING; + if (!coveredOutcome) return CHECK_STATUS.IDLE; + return coveredOutcome.reachable ? CHECK_STATUS.PASSED : CHECK_STATUS.FAILED; + }; + + // Deliberately not a second telling of the caption below it: the button + // already reads "Testing...", so this only has to say a check is under way. + const checkSummary = isTesting + ? "Checking the connection..." + : coveredOutcome?.summary; + + /** What a passing check proves, said in terms of what was actually reached. */ + const reachableSummary = (channels: SlackAuthorizedChannel[]): string => + channels.length === 1 + ? `#${channels[0].name} is reachable.` + : `All ${channels.length} channels are reachable.`; + + const checkHint = (): ReactNode => { if (authorizedChannels.length === 0) { return "Authorize at least one destination channel below to enable this check."; } - return unconfirmedChannels.length > 0 - ? `Checks every authorized channel and posts “${CONFIRMATION_MESSAGE}” once to ${channelList( - unconfirmedChannels.map((channel) => channel.name), - )}.` - : "Checks every authorized channel. Each was confirmed once already, so nothing is posted."; + return unconfirmedChannels.length > 0 ? ( + <> + Checks every authorized channel and posts “{CONFIRMATION_MESSAGE}” once + to{" "} + channel.name)} + /> + . + + ) : ( + "Checks every authorized channel. Nothing is posted." + ); }; const handleSaveChannels = async () => { @@ -395,12 +488,24 @@ export const SlackIntegrationManager = ({ // Passed in by a chained check: it runs before the save's state lands. channels: SlackAuthorizedChannel[] = authorizedChannels, ) => { + const checkedChannelIds = channels.map((channel) => channel.id); + setIsTesting(true); try { const result = await testIntegrationConnection(id); if (result.success) { provedCredentialAlive(); + setCheckOutcome({ + reachable: true, + // Prowler's own count, not the API's prose: the line has to keep + // meaning the same thing every time it is read. + summary: reachableSummary(channels), + channelIds: checkedChannelIds, + // An answer at all clears the verdict, so a pass is always taken + // under a live credential. + credentialFailure: null, + }); toast({ title: "Connection test successful!", description: @@ -411,8 +516,13 @@ export const SlackIntegrationManager = ({ // A dead credential named here is not a failure checking again can // fix, so the reason is recorded and not only reported. const reason = result.error?.trim() || null; + const reasonCode = asReasonCode(reason); - recordRefusal(asReasonCode(reason)); + recordRefusal(reasonCode); + + // The verdict this check leaves behind: its own reason when it named + // one, else the last answer's, which nothing here contradicted. + const codeAfterCheck = reasonCode ?? lastRefusalCode; const explanation = reason ? slackErrorMessage({ code: reason, detail: reason }) @@ -424,15 +534,32 @@ export const SlackIntegrationManager = ({ ? channels.find((channel) => channel.id === result.failedChannelId) : undefined; + const refusal = refusedChannel + ? `Slack refused #${refusedChannel.name}: ${explanation}` + : explanation; + + setCheckOutcome({ + reachable: false, + summary: refusal, + channelIds: checkedChannelIds, + credentialFailure: isSlackTokenErrorCode(codeAfterCheck) + ? codeAfterCheck + : null, + }); toast({ variant: "destructive", title: "Connection test failed", - description: refusedChannel - ? `Slack refused #${refusedChannel.name}: ${explanation}` - : explanation, + description: refusal, }); } } catch (_error) { + setCheckOutcome({ + reachable: false, + summary: "Prowler could not reach Slack to run the check.", + channelIds: checkedChannelIds, + // Never an answer from Slack, so the standing verdict is untouched. + credentialFailure, + }); toast({ variant: "destructive", title: "Error", @@ -607,11 +734,18 @@ export const SlackIntegrationManager = ({ ) : integration && !disconnected ? ( - + } title={`Connected to ${workspaceName ?? "your Slack workspace"}`} subtitle="Prowler posts to this workspace only." + meta={ + lastCheckedOn && ( +

+ Last checked {lastCheckedOn} +

+ ) + } connectionStatus={{ // A dead token outranks the state the page was loaded with. connected: @@ -619,21 +753,8 @@ export const SlackIntegrationManager = ({ ? integration.attributes.connected : false, }} - /> -
- - -
-
- {lastCheckedOn && ( -

- Last checked:{" "} - {lastCheckedOn} -

- )} -
-
-
+ actions={ + <> {/* The check reaches the authorized channels: the API answers 400 while the set is empty. */} -
-

- {checkHint()} -

-
-
+ + } + /> -
+ {/* Below the row, not beside the buttons: the width is what lets + the check say what it did without wrapping into a column. */} + + {checkHint()} + + + + +
- {authorizedChannels.length > 0 - ? `Prowler posts to ${channelList( - authorizedChannels.map((channel) => channel.name), - )}.` - : "No destination channels authorized yet."} + {authorizedChannels.length > 0 ? ( + <> + Prowler posts to{" "} + channel.name, + )} + /> + . + + ) : ( + "No destination channels authorized yet." + )}