feat(ui): add Slack disconnect and revoked-credential recovery

- Disconnect a Slack workspace behind a confirmation, returning the
  page to its unconnected state
- Report the revocation outcome from the DELETE response meta, telling
  the user when access still has to be removed in Slack by hand
- Offer to connect the workspace again when Slack stops accepting the
  stored credential
- Widen the integration `connected` attribute to allow null, which the
  OAuth exchange returns until the first connection check runs
This commit is contained in:
Pablo F.G
2026-08-18 10:02:45 +02:00
parent 4c181127c2
commit 293fe59101
11 changed files with 534 additions and 19 deletions
@@ -78,6 +78,18 @@ export interface SlackRefusalFixture {
retryAfterSeconds: number | null;
}
/**
* What `DELETE /integrations/{id}` reports about revoking the token at Slack.
* Revocation is best-effort: the row goes either way, and the outcome travels in
* JSON:API `meta` so the UI can say when access still needs removing by hand.
*/
export interface SlackRevocationFixture {
/** Slack confirmed the token no longer grants Prowler anything. */
revoked: boolean;
/** Slack's reason when it did not. */
error: string | null;
}
export interface SlackFixture {
/**
* The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` /
@@ -121,6 +133,8 @@ export interface SlackFixture {
*/
channelSaveRefusal: SlackRefusalFixture | null;
testMessage: SlackTestMessageFixture;
/** What disconnecting reports about revoking the token at Slack. */
revocation: SlackRevocationFixture;
}
/**
@@ -306,6 +320,18 @@ export const SLACK_CHANNELS_PAGE_SIZE = 2;
*/
export const SLACK_DEFAULT_CHANNEL = SLACK_PUBLIC_CHANNEL;
/**
* Slack's reason when `auth.revoke` could not be delivered while disconnecting.
* The row is still gone; only the revocation failed.
*/
export const SLACK_REVOKE_FAILURE_REASON = "invalid_auth";
/**
* What Slack answers once a workspace admin has revoked Prowler's token, so any
* call made with it proves the credential unusable (contract, Cross-cutting).
*/
export const SLACK_TOKEN_REVOKED_REASON = "token_revoked";
const PROWLER_HQ: SlackWorkspaceFixture = {
teamId: "T01PROWLER",
teamName: "Prowler HQ",
@@ -329,6 +355,7 @@ export const slackFixture = (
channelsRefusal: null,
channelSaveRefusal: null,
testMessage: { accepted: true, error: null },
revocation: { revoked: true, error: null },
...overrides,
});
@@ -408,3 +435,29 @@ export const configuredSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixtureWithDefaultChannel(SLACK_DEFAULT_CHANNEL, overrides);
/**
* A connected tenant whose disconnect removes the row but cannot revoke at
* Slack — the outcome the user has to finish by hand in the workspace.
*/
export const revokeFailureSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({
revocation: { revoked: false, error: SLACK_REVOKE_FAILURE_REASON },
...overrides,
});
/**
* A connected tenant whose token has been revoked at Slack: the row still says
* connected until a check runs, and the check is what surfaces it. The check
* itself needs a destination channel on record — the API refuses to test one
* that has none — so this builds on the finished setup, not the bare install.
*/
export const revokedTokenSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
configuredSlackFixture({
connection: { connected: false, error: SLACK_TOKEN_REVOKED_REASON },
...overrides,
});
+15
View File
@@ -366,5 +366,20 @@ export const handlersForSlack = (fx: SlackFixture) => {
);
},
),
// Disconnect. Revocation at Slack is best-effort: the row is removed either
// way and the outcome is reported in `meta`, so the UI can tell the user
// when access still has to be removed by hand in the workspace.
//
// The contract fixes where the outcome travels but not the key names, so
// `revoked` / `revocation_error` are this lane's reading of it — confirm at
// the S3 coordination checkpoint and change it here first if it differs.
http.delete(`${API}/integrations/:id`, () => {
install = null;
const { revoked, error } = fx.revocation;
return HttpResponse.json({
meta: { revoked, ...(error ? { revocation_error: error } : {}) },
});
}),
];
};
+75
View File
@@ -532,3 +532,78 @@ export const sendSlackTestMessage = async (
return handleApiError(error);
}
};
/** What the API reports about revoking Prowler's token at Slack. */
export interface SlackRevocation {
/**
* Whether Slack confirmed the token no longer grants Prowler anything, or
* `null` when the response carried no outcome at all. The contract says the
* outcome is always reported, so `null` means the response is wrong rather
* than the revocation — and neither answer is claimed on the user's behalf.
*/
revoked: boolean | null;
/** Slack's reason when it did not, when it gave one. */
error: string | null;
}
interface SlackDisconnectSuccess {
/** The integration is gone from Prowler, whatever Slack answered. */
disconnected: true;
revocation: SlackRevocation;
}
export type SlackDisconnectResult = SlackDisconnectSuccess | SlackActionError;
/**
* Disconnect the workspace: `DELETE /integrations/{id}`.
*
* The generic `deleteIntegration` cannot serve this: it discards the response
* body, and the whole point here is what the body carries. Revocation at Slack
* is best-effort — the row is removed either way and the outcome travels in
* JSON:API `meta` — so a caller has to be able to distinguish "gone and revoked"
* from "gone, but still installed in Slack".
*
* `revoked` is reported only as the API states it: a body without the field (an
* empty `204`, say) yields `null`, not `false`. An unreported outcome is not a
* failed revocation — it must not send the user off to clean up Slack — and it
* is not a confirmed one either, so it must not be reported as access having
* been revoked. The row is gone in all three cases, and that much is said.
*/
export const disconnectSlackIntegration = async (
id: string,
): Promise<SlackDisconnectResult> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
try {
const response = await fetch(url.toString(), { method: "DELETE", headers });
if (!response.ok) {
return {
error: await errorMessageFrom(
response,
`Unable to disconnect the Slack workspace: ${response.statusText}`,
),
};
}
const body = await response.json().catch(() => ({}));
const meta = body?.meta ?? {};
revalidatePath("/integrations");
revalidatePath("/integrations/slack");
return {
disconnected: true,
revocation: {
revoked: typeof meta.revoked === "boolean" ? meta.revoked : null,
error:
typeof meta.revocation_error === "string"
? meta.revocation_error
: null,
},
};
} catch (error) {
return handleApiError(error);
}
};
@@ -40,6 +40,15 @@ export type TestMessageOutcome =
/** Sentinel: the page settled on "no channel recorded", rather than not yet. */
const NO_DEFAULT_CHANNEL = "<no channel recorded>";
/** Whether disconnecting also revoked Prowler's token at Slack. */
export const REVOCATION_OUTCOME = {
REVOKED: "revoked",
NOT_REVOKED: "not-revoked",
} as const;
export type RevocationOutcome =
(typeof REVOCATION_OUTCOME)[keyof typeof REVOCATION_OUTCOME];
interface CallbackParams {
code?: string;
state?: string;
@@ -675,4 +684,109 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
);
return (description?.textContent ?? "").trim();
}
// --- Disconnecting ------------------------------------------------------
get disconnectCallCount(): number {
return this.countRequests("DELETE", "/integrations/");
}
/**
* Disconnects the workspace, confirming the way a user has to, and reports
* what the page says about the revocation — the two outcomes are mutually
* exclusive, so asking for one is also a check that the other is absent.
*/
async disconnect(): Promise<RevocationOutcome> {
// The card's action opens the confirmation; the dialog's own button carries
// the noun too, so the two never resolve to each other.
await this.clickButton(/^\s*Disconnect\s*$/);
await this.clickButton(/Disconnect workspace/);
return this.waitFor(
() => {
if (this.alertMatching(/revocation/i)) {
return REVOCATION_OUTCOME.NOT_REVOKED;
}
if (this.containsText(/Slack workspace disconnected/)) {
return REVOCATION_OUTCOME.REVOKED;
}
return null;
},
15000,
"the disconnect outcome",
);
}
/**
* Whether the page is back to offering an install with no workspace
* connected. The consent URL is minted after the disconnect, so the install
* affordance appears a beat after the copy does.
*/
async returnedToUnconnectedState(): Promise<boolean> {
await this.waitForText(/No workspace connected/, 10000);
return (
(await this.waitForOrNull(
() => this.offersInstall(),
5000,
"the install to be offered again",
)) ?? false
);
}
/**
* What the user is told when the row was removed but Slack never confirmed
* the revocation.
*/
async revocationNotice(): Promise<string> {
const notice = await this.waitFor(
() => this.alertMatching(/revocation/i),
10000,
"the revocation notice",
);
return (notice.textContent ?? "").trim();
}
// --- A credential Slack no longer accepts --------------------------------
/** What the user is told when Slack has stopped accepting the token. */
async revokedCredentialNotice(): Promise<string> {
const notice = await this.waitFor(
() => this.alertMatching(/has been revoked/),
10000,
"the revoked-credential notice",
);
return (notice.textContent ?? "").trim();
}
private reconnectLink(): HTMLAnchorElement | null {
return (
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
/Reconnect to Slack/.test(anchor.textContent ?? ""),
) ?? null
);
}
/** Whether the page offers to approve Prowler in the workspace again. */
offersReconnect(): boolean {
return this.reconnectLink() !== null;
}
/** The consent URL the reconnect affordance points at, once it is offered. */
async reconnectUrl(): Promise<string> {
const link = await this.waitFor(
() => this.reconnectLink(),
10000,
"the reconnect link",
);
return link.href;
}
/** The alert whose text matches, of however many the page is showing. */
private alertMatching(pattern: RegExp): HTMLElement | null {
return (
Array.from(
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
).find((alert) => pattern.test(alert.textContent ?? "")) ?? null
);
}
}
@@ -13,6 +13,8 @@ import {
connectedSlackFixture,
INTEGRATIONS_SERVER_ERROR_DETAIL,
partiallyReadSlackFixture,
revokedTokenSlackFixture,
revokeFailureSlackFixture,
SLACK_CHANNEL_NOT_FOUND_REFUSAL,
SLACK_MISSING_SCOPE_CODE,
SLACK_MISSING_SCOPE_REFUSAL,
@@ -21,8 +23,10 @@ import {
SLACK_PRIVATE_CHANNEL,
SLACK_PUBLIC_CHANNEL,
SLACK_RATE_LIMITED_REFUSAL,
SLACK_REVOKE_FAILURE_REASON,
SLACK_SECOND_PUBLIC_CHANNEL,
SLACK_TEST_MESSAGE_REFUSED_DETAIL,
SLACK_TOKEN_REVOKED_REASON,
SLACK_UNKNOWN_CHANNEL_DETAIL,
SLACK_UNMAPPED_REASON_CODE,
SLACK_UPSTREAM_REFUSAL,
@@ -33,6 +37,7 @@ import {
import {
CONNECTION_OUTCOME,
REVOCATION_OUTCOME,
SlackIntegrationHarness,
TEST_MESSAGE_OUTCOME,
} from "./slack-integration.harness";
@@ -640,3 +645,65 @@ describe("sending a test message", () => {
expect(reported).not.toBe(SLACK_UNMAPPED_REASON_CODE);
}, 60000);
});
describe("disconnecting a workspace", () => {
it("removes the integration and returns the card to its unconnected state", async () => {
// Given — a tenant with a workspace connected.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// When — the user disconnects and confirms.
// Then — Slack confirmed the revocation, so the user is told the access is
// gone and nothing warns them to finish the job by hand.
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.REVOKED);
// And the integration is gone, with the page offering a fresh install.
expect(harness.disconnectCallCount).toBe(1);
expect(await harness.returnedToUnconnectedState()).toBe(true);
}, 30000);
it("still removes the integration when the revocation fails, and says access may need removing by hand", async () => {
// Given — Slack will not accept the revocation. Revocation is best-effort:
// the row goes either way and the outcome travels in `meta`.
const harness = new SlackIntegrationHarness(revokeFailureSlackFixture());
await harness.mount();
// When
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.NOT_REVOKED);
// Then — the user reads what is true of both sides: nothing is left in
// Prowler to retry, and the app may still be installed at Slack.
const notice = await harness.revocationNotice();
expect(notice).toMatch(/gone from Prowler/);
expect(notice).toMatch(/nothing to retry here/);
expect(notice).toMatch(/may still be installed/);
expect(notice).toMatch(new RegExp(SLACK_REVOKE_FAILURE_REASON));
// The row is removed regardless, so the page does not keep offering a
// workspace that no longer exists here.
expect(await harness.returnedToUnconnectedState()).toBe(true);
}, 30000);
});
describe("a credential Slack no longer accepts", () => {
it("reports the revoked token and offers to connect the workspace again", async () => {
// Given — the token was revoked at Slack, so the row still reads connected
// until a check runs (contract, Cross-cutting).
const harness = new SlackIntegrationHarness(revokedTokenSlackFixture());
await harness.mount();
// When
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
// Then — the reason Slack gave, and a way forward rather than only an
// error: a revoked token is fixed by approving Prowler again, not by
// checking a second time.
expect(await harness.revokedCredentialNotice()).toMatch(
new RegExp(SLACK_TOKEN_REVOKED_REASON),
);
const consentScreen = new URL(await harness.reconnectUrl());
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
"https://slack.com/oauth/v2/authorize",
);
expect(harness.offersReconnect()).toBe(true);
}, 30000);
});
@@ -0,0 +1 @@
Disconnecting a Slack workspace from the Slack integration page, revoking Prowler's access at Slack and reporting when it still has to be removed from the workspace by hand
@@ -0,0 +1 @@
Offer to connect a Slack workspace again when Slack no longer accepts Prowler's credential
@@ -302,7 +302,7 @@ export const JiraIntegrationsManager = ({
icon={<JiraIcon size={32} />}
title={`${integration.attributes.configuration.domain}`}
connectionStatus={{
connected: integration.attributes.connected,
connected: integration.attributes.connected === true,
}}
/>
</CardHeader>
@@ -316,7 +316,7 @@ export const S3IntegrationsManager = ({
"/"
}`}
connectionStatus={{
connected: integration.attributes.connected,
connected: integration.attributes.connected === true,
}}
navigationUrl={`https://console.aws.amazon.com/s3/buckets/${integration.attributes.configuration.bucket_name}`}
/>
@@ -380,7 +380,7 @@ export const SecurityHubIntegrationsManager = ({
},
]}
connectionStatus={{
connected: integration.attributes.connected,
connected: integration.attributes.connected === true,
}}
/>
</CardHeader>
@@ -1,11 +1,13 @@
"use client";
import { format, isValid, parseISO } from "date-fns";
import { Send, TestTube } from "lucide-react";
import { Send, TestTube, Unplug } from "lucide-react";
import { useEffect, useState } from "react";
import { testIntegrationConnection } from "@/actions/integrations/integrations";
import {
disconnectSlackIntegration,
getSlackAuthorizeUrl,
getSlackChannels,
sendSlackTestMessage,
setSlackDefaultChannel,
@@ -23,6 +25,7 @@ import {
CardHeader,
useToast,
} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import type {
IntegrationProps,
SlackChannelOption,
@@ -94,6 +97,22 @@ const channelRefEquals = (
b: SlackChannelRef | null,
) => a?.id === b?.id && a?.name === b?.name;
/**
* Slack answers that prove the stored token itself is unusable, rather than the
* channel unreachable (contract, Cross-cutting). They are what separates "this
* needs a new install" from "this needs a different channel", so they get their
* own state instead of a generic failure.
*/
const CREDENTIAL_REVOKED_SIGNALS = [
"token_revoked",
"invalid_auth",
"account_inactive",
] as const;
const isCredentialRevoked = (reason: string | undefined): boolean =>
reason !== undefined &&
CREDENTIAL_REVOKED_SIGNALS.some((signal) => reason.includes(signal));
interface SlackIntegrationManagerProps {
/** At most one exists per tenant (one workspace). */
integration: IntegrationProps | null;
@@ -112,6 +131,24 @@ export const SlackIntegrationManager = ({
loadError,
}: SlackIntegrationManagerProps) => {
const [isTesting, setIsTesting] = useState(false);
const [isDisconnectOpen, setIsDisconnectOpen] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
// Local state after a mutation: the row is gone the moment the API says so,
// and this page is what the user is looking at. The server component's own
// revalidation refreshes the same thing on the next navigation.
const [disconnected, setDisconnected] = useState(false);
/** Slack's reason when a disconnect removed the row but could not revoke. */
const [revocationFailure, setRevocationFailure] = useState<string | null>(
null,
);
/** Slack's reason when it stopped accepting the stored token. */
const [credentialFailure, setCredentialFailure] = useState<string | null>(
null,
);
// A connected workspace arrives with no consent URL — there is no install left
// to start (design D10) — so one is minted only if the page turns out to need
// it: after a disconnect, or once the credential is known to be dead.
const [mintedInstallUrl, setMintedInstallUrl] = useState<string | null>(null);
const { toast } = useToast();
const integrationId = integration?.id ?? null;
@@ -270,18 +307,30 @@ export const SlackIntegrationManager = ({
}
};
const mintInstallUrl = async () => {
const result = await getSlackAuthorizeUrl();
if ("authorizeUrl" in result) setMintedInstallUrl(result.authorizeUrl);
};
const handleTestConnection = async (id: string) => {
setIsTesting(true);
try {
const result = await testIntegrationConnection(id);
if (result.success) {
setCredentialFailure(null);
toast({
title: "Connection test successful!",
description:
result.message || "Prowler can reach your Slack workspace.",
});
} else {
// A dead token is not a failure the user can fix by checking again, so
// it gets its own state and an offer to approve Prowler again.
if (isCredentialRevoked(result.error)) {
setCredentialFailure(result.error ?? null);
void mintInstallUrl();
}
toast({
variant: "destructive",
title: "Connection test failed",
@@ -299,7 +348,61 @@ export const SlackIntegrationManager = ({
}
};
const handleDisconnect = async (id: string) => {
const workspace =
integration?.attributes.configuration.team_name ?? "your Slack workspace";
setIsDisconnecting(true);
try {
const result = await disconnectSlackIntegration(id);
if ("error" in result) {
toast({
variant: "destructive",
title: "Disconnect failed",
description: result.error,
});
return;
}
const { revoked, error } = result.revocation;
// The integration is gone whatever Slack answered, so the page goes back
// to its unconnected state either way — with a consent URL ready, so
// connecting again is a click rather than a reload.
setDisconnected(true);
setCredentialFailure(null);
// Only an explicit "not revoked" sends the user to finish the job in
// Slack. An unreported outcome is neither a failed revocation nor a
// confirmed one, so it claims neither.
setRevocationFailure(
revoked === false ? (error ?? "Slack gave no reason") : null,
);
void mintInstallUrl();
if (revoked !== false) {
toast({
title: "Slack workspace disconnected",
description:
revoked === true
? `Prowler's access to ${workspace} has been revoked.`
: `${workspace} is no longer connected to Prowler.`,
});
}
} catch (_error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to disconnect Slack. Please try again.",
});
} finally {
setIsDisconnecting(false);
setIsDisconnectOpen(false);
}
};
const workspaceName = integration?.attributes.configuration.team_name;
const installUrl = mintedInstallUrl ?? authorizeUrl;
const checkedAt = integration?.attributes.connection_last_checked_at;
const checkedOn = checkedAt ? parseISO(checkedAt) : null;
@@ -318,6 +421,37 @@ export const SlackIntegrationManager = ({
</Alert>
)}
{/* Portaled by Radix, so its place in this tree costs no layout. */}
<Modal
open={isDisconnectOpen}
onOpenChange={setIsDisconnectOpen}
title="Disconnect Slack workspace"
description={`Prowler will revoke its access at Slack and stop posting to ${workspaceName ?? "this workspace"}. Connecting again means approving Prowler in Slack.`}
>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
disabled={isDisconnecting}
onClick={() => setIsDisconnectOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="lg"
disabled={isDisconnecting}
onClick={() => integration && handleDisconnect(integration.id)}
>
{!isDisconnecting && <Unplug size={20} />}
{isDisconnecting ? "Disconnecting..." : "Disconnect workspace"}
</Button>
</div>
</Modal>
{loadError && (
<Alert variant="error">
<AlertTitle>Could not load your Slack integration</AlertTitle>
@@ -325,6 +459,45 @@ export const SlackIntegrationManager = ({
</Alert>
)}
{revocationFailure && (
<Alert variant="warning">
<AlertTitle>
Slack disconnected remove Prowler&apos;s access in Slack
</AlertTitle>
<AlertDescription>
The integration and the token Prowler had stored are gone from
Prowler, so there is nothing to retry here. Slack did not confirm
the revocation ({revocationFailure}), so the Prowler app may still
be installed in {workspaceName ?? "the workspace"} remove it from
that workspace&apos;s Slack app settings.
</AlertDescription>
</Alert>
)}
{credentialFailure && (
<Alert variant="error">
<AlertTitle>
Prowler&apos;s access to {workspaceName ?? "this workspace"} has
been revoked
</AlertTitle>
<AlertDescription>
Slack no longer accepts the token Prowler stored (
{credentialFailure}), so Prowler cannot post to this workspace.
Approve Prowler in Slack again to restore access.
</AlertDescription>
{installUrl && (
<div className="col-start-2 mt-3">
<Button asChild size="sm">
<a href={installUrl} rel="noopener noreferrer">
<SlackIcon size={16} />
Reconnect to Slack
</a>
</Button>
</div>
)}
</Alert>
)}
{/* Replaces the cards, not the whole page: an early return here would
swallow the rate-limit and load-error notices above. */}
{unavailable ? (
@@ -338,7 +511,7 @@ export const SlackIntegrationManager = ({
soon as it is.
</AlertDescription>
</Alert>
) : integration ? (
) : integration && !disconnected ? (
<Card variant="base">
<CardHeader>
<IntegrationCardHeader
@@ -346,7 +519,12 @@ export const SlackIntegrationManager = ({
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
subtitle="Prowler posts to this workspace only."
connectionStatus={{
connected: integration.attributes.connected,
// A check that came back with a dead token outranks the state
// the page was loaded with.
connected:
credentialFailure === null
? integration.attributes.connected
: false,
}}
/>
</CardHeader>
@@ -367,17 +545,28 @@ export const SlackIntegrationManager = ({
</p>
)}
</div>
{/* The check posts to the destination channel: the API answers
400 when none is recorded yet. */}
<Button
size="sm"
variant="outline"
disabled={isTesting || !defaultChannel}
onClick={() => handleTestConnection(integration.id)}
>
<TestTube size={14} />
{isTesting ? "Testing..." : "Test connection"}
</Button>
<div className="flex items-center gap-2">
{/* The check posts to the destination channel: the API answers
400 when none is recorded yet. */}
<Button
size="sm"
variant="outline"
disabled={isTesting || !defaultChannel}
onClick={() => handleTestConnection(integration.id)}
>
<TestTube size={14} />
{isTesting ? "Testing..." : "Test connection"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={isDisconnecting}
onClick={() => setIsDisconnectOpen(true)}
>
<Unplug size={14} />
Disconnect
</Button>
</div>
</div>
<div className="border-border-neutral-secondary mt-6 flex flex-col gap-4 border-t pt-6">
@@ -476,9 +665,9 @@ export const SlackIntegrationManager = ({
Prowler asks for permission to post messages and to read the
workspace&apos;s channel list.
</p>
{authorizeUrl ? (
{installUrl ? (
<Button asChild>
<a href={authorizeUrl} rel="noopener noreferrer">
<a href={installUrl} rel="noopener noreferrer">
<SlackIcon size={16} />
Add to Slack
</a>