diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx index adb43aa4..9d010330 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx @@ -59,6 +59,7 @@ export default function Page() { data: slackIntegration.data as { teamName?: string; }, + missingScopes: slackIntegration.missingScopes, } : null } diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx index d276f461..d7065ab3 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx @@ -17,6 +17,7 @@ import { FormCardTitle, FormCardUpgrade, } from "@/components/forms/form-card"; +import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; const SERVER_URL = @@ -30,6 +31,7 @@ interface SlackIntegrationCardProps { id: number; externalId: string; data: { teamName?: string }; + missingScopes: string[]; } | null; } @@ -41,6 +43,9 @@ export function SlackIntegrationCard({ const trpc = useTRPC(); const queryClient = useQueryClient(); const isConnected = !!integration; + const incidents = useFeature("incident-management"); + const needsReconnect = + incidents && (integration?.missingScopes.length ?? 0) > 0; const deleteIntegration = useMutation( trpc.integrationRouter.deleteIntegration.mutationOptions({ @@ -97,6 +102,21 @@ export function SlackIntegrationCard({ {integration.data?.teamName ?? "Slack workspace"} . Only members with a linked Slack account can use it.

+ {needsReconnect ? ( +
+ + Reconnect Slack to enable incident channels. + + +
+ ) : null} {linkedAccountsQuery.isPending ? (

Loading linked accounts… diff --git a/apps/server/slack-manifest.json b/apps/server/slack-manifest.json index c12e8d22..895a2505 100644 --- a/apps/server/slack-manifest.json +++ b/apps/server/slack-manifest.json @@ -40,8 +40,8 @@ { "command": "/openstatus", "url": "https://api.openstatus.dev/slack/commands", - "description": "Manage this channel's status page subscriptions", - "usage_hint": "subscribe | unsubscribe | subscriptions | help", + "description": "Incidents and status page subscriptions from Slack", + "usage_hint": "incident declare | incident note | incident list | subscribe | help", "should_escape": false } ] @@ -56,12 +56,17 @@ "assistant:write", "channels:history", "channels:join", + "channels:manage", + "channels:write.invites", "chat:write", "commands", "groups:history", "groups:read", "groups:write", "im:history", + "pins:write", + "reactions:read", + "reactions:write", "users:read", "users:read.email" ] @@ -77,9 +82,14 @@ "app_context_changed", "app_home_opened", "app_mention", + "app_uninstalled", + "channel_archive", + "channel_deleted", "message.channels", "message.groups", - "message.im" + "message.im", + "reaction_added", + "tokens_revoked" ] }, "interactivity": { diff --git a/apps/server/src/routes/slack/handler.test.ts b/apps/server/src/routes/slack/handler.test.ts index d325579f..17c4bcee 100644 --- a/apps/server/src/routes/slack/handler.test.ts +++ b/apps/server/src/routes/slack/handler.test.ts @@ -1932,3 +1932,36 @@ describe("hardening", () => { } }); }); + +describe("reconnect banner", () => { + const app = createTestApp(); + + beforeEach(resetSlackTestState); + + test("the home tab asks an old install to reconnect", async () => { + slackTestState.resolveWorkspace = () => + Promise.resolve({ + workspace: { + id: 1, + name: "Test Workspace", + slug: "test", + plan: "team", + limits: { "slack-agent": true }, + }, + botToken: "xoxb-test", + botUserId: "UBOT", + scopes: "chat:write,users:read,users:read.email", + }); + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_reconnect_${Date.now()}`, + event: { type: "app_home_opened", tab: "home", user: "U1" }, + }); + const publish = await waitForCall("views.publish"); + const view = publish?.args.view as { + blocks: { type: string; text?: { text: string } }[]; + }; + expect(view.blocks[0].text?.text).toContain("Reconnect openstatus"); + }); +}); diff --git a/apps/server/src/routes/slack/handler.ts b/apps/server/src/routes/slack/handler.ts index ac8fc4d2..f2cafd77 100644 --- a/apps/server/src/routes/slack/handler.ts +++ b/apps/server/src/routes/slack/handler.ts @@ -1,5 +1,9 @@ import { getLogger } from "@logtape/logtape"; -import { uninstallSlackTeam } from "@openstatus/services/integration"; +import { isFeatureEnabled } from "@openstatus/services"; +import { + missingSlackScopes, + uninstallSlackTeam, +} from "@openstatus/services/integration"; import { WebClient } from "@slack/web-api"; import type { Context } from "hono"; import { z } from "zod"; @@ -367,7 +371,14 @@ async function processEvent(body: SlackEvent, config: SlackConfig) { slackUserId: userId, }); if (actor) { - await publishHomeView(slack, userId); + const needsReconnect = + isFeatureEnabled(resolved.workspace, "incident-management") && + missingSlackScopes(resolved.scopes).length > 0; + await publishHomeView(slack, userId, { + reconnectUrl: needsReconnect + ? `${config.dashboardUrl}/settings/integrations` + : undefined, + }); } else { const url = await linkAccountUrl(config, { workspaceId: resolved.workspace.id, diff --git a/apps/server/src/routes/slack/home.ts b/apps/server/src/routes/slack/home.ts index 824df333..3fc7008e 100644 --- a/apps/server/src/routes/slack/home.ts +++ b/apps/server/src/routes/slack/home.ts @@ -5,8 +5,23 @@ import { buildLinkAccountBlocks } from "./blocks"; export const DOCS_URL = "https://www.openstatus.dev/docs"; -export function buildHomeBlocks(): KnownBlock[] { +export function buildHomeBlocks( + opts: { reconnectUrl?: string } = {}, +): KnownBlock[] { + const reconnect: KnownBlock[] = opts.reconnectUrl + ? [ + { + type: "section", + text: { + type: "mrkdwn", + text: `:warning: *Reconnect openstatus to enable incident channels.* This install is missing permissions openstatus needs to open a channel per incident. <${opts.reconnectUrl}|Reconnect from the dashboard>.`, + }, + }, + { type: "divider" }, + ] + : []; return [ + ...reconnect, { type: "header", text: { type: "plain_text", text: "openstatus", emoji: true }, @@ -48,10 +63,11 @@ export function buildHomeBlocks(): KnownBlock[] { export async function publishHomeView( slack: WebClient, userId: string, + opts: { reconnectUrl?: string } = {}, ): Promise { await slack.views.publish({ user_id: userId, - view: { type: "home", blocks: buildHomeBlocks() }, + view: { type: "home", blocks: buildHomeBlocks(opts) }, }); } diff --git a/apps/server/src/routes/slack/oauth.test.ts b/apps/server/src/routes/slack/oauth.test.ts index 27e8a331..b62703df 100644 --- a/apps/server/src/routes/slack/oauth.test.ts +++ b/apps/server/src/routes/slack/oauth.test.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; +import { SLACK_BOT_SCOPES } from "@openstatus/services/integration"; import { expect } from "@std/expect"; import { describe, test } from "@std/testing/bdd"; import { Hono } from "hono"; @@ -9,6 +10,7 @@ import { withSlackConfig, } from "@/libs/test/slack-config"; +import manifest from "../../../slack-manifest.json" with { type: "json" }; import type { SlackEnv } from "./config"; import { handleSlackInstall, handleSlackOAuthCallback } from "./oauth"; @@ -40,6 +42,26 @@ function makeInstallToken(workspaceId: number): string { return signToken({ workspaceId, userId: 1, ts: Date.now() }); } +describe("slack manifest", () => { + test("requests exactly the bot scopes the code asks for", () => { + expect([...manifest.oauth_config.scopes.bot].sort()).toEqual( + [...SLACK_BOT_SCOPES].sort(), + ); + }); + + test("subscribes to the events the handler acts on", () => { + for (const event of [ + "app_uninstalled", + "tokens_revoked", + "reaction_added", + "channel_deleted", + "channel_archive", + ]) { + expect(manifest.settings.event_subscriptions.bot_events).toContain(event); + } + }); +}); + describe("handleSlackInstall", () => { const app = createTestApp(); diff --git a/apps/server/src/routes/slack/oauth.ts b/apps/server/src/routes/slack/oauth.ts index a877d0e6..d413b832 100644 --- a/apps/server/src/routes/slack/oauth.ts +++ b/apps/server/src/routes/slack/oauth.ts @@ -6,7 +6,10 @@ import { selectWorkspaceSchema, workspace as workspaceTable, } from "@openstatus/db/src/schema"; -import { installSlackAgent } from "@openstatus/services/integration"; +import { + installSlackAgent, + SLACK_BOT_SCOPES, +} from "@openstatus/services/integration"; import type { Context } from "hono"; import { z } from "zod"; @@ -17,21 +20,7 @@ const logger = getLogger(["api-server", "slack", "oauth"]); const SLACK_OAUTH_URL = "https://slack.com/oauth/v2/authorize"; const SLACK_TOKEN_URL = "https://slack.com/api/oauth.v2.access"; -const BOT_SCOPES = [ - "app_mentions:read", - "assistant:write", - "channels:history", - "channels:join", - "chat:write", - "commands", - "groups:history", - "groups:read", - "groups:write", - "im:history", - // users.info + profile.email: attributes Slack actions to workspace members. - "users:read", - "users:read.email", -].join(","); +const BOT_SCOPES = SLACK_BOT_SCOPES.join(","); const oauthStateSchema = z.object({ workspaceId: z.number().int(), diff --git a/apps/server/src/routes/slack/workspace-resolver.ts b/apps/server/src/routes/slack/workspace-resolver.ts index ec01c8b9..a9006e02 100644 --- a/apps/server/src/routes/slack/workspace-resolver.ts +++ b/apps/server/src/routes/slack/workspace-resolver.ts @@ -5,11 +5,16 @@ import { workspace, } from "@openstatus/db/src/schema"; import type { Workspace } from "@openstatus/db/src/schema/workspaces/validation"; +import { z } from "zod"; + +const integrationDataSchema = z.object({ scopes: z.string().optional() }); export interface SlackWorkspace { workspace: Workspace; botToken: string; botUserId: string; + /** Comma list Slack granted at install; missing scopes mean "reconnect". */ + scopes?: string; } interface IntegrationCredential { @@ -24,6 +29,7 @@ export async function resolveWorkspace( .select({ workspaceId: integration.workspaceId, credential: integration.credential, + data: integration.data, }) .from(integration) .where( @@ -58,5 +64,6 @@ export async function resolveWorkspace( workspace: parsed.data, botToken: credential.botToken, botUserId: credential.botUserId ?? "", + scopes: integrationDataSchema.safeParse(row.data).data?.scopes ?? "", }; } diff --git a/packages/api/src/router/integration.ts b/packages/api/src/router/integration.ts index 745e5c39..11475bd1 100644 --- a/packages/api/src/router/integration.ts +++ b/packages/api/src/router/integration.ts @@ -4,6 +4,7 @@ import crypto from "crypto"; import { deleteIntegration, listIntegrations, + missingSlackScopes, } from "@openstatus/services/integration"; import { z } from "zod"; @@ -31,7 +32,14 @@ function signInstallToken(args: { export const integrationRouter = createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) => { try { - return await listIntegrations({ ctx: toServiceCtx(ctx) }); + const integrations = await listIntegrations({ ctx: toServiceCtx(ctx) }); + return integrations.map((i) => ({ + ...i, + missingScopes: + i.name === "slack-agent" && typeof i.data.scopes === "string" + ? missingSlackScopes(i.data.scopes) + : [], + })); } catch (err) { toTRPCError(err); } diff --git a/packages/services/src/integration/__tests__/slack-scopes.test.ts b/packages/services/src/integration/__tests__/slack-scopes.test.ts new file mode 100644 index 00000000..1ac6279d --- /dev/null +++ b/packages/services/src/integration/__tests__/slack-scopes.test.ts @@ -0,0 +1,26 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { SLACK_BOT_SCOPES, missingSlackScopes } from "../slack-scopes"; + +describe("missingSlackScopes", () => { + test("nothing missing when every scope was granted", () => { + expect(missingSlackScopes(SLACK_BOT_SCOPES.join(","))).toEqual([]); + }); + + test("an install from before the incident scopes needs a reconnect", () => { + const old = + "app_mentions:read,assistant:write,channels:history,channels:join,chat:write,commands,groups:history,groups:read,groups:write,im:history,users:read,users:read.email"; + expect(missingSlackScopes(old)).toEqual([ + "channels:manage", + "channels:write.invites", + "pins:write", + "reactions:read", + "reactions:write", + ]); + }); + + test("no scopes recorded means everything is missing", () => { + expect(missingSlackScopes(undefined)).toHaveLength(SLACK_BOT_SCOPES.length); + }); +}); diff --git a/packages/services/src/integration/index.ts b/packages/services/src/integration/index.ts index 893bf790..b1593aef 100644 --- a/packages/services/src/integration/index.ts +++ b/packages/services/src/integration/index.ts @@ -1,5 +1,6 @@ export { deleteIntegration } from "./delete"; export { installSlackAgent } from "./install-slack-agent"; +export { missingSlackScopes, SLACK_BOT_SCOPES } from "./slack-scopes"; export { uninstallSlackAgent, uninstallSlackTeam, diff --git a/packages/services/src/integration/slack-scopes.ts b/packages/services/src/integration/slack-scopes.ts new file mode 100644 index 00000000..c5ae1657 --- /dev/null +++ b/packages/services/src/integration/slack-scopes.ts @@ -0,0 +1,31 @@ +/** Bot scopes the Slack app requests; the manifest lists the same set. */ +export const SLACK_BOT_SCOPES = [ + "app_mentions:read", + "assistant:write", + "channels:history", + "channels:join", + "channels:manage", + "channels:write.invites", + "chat:write", + "commands", + "groups:history", + "groups:read", + "groups:write", + "im:history", + "pins:write", + "reactions:read", + "reactions:write", + "users:read", + "users:read.email", +] as const; + +/** Scopes the install lacks, from the comma list Slack returned at OAuth. */ +export function missingSlackScopes(granted: string | undefined): string[] { + const have = new Set( + (granted ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + return SLACK_BOT_SCOPES.filter((scope) => !have.has(scope)); +}