mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(ui): add Slack integration connect flow (#12435)
This commit is contained in:
@@ -752,6 +752,51 @@ export const JiraIcon: React.FC<IconSvgProps> = ({
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SlackIcon: React.FC<IconSvgProps> = ({
|
||||
size = 32,
|
||||
width,
|
||||
height,
|
||||
className = "rounded-md",
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={height ?? size}
|
||||
viewBox="0 0 48 48"
|
||||
width={width ?? size}
|
||||
className={className}
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
{/* Slack mark on its native 122.8 grid, scaled into the 48px tile's 30px
|
||||
safe area (30 / 122.8 = 0.2443). */}
|
||||
<g transform="translate(9 9) scale(0.2443)">
|
||||
<path
|
||||
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9zm6.5 0c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2zm0 6.5c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2zm-6.5 0c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9zm0-6.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const AWSSecurityHubIcon: React.FC<IconSvgProps> = ({
|
||||
size = 32,
|
||||
width,
|
||||
|
||||
@@ -6,18 +6,47 @@ import { ReactNode } from "react";
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// `null` means never checked, not disconnected: it must not get the fail tokens.
|
||||
const CONNECTION_BADGE = {
|
||||
connected: {
|
||||
label: "Connected",
|
||||
className:
|
||||
"bg-bg-pass-secondary text-text-success-primary border-transparent",
|
||||
},
|
||||
disconnected: {
|
||||
label: "Disconnected",
|
||||
className:
|
||||
"bg-bg-fail-secondary text-text-error-primary border-transparent",
|
||||
},
|
||||
unchecked: {
|
||||
label: "Not checked yet",
|
||||
className: "border-border-tag bg-bg-tag text-text-neutral-secondary",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ConnectionBadgeState = keyof typeof CONNECTION_BADGE;
|
||||
|
||||
const connectionBadgeState = (
|
||||
connected: boolean | null,
|
||||
): ConnectionBadgeState =>
|
||||
connected === null ? "unchecked" : connected ? "connected" : "disconnected";
|
||||
|
||||
interface IntegrationCardChip {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface IntegrationConnectionStatus {
|
||||
connected: boolean | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface IntegrationCardHeaderProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
chips?: Array<{
|
||||
label: string;
|
||||
className?: string;
|
||||
}>;
|
||||
connectionStatus?: {
|
||||
connected: boolean;
|
||||
label?: string;
|
||||
};
|
||||
chips?: IntegrationCardChip[];
|
||||
connectionStatus?: IntegrationConnectionStatus;
|
||||
navigationUrl?: string;
|
||||
}
|
||||
|
||||
@@ -29,6 +58,11 @@ export const IntegrationCardHeader = ({
|
||||
connectionStatus,
|
||||
navigationUrl,
|
||||
}: IntegrationCardHeaderProps) => {
|
||||
const badgeState = connectionStatus
|
||||
? connectionBadgeState(connectionStatus.connected)
|
||||
: null;
|
||||
const badge = badgeState ? CONNECTION_BADGE[badgeState] : null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -55,7 +89,7 @@ export const IntegrationCardHeader = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(chips.length > 0 || connectionStatus) && (
|
||||
{(chips.length > 0 || badge) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chips.map((chip, index) => (
|
||||
<Badge
|
||||
@@ -69,18 +103,13 @@ export const IntegrationCardHeader = ({
|
||||
{chip.label}
|
||||
</Badge>
|
||||
))}
|
||||
{connectionStatus && (
|
||||
{badge && badgeState && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
connectionStatus.connected
|
||||
? "bg-bg-pass-secondary text-text-success-primary border-transparent"
|
||||
: "bg-bg-fail-secondary text-text-error-primary border-transparent",
|
||||
)}
|
||||
data-connection-status={badgeState}
|
||||
className={cn("text-xs font-normal", badge.className)}
|
||||
>
|
||||
{connectionStatus.label ||
|
||||
(connectionStatus.connected ? "Connected" : "Disconnected")}
|
||||
{connectionStatus?.label || badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* The cases `slack-page.integration.test.tsx` cannot express: it runs the Server
|
||||
* Action as a plain function, so there is no client→server transport to reject,
|
||||
* and its handler only answers the contract's shapes. React error boundaries
|
||||
* cannot see a rejection awaited in an effect, so an uncaught one leaves the
|
||||
* user on the spinner with no error and no way out.
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationProps } from "@/types/integrations";
|
||||
|
||||
import { SlackCallback } from "./slack-callback";
|
||||
|
||||
const COMPLETED_QUERY = "code=slack-code-1f4a&state=st-2f1c9d7a";
|
||||
|
||||
const { exchangeSlackOAuthCode, callbackQuery, routerReplace } = vi.hoisted(
|
||||
() => ({
|
||||
exchangeSlackOAuthCode: vi.fn(),
|
||||
callbackQuery: { value: "" },
|
||||
routerReplace: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/actions/integrations/slack", () => ({ exchangeSlackOAuthCode }));
|
||||
|
||||
// One router across renders, so the redirect off the spent code is assertable.
|
||||
const router = { replace: routerReplace };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => router,
|
||||
useSearchParams: () => new URLSearchParams(callbackQuery.value),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
callbackQuery.value = COMPLETED_QUERY;
|
||||
routerReplace.mockClear();
|
||||
});
|
||||
|
||||
const SPINNER_COPY = /Connecting your Slack workspace/;
|
||||
|
||||
/**
|
||||
* Literals, not imports: a rename on the component's side has to fail here.
|
||||
* `FAILURE_TITLE` claims nothing was connected, which only holds for outcomes
|
||||
* that happen before the API consumed the code.
|
||||
*/
|
||||
const FAILURE_TITLE = "Slack workspace not connected";
|
||||
const UNCONFIRMED_TITLE = "Slack install not confirmed";
|
||||
|
||||
describe("returning from Slack when the completion answers unexpectedly", () => {
|
||||
it("reports an unconfirmed result instead of spinning forever when the exchange call never comes back", async () => {
|
||||
// The client→server POST itself fails (dropped connection, action id
|
||||
// invalidated by a deploy), so the action's own error handling never runs.
|
||||
exchangeSlackOAuthCode.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
render(<SlackCallback />);
|
||||
|
||||
// The API consumes the single-use code before answering, so the workspace
|
||||
// may well be connected: unknown, not failed.
|
||||
expect(
|
||||
await screen.findByText(/could not confirm whether the workspace/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Back to Slack integration/ }),
|
||||
).toHaveAttribute("href", "/integrations/slack");
|
||||
expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(UNCONFIRMED_TITLE)).toBeInTheDocument();
|
||||
expect(screen.queryByText(FAILURE_TITLE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still reports the workspace as connected when the created integration carries no configuration", async () => {
|
||||
// The install already succeeded; `configuration` only goes missing on the
|
||||
// client, where the callback reads the workspace name off it.
|
||||
exchangeSlackOAuthCode.mockResolvedValue({
|
||||
integration: {
|
||||
type: "integrations",
|
||||
id: "slack-integration-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-08-10T09:00:00Z",
|
||||
updated_at: "2026-08-10T09:00:00Z",
|
||||
enabled: true,
|
||||
connected: null,
|
||||
connection_last_checked_at: null,
|
||||
integration_type: "slack",
|
||||
},
|
||||
links: { self: "/api/v1/integrations/slack-integration-1" },
|
||||
// Cast: the shape is the one the contract rules out.
|
||||
} as unknown as IntegrationProps,
|
||||
});
|
||||
|
||||
render(<SlackCallback />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Connected to your Slack workspace/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
|
||||
// Keyed on the escape link, the only element unique to the failure branch,
|
||||
// so this holds whichever headline that branch would have carried.
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /Back to Slack integration/ }),
|
||||
).not.toBeInTheDocument();
|
||||
// `replace`, not `push`: a back navigation must not remount onto the code.
|
||||
expect(routerReplace).toHaveBeenCalledWith("/integrations/slack");
|
||||
});
|
||||
});
|
||||
|
||||
describe("returning from Slack with an error on the callback URL", () => {
|
||||
it("says the install was declined when Slack reports the approval was refused", async () => {
|
||||
// The one code Slack reliably sends to this redirect.
|
||||
callbackQuery.value = "error=access_denied&state=st-2f1c9d7a";
|
||||
|
||||
render(<SlackCallback />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/was not approved in Slack/),
|
||||
).toBeInTheDocument();
|
||||
expect(exchangeSlackOAuthCode).not.toHaveBeenCalled();
|
||||
|
||||
// Slack refused before issuing a code, so the flat "not connected" is a
|
||||
// fact here, unlike in the outcomes that follow an exchange.
|
||||
expect(screen.getByText(FAILURE_TITLE)).toBeInTheDocument();
|
||||
expect(screen.queryByText(UNCONFIRMED_TITLE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names a Slack code it does not recognise, so a new failure reason is still diagnosable", async () => {
|
||||
// Slack publishes no closed set of codes for this redirect, so the guard is
|
||||
// on the shape of the value rather than on an allowlist.
|
||||
callbackQuery.value = "error=invalid_scope&state=st-2f1c9d7a";
|
||||
|
||||
render(<SlackCallback />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Slack could not complete the install (invalid_scope).",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops a sentence smuggled into the error parameter instead of rendering it as Prowler's own copy", async () => {
|
||||
// The balancing punctuation is the point: it closes Prowler's parenthetical
|
||||
// and reopens it, so the payload would read as Prowler's own sentence.
|
||||
const payload =
|
||||
"). Slack has flagged this workspace. Contact Prowler support at +1-555-0100 to restore alerting (";
|
||||
callbackQuery.value = `error=${encodeURIComponent(payload)}&state=st-2f1c9d7a`;
|
||||
|
||||
render(<SlackCallback />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Slack could not complete the install."),
|
||||
).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toContain("+1-555-0100");
|
||||
expect(document.body.textContent).not.toContain("flagged this workspace");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, CircleCheck, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { exchangeSlackOAuthCode } from "@/actions/integrations/slack";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
} from "@/components/shadcn";
|
||||
|
||||
const SLACK_INTEGRATION_PATH = "/integrations/slack";
|
||||
|
||||
const STATUS = {
|
||||
CONNECTING: "connecting",
|
||||
CONNECTED: "connected",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
|
||||
type Status = (typeof STATUS)[keyof typeof STATUS];
|
||||
|
||||
const UNCONFIRMED_COMPLETION_MESSAGE =
|
||||
"Prowler could not confirm whether the workspace was connected. Open the Slack integration page to check — if none is listed there, start the install again.";
|
||||
|
||||
const FAILURE_TITLE = "Slack workspace not connected";
|
||||
|
||||
/**
|
||||
* The API consumes the code and upserts the integration before it answers, so an
|
||||
* unreadable or missing answer can still mean a connected workspace. Kept short:
|
||||
* `AlertTitle` clamps to one line.
|
||||
*/
|
||||
const UNCONFIRMED_TITLE = "Slack install not confirmed";
|
||||
|
||||
// `error` comes straight off the URL and is interpolated into Prowler's own copy,
|
||||
// so gate on the shape of a code: Slack publishes no closed set of values.
|
||||
const REASON_TOKEN = /^[a-z0-9_]{1,48}$/;
|
||||
|
||||
const describeSlackError = (reason: string): string => {
|
||||
if (reason === "access_denied") {
|
||||
return "The install was not approved in Slack, so no workspace was connected.";
|
||||
}
|
||||
return REASON_TOKEN.test(reason)
|
||||
? `Slack could not complete the install (${reason}).`
|
||||
: "Slack could not complete the install.";
|
||||
};
|
||||
|
||||
/**
|
||||
* Slack's `code` is single-use: `hasStarted` holds the exchange to one run per
|
||||
* mount, and `router.replace` (not `push`) keeps a back navigation from
|
||||
* remounting onto a spent code.
|
||||
*/
|
||||
export const SlackCallback = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [status, setStatus] = useState<Status>(STATUS.CONNECTING);
|
||||
const [workspaceName, setWorkspaceName] = useState<string | null>(null);
|
||||
const [failure, setFailure] = useState<string>("");
|
||||
const [failureTitle, setFailureTitle] = useState<string>(FAILURE_TITLE);
|
||||
const hasStarted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasStarted.current) return;
|
||||
hasStarted.current = true;
|
||||
|
||||
const slackError = searchParams.get("error");
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
|
||||
// Slack answers a declined install with `error` and no code, so there is
|
||||
// nothing to exchange.
|
||||
if (slackError) {
|
||||
setFailure(describeSlackError(slackError));
|
||||
setStatus(STATUS.FAILED);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
setFailure(
|
||||
"Slack sent an incomplete response back, so the install could not be completed.",
|
||||
);
|
||||
setStatus(STATUS.FAILED);
|
||||
return;
|
||||
}
|
||||
|
||||
const complete = async () => {
|
||||
const result = await exchangeSlackOAuthCode({ code, state });
|
||||
|
||||
if ("integration" in result) {
|
||||
setWorkspaceName(
|
||||
result.integration.attributes?.configuration?.team_name ?? null,
|
||||
);
|
||||
setStatus(STATUS.CONNECTED);
|
||||
router.replace(SLACK_INTEGRATION_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("unavailable" in result) {
|
||||
setFailure("Slack is not available in this environment yet.");
|
||||
} else if ("rateLimited" in result) {
|
||||
setFailure(result.message);
|
||||
} else if ("unconfirmed" in result) {
|
||||
setFailure(result.message);
|
||||
setFailureTitle(UNCONFIRMED_TITLE);
|
||||
} else {
|
||||
setFailure(result.error);
|
||||
}
|
||||
setStatus(STATUS.FAILED);
|
||||
};
|
||||
|
||||
// A rejection here means the call never came back (stale action id after a
|
||||
// deploy, HTML 502): error boundaries cannot see a rejection awaited inside
|
||||
// an effect, and the once-guard blocks a retry, so the page would spin.
|
||||
void complete().catch(() => {
|
||||
setFailure(UNCONFIRMED_COMPLETION_MESSAGE);
|
||||
setFailureTitle(UNCONFIRMED_TITLE);
|
||||
setStatus(STATUS.FAILED);
|
||||
});
|
||||
}, [router, searchParams]);
|
||||
|
||||
if (status === STATUS.CONNECTING) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
Connecting your Slack workspace...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === STATUS.CONNECTED) {
|
||||
return (
|
||||
<Alert variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>
|
||||
Connected to {workspaceName ?? "your Slack workspace"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Taking you back to the Slack integration, where you can choose the
|
||||
channel Prowler posts to.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<Alert variant="error">
|
||||
<AlertCircle />
|
||||
<AlertTitle>{failureTitle}</AlertTitle>
|
||||
<AlertDescription>{failure}</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={SLACK_INTEGRATION_PATH}>Back to Slack integration</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { SlackIcon } from "@/components/icons/services/IconServices";
|
||||
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/shadcn/custom/custom-link";
|
||||
|
||||
// Placeholder slug: the docs slice writes the page and confirms it.
|
||||
const SLACK_DOCS_URL =
|
||||
"https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-slack-integration/";
|
||||
|
||||
export const SlackIntegrationCard = () => {
|
||||
return (
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<div className="flex w-full flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<SlackIcon size={40} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Slack
|
||||
</h4>
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
|
||||
<p className="text-xs text-nowrap text-gray-500 dark:text-gray-300">
|
||||
Send Prowler messages to your Slack workspace.
|
||||
</p>
|
||||
<CustomLink
|
||||
href={SLACK_DOCS_URL}
|
||||
aria-label="Learn more about Slack integration"
|
||||
size="xs"
|
||||
>
|
||||
Learn more
|
||||
</CustomLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/integrations/slack">
|
||||
<SettingsIcon size={14} />
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Connect a Slack workspace and pick the channel Prowler posts to, so
|
||||
your team gets security updates where it already works.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { format, isValid, parseISO } from "date-fns";
|
||||
|
||||
import { SlackIcon } from "@/components/icons/services/IconServices";
|
||||
import { IntegrationCardHeader } from "@/components/integrations/shared";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
} from "@/components/shadcn";
|
||||
import type { IntegrationProps } from "@/types/integrations";
|
||||
|
||||
interface SlackIntegrationManagerProps {
|
||||
/** At most one exists per tenant (one workspace). */
|
||||
integration: IntegrationProps | null;
|
||||
authorizeUrl: string | null;
|
||||
/** This deployment has no Prowler Slack app, so no install can be started. */
|
||||
unavailable: boolean;
|
||||
rateLimitMessage: string | null;
|
||||
loadError: string | null;
|
||||
}
|
||||
|
||||
export const SlackIntegrationManager = ({
|
||||
integration,
|
||||
authorizeUrl,
|
||||
unavailable,
|
||||
rateLimitMessage,
|
||||
loadError,
|
||||
}: SlackIntegrationManagerProps) => {
|
||||
const configuration = integration?.attributes.configuration;
|
||||
const workspaceName = configuration?.team_name;
|
||||
// Absent until a channel is chosen, never present-and-null.
|
||||
const channelId = configuration?.channel_id ?? null;
|
||||
|
||||
const checkedAt = integration?.attributes.connection_last_checked_at;
|
||||
const checkedOn = checkedAt ? parseISO(checkedAt) : null;
|
||||
// `format` throws a RangeError on an unreadable value, which would replace
|
||||
// the page with the route's error boundary: show nothing instead, as for a
|
||||
// connection that was never checked.
|
||||
const lastCheckedOn =
|
||||
checkedOn && isValid(checkedOn) ? format(checkedOn, "yyyy/MM/dd") : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{rateLimitMessage && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Slack is busy right now</AlertTitle>
|
||||
<AlertDescription>{rateLimitMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loadError && (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not load your Slack integration</AlertTitle>
|
||||
<AlertDescription>{loadError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Replaces the cards, not the whole page: an early return here would
|
||||
swallow the rate-limit and load-error notices above. */}
|
||||
{unavailable ? (
|
||||
<Alert variant="info">
|
||||
<AlertTitle>
|
||||
Slack is not available in this environment yet
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
The Prowler Slack app is not configured here, so no workspace can be
|
||||
connected. Nothing to do on your side — this page starts working as
|
||||
soon as it is.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : integration ? (
|
||||
<Card variant="base">
|
||||
<CardHeader>
|
||||
<IntegrationCardHeader
|
||||
icon={<SlackIcon size={32} />}
|
||||
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
|
||||
subtitle="Prowler posts to this workspace only."
|
||||
connectionStatus={{
|
||||
connected: integration.attributes.connected,
|
||||
}}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{/* No check here: it needs a destination channel, and nothing on
|
||||
this page records one yet. */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-300">
|
||||
{lastCheckedOn && (
|
||||
<p>
|
||||
<span className="font-medium">Last checked:</span>{" "}
|
||||
{lastCheckedOn}
|
||||
</p>
|
||||
)}
|
||||
{!channelId && (
|
||||
<p>
|
||||
Choosing a destination channel is the next step — the
|
||||
connection is checked against it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card variant="base">
|
||||
<CardHeader>
|
||||
<IntegrationCardHeader
|
||||
icon={<SlackIcon size={32} />}
|
||||
title="No workspace connected"
|
||||
subtitle="Approve Prowler in Slack to connect a workspace. No tokens to copy."
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Prowler asks for permission to post messages and to read the
|
||||
workspace's channel list.
|
||||
</p>
|
||||
{authorizeUrl ? (
|
||||
<Button asChild>
|
||||
<a href={authorizeUrl} rel="noopener noreferrer">
|
||||
<SlackIcon size={16} />
|
||||
Add to Slack
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled>
|
||||
<SlackIcon size={16} />
|
||||
Add to Slack
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user