fix(ui): apply Slack integration design feedback (#12677)
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -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.
|
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
|
### 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
|
```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.
|
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
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
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**.
|
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.
|
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.
|
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 |
|
| 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 |
|
| **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 |
|
| **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 |
|
| **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
|
### 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
|
### Connection Test Fails
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export interface SlackConnectionFixture {
|
|||||||
export interface SlackChannelFixture {
|
export interface SlackChannelFixture {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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;
|
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
|
* 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 = {
|
export const SLACK_NOT_IN_CHANNEL_REFUSAL: SlackRefusalFixture = {
|
||||||
status: 400,
|
status: 400,
|
||||||
|
|||||||
@@ -384,7 +384,7 @@ export class AlertsPageHarness extends BrowserHarness<AlertsFixture> {
|
|||||||
).map((chip) => (chip.textContent ?? "").replace(/\s+/g, " ").trim());
|
).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 {
|
private static toChannelChip(text: string): SelectedChannelChip {
|
||||||
return {
|
return {
|
||||||
isPrivate: /Private/.test(text),
|
isPrivate: /Private/.test(text),
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
|
|||||||
import { worker } from "@/__tests__/msw/worker";
|
import { worker } from "@/__tests__/msw/worker";
|
||||||
import { render } from "@/__tests__/render-browser";
|
import { render } from "@/__tests__/render-browser";
|
||||||
import { setSlackAuthorizedChannels } from "@/actions/integrations/slack";
|
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 { SLACK_CONNECT_PARAMS } from "@/lib/integrations/slack-connect-status";
|
||||||
|
|
||||||
import { IntegrationsContent } from "../integrations-content";
|
import { IntegrationsContent } from "../integrations-content";
|
||||||
@@ -335,6 +339,69 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async connectionSuccessToast(): Promise<string> {
|
||||||
|
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<CheckStatus> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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 —
|
* The "last checked" line as rendered, or null when the page shows none —
|
||||||
* which is what a workspace whose connection was never checked shows.
|
* which is what a workspace whose connection was never checked shows.
|
||||||
@@ -342,7 +409,7 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
|||||||
lastCheckedLine(): string | null {
|
lastCheckedLine(): string | null {
|
||||||
const line = Array.from(
|
const line = Array.from(
|
||||||
this.container.querySelectorAll<HTMLElement>("p"),
|
this.container.querySelectorAll<HTMLElement>("p"),
|
||||||
).find((p) => /^Last checked:/.test((p.textContent ?? "").trim()));
|
).find((p) => /^Last checked\b/.test((p.textContent ?? "").trim()));
|
||||||
return line ? (line.textContent ?? "").trim() : null;
|
return line ? (line.textContent ?? "").trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,16 +417,24 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
|||||||
return this.countRequests("POST", "/connection");
|
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<ConnectionOutcome> {
|
async connectionOutcome(): Promise<ConnectionOutcome> {
|
||||||
return this.waitFor(
|
return this.waitFor(
|
||||||
() => {
|
() => {
|
||||||
if (this.containsText(/Connection test successful/)) {
|
const status = this.q("[data-connection-check-status]")?.getAttribute(
|
||||||
return CONNECTION_OUTCOME.SUCCESS;
|
"data-connection-check-status",
|
||||||
}
|
);
|
||||||
if (this.containsText(/Connection test failed/)) {
|
if (status === CHECK_STATUS.PASSED) return CONNECTION_OUTCOME.SUCCESS;
|
||||||
return CONNECTION_OUTCOME.FAILURE;
|
if (status === CHECK_STATUS.FAILED) return CONNECTION_OUTCOME.FAILURE;
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
15000,
|
15000,
|
||||||
@@ -508,8 +583,8 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-read the workspace's channels, the way a user does after inviting
|
* 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
|
* `@Prowler Cloud` to one in Slack. Waits for the read to have settled, not
|
||||||
* click alone.
|
* for the click alone.
|
||||||
*/
|
*/
|
||||||
async refreshChannels(): Promise<void> {
|
async refreshChannels(): Promise<void> {
|
||||||
const readsBefore = this.channelListCallCount;
|
const readsBefore = this.channelListCallCount;
|
||||||
@@ -783,11 +858,16 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
|||||||
"the authorized channel chips",
|
"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) => {
|
return chips.map((chip) => {
|
||||||
const text = (chip.textContent ?? "").trim();
|
const text = (chip.textContent ?? "").trim();
|
||||||
return {
|
return {
|
||||||
name: text.replace(/^Private/, "").replace(/^#/, ""),
|
name: text
|
||||||
isPrivate: /^Private/.test(text),
|
.replace(/Private/g, "")
|
||||||
|
.replace(/^#/, "")
|
||||||
|
.trim(),
|
||||||
|
isPrivate: /Private/.test(text),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -825,14 +905,27 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
|||||||
return this.containsText(/Could not read the workspace/);
|
return this.containsText(/Could not read the workspace/);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private channelInviteHintParagraph(): HTMLElement | null {
|
||||||
|
return (
|
||||||
|
Array.from(this.container.querySelectorAll<HTMLElement>("p")).find(
|
||||||
|
(element) => /invites? @Prowler Cloud/.test(element.textContent ?? ""),
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The invite copy that says how to make a private channel appear. */
|
/** The invite copy that says how to make a private channel appear. */
|
||||||
channelInviteHint(): string | null {
|
channelInviteHint(): string | null {
|
||||||
const hint = Array.from(
|
const hint = this.channelInviteHintParagraph();
|
||||||
this.container.querySelectorAll<HTMLElement>("p"),
|
|
||||||
).find((element) => /invites? @Prowler/.test(element.textContent ?? ""));
|
|
||||||
return hint ? (hint.textContent ?? "").trim() : null;
|
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<HTMLAnchorElement>("a");
|
||||||
|
return link ? link.href : null;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Disconnecting ------------------------------------------------------
|
// --- Disconnecting ------------------------------------------------------
|
||||||
|
|
||||||
get disconnectCallCount(): number {
|
get disconnectCallCount(): number {
|
||||||
|
|||||||
@@ -205,8 +205,24 @@ describe("a connected workspace", () => {
|
|||||||
|
|
||||||
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
|
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
|
||||||
expect(await harness.connectionBadge()).toBe("Connected");
|
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.offersConnectionTest()).toBe(true);
|
||||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS);
|
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
|
// One workspace per tenant (design D10): no second install on offer, and no
|
||||||
// consent URL minted for a page that would never use it.
|
// consent URL minted for a page that would never use it.
|
||||||
expect(harness.offersInstall()).toBe(false);
|
expect(harness.offersInstall()).toBe(false);
|
||||||
@@ -356,8 +372,8 @@ describe("authorizing destination channels", () => {
|
|||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
it("offers a private channel the app was invited to, marked as private, and authorizes it", async () => {
|
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
|
// Given — `@Prowler Cloud` was invited to one private channel;
|
||||||
// membership-gated (D2).
|
// `groups:read` is membership-gated (D2).
|
||||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||||
await harness.mount();
|
await harness.mount();
|
||||||
|
|
||||||
@@ -381,7 +397,7 @@ describe("authorizing destination channels", () => {
|
|||||||
]);
|
]);
|
||||||
}, 60000);
|
}, 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
|
// Given — a workspace whose only channels are public: `groups:read` is
|
||||||
// membership-gated (design D2).
|
// membership-gated (design D2).
|
||||||
const harness = new SlackIntegrationHarness(
|
const harness = new SlackIntegrationHarness(
|
||||||
@@ -397,8 +413,8 @@ describe("authorizing destination channels", () => {
|
|||||||
SLACK_PRIVATE_CHANNEL.name,
|
SLACK_PRIVATE_CHANNEL.name,
|
||||||
);
|
);
|
||||||
|
|
||||||
// When — `@Prowler` is invited to a private channel, and the user refreshes
|
// When — `@Prowler Cloud` is invited to a private channel, and the user
|
||||||
// instead of reconnecting the workspace.
|
// refreshes instead of reconnecting the workspace.
|
||||||
harness.fixture.channels.push({ ...SLACK_PRIVATE_CHANNEL });
|
harness.fixture.channels.push({ ...SLACK_PRIVATE_CHANNEL });
|
||||||
await harness.refreshChannels();
|
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.
|
// Then — the user is told what to do, not merely that the list is empty.
|
||||||
const message = await harness.channelPickerMessage();
|
const message = await harness.channelPickerMessage();
|
||||||
expect(message).toMatch(/No channels available yet/);
|
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.authorizedChannels()).toEqual([]);
|
||||||
expect(await harness.offersConnectionTest()).toBe(false);
|
expect(await harness.offersConnectionTest()).toBe(false);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
@@ -443,6 +459,12 @@ describe("authorizing destination channels", () => {
|
|||||||
// Then
|
// Then
|
||||||
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS);
|
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS);
|
||||||
expect(harness.connectionCheckCallCount).toBe(1);
|
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
|
// And — everything waiting on a destination moves with the save, in the
|
||||||
// same paint: no reload to find the check on offer for later.
|
// same paint: no reload to find the check on offer for later.
|
||||||
expect(await harness.offersConnectionTest()).toBe(true);
|
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
|
// Then — only the check failed, so the destinations stay on record, and
|
||||||
// the failure names the one channel Slack refused.
|
// the failure names the one channel Slack refused.
|
||||||
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.FAILURE);
|
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(
|
expect(await harness.connectionFailureToast()).toMatch(
|
||||||
new RegExp(`Slack refused #${SLACK_PRIVATE_CHANNEL.name}`),
|
new RegExp(`Slack refused #${SLACK_PRIVATE_CHANNEL.name}`),
|
||||||
);
|
);
|
||||||
@@ -516,6 +543,41 @@ describe("authorizing destination channels", () => {
|
|||||||
expect(harness.offersChannelsSave()).toBe(false);
|
expect(harness.offersChannelsSave()).toBe(false);
|
||||||
}, 60000);
|
}, 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 () => {
|
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
|
// Given — an authorized set, and an install missing a scope the listing
|
||||||
// needs. The API names it in `code` (contract, Errors), not in `detail`.
|
// 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
|
// Slack's reason is a protocol token: it travels in `code` and is never
|
||||||
// shown.
|
// shown.
|
||||||
expect(message).not.toMatch(SLACK_MISSING_SCOPE_CODE);
|
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
|
// And — a listing Prowler could not read says nothing about the channels
|
||||||
// already authorized.
|
// already authorized.
|
||||||
@@ -546,6 +608,20 @@ describe("authorizing destination channels", () => {
|
|||||||
expect(await harness.offersConnectionTest()).toBe(true);
|
expect(await harness.offersConnectionTest()).toBe(true);
|
||||||
}, 30000);
|
}, 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 () => {
|
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,
|
// Given — `conversations.list` is Slack tier 2 and paginated (contract,
|
||||||
// Errors); the `429` carries the wait in `Retry-After`.
|
// Errors); the `429` carries the wait in `Retry-After`.
|
||||||
@@ -632,7 +708,7 @@ describe("authorizing destination channels", () => {
|
|||||||
]);
|
]);
|
||||||
}, 30000);
|
}, 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
|
// 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`.
|
// set against Slack on the way in and refuses with `not_in_channel`.
|
||||||
const harness = new SlackIntegrationHarness(
|
const harness = new SlackIntegrationHarness(
|
||||||
@@ -649,7 +725,7 @@ describe("authorizing destination channels", () => {
|
|||||||
|
|
||||||
// Then — the one fix the user can carry out themselves, in Slack.
|
// Then — the one fix the user can carry out themselves, in Slack.
|
||||||
expect(refusal).toMatch(/Prowler is not in that channel/);
|
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);
|
expect(refusal).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE);
|
||||||
|
|
||||||
// And — nothing was recorded, so there is still nothing to check against.
|
// 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);
|
expect(await harness.offersConnectionTest()).toBe(false);
|
||||||
}, 60000);
|
}, 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`
|
// 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
|
// is word-for-word the one for `not_in_channel`, so only `code` tells them
|
||||||
// apart.
|
// apart.
|
||||||
@@ -677,7 +753,7 @@ describe("authorizing destination channels", () => {
|
|||||||
// channel that no longer exists.
|
// channel that no longer exists.
|
||||||
expect(refusal).toMatch(/no longer exists in the workspace/);
|
expect(refusal).toMatch(/no longer exists in the workspace/);
|
||||||
expect(refusal).toMatch(/Choose another one/);
|
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(refusal).not.toMatch(SLACK_UNKNOWN_CHANNEL_DETAIL);
|
||||||
expect(await harness.authorizedChannels()).toEqual([]);
|
expect(await harness.authorizedChannels()).toEqual([]);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
@@ -689,7 +765,7 @@ describe("authorizing destination channels", () => {
|
|||||||
|
|
||||||
// Then — checking again posts nothing: the confirmation is one-time
|
// Then — checking again posts nothing: the confirmation is one-time
|
||||||
// (design D7), not a fresh message every run.
|
// (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);
|
expect(harness.connectionCheckHint()).not.toMatch(/test message/i);
|
||||||
|
|
||||||
// When — a second channel is authorized.
|
// When — a second channel is authorized.
|
||||||
@@ -711,7 +787,7 @@ describe("authorizing destination channels", () => {
|
|||||||
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS);
|
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS);
|
||||||
await harness.refreshPageData();
|
await harness.refreshPageData();
|
||||||
expect(
|
expect(
|
||||||
await harness.connectionCheckHintMatching(/nothing is posted/),
|
await harness.connectionCheckHintMatching(/Nothing is posted/),
|
||||||
).toMatch(/every authorized channel/);
|
).toMatch(/every authorized channel/);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
@@ -831,7 +907,7 @@ describe("authorizing destination channels", () => {
|
|||||||
// Given — a finished setup whose channel an earlier check confirmed.
|
// Given — a finished setup whose channel an earlier check confirmed.
|
||||||
const harness = new SlackIntegrationHarness(configuredSlackFixture());
|
const harness = new SlackIntegrationHarness(configuredSlackFixture());
|
||||||
await harness.mount();
|
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
|
// When — the same workspace is approved again. The exchange is the
|
||||||
// callback route's doing (covered in `callback/route.test.ts`); here it
|
// callback route's doing (covered in `callback/route.test.ts`); here it
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -10,17 +10,18 @@ import { cn } from "@/lib/utils";
|
|||||||
const CONNECTION_BADGE = {
|
const CONNECTION_BADGE = {
|
||||||
connected: {
|
connected: {
|
||||||
label: "Connected",
|
label: "Connected",
|
||||||
className:
|
variant: "success",
|
||||||
"bg-bg-pass-secondary text-text-success-primary border-transparent",
|
dotClassName: "bg-bg-pass",
|
||||||
},
|
},
|
||||||
disconnected: {
|
disconnected: {
|
||||||
label: "Disconnected",
|
label: "Disconnected",
|
||||||
className:
|
variant: "error",
|
||||||
"bg-bg-fail-secondary text-text-error-primary border-transparent",
|
dotClassName: "bg-bg-fail",
|
||||||
},
|
},
|
||||||
unchecked: {
|
unchecked: {
|
||||||
label: "Not checked yet",
|
label: "Not checked yet",
|
||||||
className: "border-border-tag bg-bg-tag text-text-neutral-secondary",
|
variant: "tag",
|
||||||
|
dotClassName: "bg-bg-data-muted",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -45,30 +46,57 @@ interface IntegrationCardHeaderProps {
|
|||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: 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[];
|
chips?: IntegrationCardChip[];
|
||||||
connectionStatus?: IntegrationConnectionStatus;
|
connectionStatus?: IntegrationConnectionStatus;
|
||||||
navigationUrl?: string;
|
navigationUrl?: string;
|
||||||
|
/** The integration's own controls, pinned to the end of the row. */
|
||||||
|
actions?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IntegrationCardHeader = ({
|
export const IntegrationCardHeader = ({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
meta,
|
||||||
chips = [],
|
chips = [],
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
navigationUrl,
|
navigationUrl,
|
||||||
|
actions,
|
||||||
}: IntegrationCardHeaderProps) => {
|
}: IntegrationCardHeaderProps) => {
|
||||||
const badgeState = connectionStatus
|
const badgeState = connectionStatus
|
||||||
? connectionBadgeState(connectionStatus.connected)
|
? connectionBadgeState(connectionStatus.connected)
|
||||||
: null;
|
: null;
|
||||||
const badge = badgeState ? CONNECTION_BADGE[badgeState] : 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 ? (
|
||||||
|
<Badge variant={badge.variant} data-connection-status={badgeState}>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("size-1.5 rounded-full", badge.dotClassName)}
|
||||||
|
/>
|
||||||
|
{connectionStatus?.label || badge.label}
|
||||||
|
</Badge>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const hasAside =
|
||||||
|
chips.length > 0 || Boolean(actions) || (statusBadge && !statusBesideTitle);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{icon}
|
{icon}
|
||||||
<div>
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h4 className="text-md font-semibold">{title}</h4>
|
<h4 className="text-md font-semibold">{title}</h4>
|
||||||
{navigationUrl && (
|
{navigationUrl && (
|
||||||
<a
|
<a
|
||||||
@@ -81,37 +109,32 @@ export const IntegrationCardHeader = ({
|
|||||||
<ExternalLink size={16} />
|
<ExternalLink size={16} />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{statusBesideTitle && statusBadge}
|
||||||
</div>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-300">
|
<p className="text-text-neutral-tertiary text-xs">{subtitle}</p>
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
{meta}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(chips.length > 0 || badge) && (
|
{hasAside && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{chips.map((chip, index) => (
|
{chips.map((chip, index) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={index}
|
key={index}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
// Weight left to the Badge, so a chip and the status pill beside
|
||||||
|
// it are never set differently.
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-border-neutral-secondary bg-bg-neutral-secondary text-text-neutral-primary text-xs font-normal",
|
"border-border-neutral-secondary bg-bg-neutral-secondary text-text-neutral-primary text-xs",
|
||||||
chip.className,
|
chip.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{chip.label}
|
{chip.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{badge && badgeState && (
|
{!statusBesideTitle && statusBadge}
|
||||||
<Badge
|
{actions}
|
||||||
variant="outline"
|
|
||||||
data-connection-status={badgeState}
|
|
||||||
className={cn("text-xs font-normal", badge.className)}
|
|
||||||
>
|
|
||||||
{connectionStatus?.label || badge.label}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Lock, RefreshCw } from "lucide-react";
|
import { RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
import { SlackInlineCode } from "@/components/integrations/slack/slack-inline-code";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Label,
|
Label,
|
||||||
} from "@/components/shadcn";
|
} from "@/components/shadcn";
|
||||||
|
import { CustomLink } from "@/components/shadcn/custom/custom-link";
|
||||||
import {
|
import {
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
MultiSelectContent,
|
MultiSelectContent,
|
||||||
@@ -17,10 +19,23 @@ import {
|
|||||||
MultiSelectTrigger,
|
MultiSelectTrigger,
|
||||||
MultiSelectValue,
|
MultiSelectValue,
|
||||||
} from "@/components/shadcn/select/multiselect";
|
} from "@/components/shadcn/select/multiselect";
|
||||||
|
import { DOCS_URLS } from "@/lib/external-urls";
|
||||||
import type { SlackChannelOption } from "@/types/integrations";
|
import type { SlackChannelOption } from "@/types/integrations";
|
||||||
|
|
||||||
const INVITE_HINT =
|
const INVITE_HINT = (
|
||||||
"A private channel only appears here after someone invites @Prowler to it in Slack. Invite it, then refresh.";
|
<>
|
||||||
|
A private channel only appears here after someone invites{" "}
|
||||||
|
<SlackInlineCode>@Prowler Cloud</SlackInlineCode> to it in Slack. Invite it,
|
||||||
|
then refresh.{" "}
|
||||||
|
<CustomLink
|
||||||
|
href={DOCS_URLS.SLACK_INTEGRATION_PRIVATE_CHANNELS}
|
||||||
|
ariaLabel="Learn more about why a private channel is missing from the channel list"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</CustomLink>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
interface SlackChannelMultiSelectProps {
|
interface SlackChannelMultiSelectProps {
|
||||||
options: SlackChannelOption[];
|
options: SlackChannelOption[];
|
||||||
@@ -38,15 +53,15 @@ interface SlackChannelMultiSelectProps {
|
|||||||
describedBy?: string;
|
describedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Marked exactly as the listing marks it, so the chip is the row it came from. */
|
||||||
const chipLabel = (option: SlackChannelOption) => (
|
const chipLabel = (option: SlackChannelOption) => (
|
||||||
<span className="flex min-w-0 items-center gap-1">
|
<span className="flex min-w-0 items-center gap-1">
|
||||||
{option.is_private && (
|
|
||||||
<>
|
|
||||||
<Lock size={12} aria-hidden="true" />
|
|
||||||
<span className="sr-only">Private</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className="truncate">#{option.name}</span>
|
<span className="truncate">#{option.name}</span>
|
||||||
|
{option.is_private && (
|
||||||
|
<Badge variant="tag" size="sm">
|
||||||
|
Private
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -104,8 +119,10 @@ export const SlackChannelMultiSelect = ({
|
|||||||
<AlertTitle>No channels available yet</AlertTitle>
|
<AlertTitle>No channels available yet</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Prowler cannot see a single channel in this workspace. Create a
|
Prowler cannot see a single channel in this workspace. Create a
|
||||||
public channel, or invite @Prowler to a private one in Slack with
|
public channel, or invite{" "}
|
||||||
<span className="font-medium"> /invite @Prowler</span>, then
|
<SlackInlineCode>@Prowler Cloud</SlackInlineCode> to a private one
|
||||||
|
in Slack with{" "}
|
||||||
|
<SlackInlineCode>/invite @Prowler Cloud</SlackInlineCode>, then
|
||||||
refresh.
|
refresh.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -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<CheckStatus, CheckStatusStyle>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
data-connection-check-status={status}
|
||||||
|
className={cn("flex w-full flex-col", outcome && "gap-2")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2 text-xs",
|
||||||
|
outcome && "rounded-lg border px-3 py-2",
|
||||||
|
outcome && className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Icon && outcome && (
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 size-3.5 shrink-0",
|
||||||
|
status === CHECK_STATUS.RUNNING && "animate-spin",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Always mounted, so a landing outcome is announced as a change to a
|
||||||
|
region that was already there — and polite, since the user asked
|
||||||
|
for the check and is watching it. Empty, it takes no room.
|
||||||
|
|
||||||
|
Keyed, because the check's toast carries this same refusal copy: a
|
||||||
|
reader going by text alone would answer for the toast, and pass
|
||||||
|
with this line never rendered. */}
|
||||||
|
<p
|
||||||
|
data-connection-check-outcome
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn("font-medium", !outcome && "sr-only")}
|
||||||
|
>
|
||||||
|
{outcome}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
id={captionId}
|
||||||
|
className="text-text-neutral-tertiary max-w-prose text-xs"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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) => (
|
||||||
|
<code className="bg-bg-neutral-tertiary text-text-neutral-secondary rounded px-1 py-0.5 font-mono">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
@@ -4,9 +4,7 @@ import Link from "next/link";
|
|||||||
import { SlackIcon } from "@/components/icons/services/IconServices";
|
import { SlackIcon } from "@/components/icons/services/IconServices";
|
||||||
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
|
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
|
||||||
import { CustomLink } from "@/components/shadcn/custom/custom-link";
|
import { CustomLink } from "@/components/shadcn/custom/custom-link";
|
||||||
|
import { DOCS_URLS } from "@/lib/external-urls";
|
||||||
const SLACK_DOCS_URL =
|
|
||||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration";
|
|
||||||
|
|
||||||
export const SlackIntegrationCard = () => {
|
export const SlackIntegrationCard = () => {
|
||||||
return (
|
return (
|
||||||
@@ -24,7 +22,7 @@ export const SlackIntegrationCard = () => {
|
|||||||
Send Prowler messages to your Slack workspace.
|
Send Prowler messages to your Slack workspace.
|
||||||
</p>
|
</p>
|
||||||
<CustomLink
|
<CustomLink
|
||||||
href={SLACK_DOCS_URL}
|
href={DOCS_URLS.SLACK_INTEGRATION}
|
||||||
aria-label="Learn more about Slack integration"
|
aria-label="Learn more about Slack integration"
|
||||||
size="xs"
|
size="xs"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { format, isValid, parseISO } from "date-fns";
|
import { format, isValid, parseISO } from "date-fns";
|
||||||
import { TestTube, Unplug } from "lucide-react";
|
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 { testIntegrationConnection } from "@/actions/integrations/integrations";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
import { SlackIcon } from "@/components/icons/services/IconServices";
|
import { SlackIcon } from "@/components/icons/services/IconServices";
|
||||||
import { IntegrationCardHeader } from "@/components/integrations/shared";
|
import { IntegrationCardHeader } from "@/components/integrations/shared";
|
||||||
import { SlackChannelMultiSelect } from "@/components/integrations/slack/slack-channel-multi-select";
|
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 {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
@@ -76,6 +82,20 @@ interface UnconfirmedRevocation {
|
|||||||
workspace: string | null;
|
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. */
|
/** Order-insensitive: the mirror must not re-seed on a mere reordering. */
|
||||||
const sameChannelIds = (a: string[], b: string[]) =>
|
const sameChannelIds = (a: string[], b: string[]) =>
|
||||||
a.length === b.length && new Set([...a, ...b]).size === a.length;
|
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 =>
|
const channelList = (names: string[]): string =>
|
||||||
new Intl.ListFormat("en", { style: "long", type: "conjunction" }).format(
|
CHANNEL_LIST_FORMAT.format(channelTokens(names));
|
||||||
names.map((name) => `#${name}`),
|
|
||||||
);
|
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" ? (
|
||||||
|
<SlackInlineCode key={index}>{part.value}</SlackInlineCode>
|
||||||
|
) : (
|
||||||
|
part.value
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slack's own reason, when the string is one: the connection check reports a
|
* Slack's own reason, when the string is one: the connection check reports a
|
||||||
@@ -160,6 +209,10 @@ export const SlackIntegrationManager = ({
|
|||||||
loadError,
|
loadError,
|
||||||
}: SlackIntegrationManagerProps) => {
|
}: SlackIntegrationManagerProps) => {
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
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<ConnectionCheckOutcome | null>(null);
|
||||||
const [isDisconnectOpen, setIsDisconnectOpen] = useState(false);
|
const [isDisconnectOpen, setIsDisconnectOpen] = useState(false);
|
||||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||||
// The row is gone the moment the API says so; the server component's
|
// 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),
|
(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) {
|
if (authorizedChannels.length === 0) {
|
||||||
return "Authorize at least one destination channel below to enable this check.";
|
return "Authorize at least one destination channel below to enable this check.";
|
||||||
}
|
}
|
||||||
return unconfirmedChannels.length > 0
|
return unconfirmedChannels.length > 0 ? (
|
||||||
? `Checks every authorized channel and posts “${CONFIRMATION_MESSAGE}” once to ${channelList(
|
<>
|
||||||
unconfirmedChannels.map((channel) => channel.name),
|
Checks every authorized channel and posts “{CONFIRMATION_MESSAGE}” once
|
||||||
)}.`
|
to{" "}
|
||||||
: "Checks every authorized channel. Each was confirmed once already, so nothing is posted.";
|
<StyledChannelList
|
||||||
|
names={unconfirmedChannels.map((channel) => channel.name)}
|
||||||
|
/>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Checks every authorized channel. Nothing is posted."
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveChannels = async () => {
|
const handleSaveChannels = async () => {
|
||||||
@@ -395,12 +488,24 @@ export const SlackIntegrationManager = ({
|
|||||||
// Passed in by a chained check: it runs before the save's state lands.
|
// Passed in by a chained check: it runs before the save's state lands.
|
||||||
channels: SlackAuthorizedChannel[] = authorizedChannels,
|
channels: SlackAuthorizedChannel[] = authorizedChannels,
|
||||||
) => {
|
) => {
|
||||||
|
const checkedChannelIds = channels.map((channel) => channel.id);
|
||||||
|
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
try {
|
try {
|
||||||
const result = await testIntegrationConnection(id);
|
const result = await testIntegrationConnection(id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
provedCredentialAlive();
|
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({
|
toast({
|
||||||
title: "Connection test successful!",
|
title: "Connection test successful!",
|
||||||
description:
|
description:
|
||||||
@@ -411,8 +516,13 @@ export const SlackIntegrationManager = ({
|
|||||||
// A dead credential named here is not a failure checking again can
|
// A dead credential named here is not a failure checking again can
|
||||||
// fix, so the reason is recorded and not only reported.
|
// fix, so the reason is recorded and not only reported.
|
||||||
const reason = result.error?.trim() || null;
|
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
|
const explanation = reason
|
||||||
? slackErrorMessage({ code: reason, detail: reason })
|
? slackErrorMessage({ code: reason, detail: reason })
|
||||||
@@ -424,15 +534,32 @@ export const SlackIntegrationManager = ({
|
|||||||
? channels.find((channel) => channel.id === result.failedChannelId)
|
? channels.find((channel) => channel.id === result.failedChannelId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const refusal = refusedChannel
|
||||||
|
? `Slack refused #${refusedChannel.name}: ${explanation}`
|
||||||
|
: explanation;
|
||||||
|
|
||||||
|
setCheckOutcome({
|
||||||
|
reachable: false,
|
||||||
|
summary: refusal,
|
||||||
|
channelIds: checkedChannelIds,
|
||||||
|
credentialFailure: isSlackTokenErrorCode(codeAfterCheck)
|
||||||
|
? codeAfterCheck
|
||||||
|
: null,
|
||||||
|
});
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Connection test failed",
|
title: "Connection test failed",
|
||||||
description: refusedChannel
|
description: refusal,
|
||||||
? `Slack refused #${refusedChannel.name}: ${explanation}`
|
|
||||||
: explanation,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} 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({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
@@ -607,11 +734,18 @@ export const SlackIntegrationManager = ({
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : integration && !disconnected ? (
|
) : integration && !disconnected ? (
|
||||||
<Card variant="base">
|
<Card variant="base">
|
||||||
<CardHeader>
|
<CardHeader className="gap-3">
|
||||||
<IntegrationCardHeader
|
<IntegrationCardHeader
|
||||||
icon={<SlackIcon size={32} />}
|
icon={<SlackIcon size={32} />}
|
||||||
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
|
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
|
||||||
subtitle="Prowler posts to this workspace only."
|
subtitle="Prowler posts to this workspace only."
|
||||||
|
meta={
|
||||||
|
lastCheckedOn && (
|
||||||
|
<p className="text-text-neutral-tertiary font-mono text-xs">
|
||||||
|
Last checked {lastCheckedOn}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
connectionStatus={{
|
connectionStatus={{
|
||||||
// A dead token outranks the state the page was loaded with.
|
// A dead token outranks the state the page was loaded with.
|
||||||
connected:
|
connected:
|
||||||
@@ -619,21 +753,8 @@ export const SlackIntegrationManager = ({
|
|||||||
? integration.attributes.connected
|
? integration.attributes.connected
|
||||||
: false,
|
: false,
|
||||||
}}
|
}}
|
||||||
/>
|
actions={
|
||||||
</CardHeader>
|
<>
|
||||||
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-300">
|
|
||||||
{lastCheckedOn && (
|
|
||||||
<p>
|
|
||||||
<span className="font-medium">Last checked:</span>{" "}
|
|
||||||
{lastCheckedOn}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* The check reaches the authorized channels: the API
|
{/* The check reaches the authorized channels: the API
|
||||||
answers 400 while the set is empty. */}
|
answers 400 while the set is empty. */}
|
||||||
<Button
|
<Button
|
||||||
@@ -661,17 +782,23 @@ export const SlackIntegrationManager = ({
|
|||||||
<Unplug size={14} />
|
<Unplug size={14} />
|
||||||
Disconnect
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
<p
|
}
|
||||||
id={CHECK_HINT_ID}
|
/>
|
||||||
className="max-w-prose text-xs text-gray-500 sm:text-right dark:text-gray-300"
|
|
||||||
>
|
|
||||||
{checkHint()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-border-neutral-secondary mt-6 flex flex-col gap-4 border-t pt-6">
|
{/* Below the row, not beside the buttons: the width is what lets
|
||||||
|
the check say what it did without wrapping into a column. */}
|
||||||
|
<SlackConnectionCheckStatus
|
||||||
|
status={checkStatus()}
|
||||||
|
outcome={checkSummary}
|
||||||
|
captionId={CHECK_HINT_ID}
|
||||||
|
>
|
||||||
|
{checkHint()}
|
||||||
|
</SlackConnectionCheckStatus>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="border-border-neutral-secondary flex flex-col gap-4 border-t pt-6">
|
||||||
<SlackChannelMultiSelect
|
<SlackChannelMultiSelect
|
||||||
options={channelOptions}
|
options={channelOptions}
|
||||||
values={selectedChannelIds}
|
values={selectedChannelIds}
|
||||||
@@ -714,19 +841,24 @@ export const SlackIntegrationManager = ({
|
|||||||
className="text-text-neutral-secondary text-xs"
|
className="text-text-neutral-secondary text-xs"
|
||||||
data-authorized-channels
|
data-authorized-channels
|
||||||
>
|
>
|
||||||
{authorizedChannels.length > 0
|
{authorizedChannels.length > 0 ? (
|
||||||
? `Prowler posts to ${channelList(
|
<>
|
||||||
authorizedChannels.map((channel) => channel.name),
|
Prowler posts to{" "}
|
||||||
)}.`
|
<StyledChannelList
|
||||||
: "No destination channels authorized yet."}
|
names={authorizedChannels.map(
|
||||||
|
(channel) => channel.name,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"No destination channels authorized yet."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={
|
disabled={
|
||||||
sameChannelIds(
|
sameChannelIds(selectedChannelIds, authorizedChannelIds) ||
|
||||||
selectedChannelIds,
|
|
||||||
authorizedChannels.map((channel) => channel.id),
|
|
||||||
) ||
|
|
||||||
isSavingChannels ||
|
isSavingChannels ||
|
||||||
isTesting
|
isTesting
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export const DOCS_URLS = {
|
|||||||
GCP_ORGANIZATIONS:
|
GCP_ORGANIZATIONS:
|
||||||
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-gcp-organizations",
|
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-gcp-organizations",
|
||||||
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",
|
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",
|
||||||
|
SLACK_INTEGRATION:
|
||||||
|
"https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration",
|
||||||
|
SLACK_INTEGRATION_PRIVATE_CHANNELS:
|
||||||
|
"https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration#why-a-private-channel-is-missing-from-the-channel-list",
|
||||||
SCAN_CONFIGURATION:
|
SCAN_CONFIGURATION:
|
||||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration",
|
"https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration",
|
||||||
ATTACK_PATHS_CUSTOM_QUERIES:
|
ATTACK_PATHS_CUSTOM_QUERIES:
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const SLACK_RATE_LIMITED_MESSAGE =
|
|||||||
* out, or `links.next` left the API's origin.
|
* out, or `links.next` left the API's origin.
|
||||||
*/
|
*/
|
||||||
export const SLACK_PARTIAL_CHANNEL_LIST_MESSAGE =
|
export const SLACK_PARTIAL_CHANNEL_LIST_MESSAGE =
|
||||||
"This workspace has more channels than Prowler reads in one go, so this list is not all of them. A channel missing from it is not necessarily one @Prowler has to be invited to.";
|
"This workspace has more channels than Prowler reads in one go, so this list is not all of them. A channel missing from it is not necessarily one @Prowler Cloud has to be invited to.";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For a `2xx` the UI could not read. Not phrased as a failure: the install
|
* For a `2xx` the UI could not read. Not phrased as a failure: the install
|
||||||
@@ -69,7 +69,7 @@ export const SLACK_ERROR_MESSAGES = {
|
|||||||
[SLACK_ERROR_CODE.CHANNEL_NOT_FOUND]:
|
[SLACK_ERROR_CODE.CHANNEL_NOT_FOUND]:
|
||||||
"That channel no longer exists in the workspace. Choose another one.",
|
"That channel no longer exists in the workspace. Choose another one.",
|
||||||
[SLACK_ERROR_CODE.NOT_IN_CHANNEL]:
|
[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.",
|
"Prowler is not in that channel. Invite @Prowler Cloud to it in Slack, or choose a channel it can already post to.",
|
||||||
[SLACK_ERROR_CODE.NO_PERMISSION]:
|
[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 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.TOKEN_REVOKED]: `Prowler's access to Slack was revoked. ${RECONNECT}`,
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export interface IntegrationProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A channel Prowler can post to: every active public channel, plus the private
|
* A channel Prowler can post to: every active public channel, plus the private
|
||||||
* ones `@Prowler` was invited to. `is_private` keeps the API's own naming.
|
* ones `@Prowler Cloud` was invited to. `is_private` keeps the API's own naming.
|
||||||
*/
|
*/
|
||||||
export interface SlackChannelOption {
|
export interface SlackChannelOption {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||