From 376093b50f6e6d2dda5af3928b01bc40c43d9d7e Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:28:48 -0400 Subject: [PATCH 1/6] [Feat] Create the Slack app from a pasted app configuration token Slack setup previously opened a prefilled manifest in Slack's UI and made the user copy three secrets back by hand. The setup and settings surfaces now take a Slack app configuration token instead: an admin-gated tRPC command (plus a setup-token-gated bootstrap variant) builds the existing manifest, POSTs it to apps.manifest.create, persists R_SLACK_CLIENT_ID, R_SLACK_CLIENT_SECRET, and R_SLACK_SIGNING_SECRET in one transaction, and the UI advances straight to the Connect to Slack install step. The config token is used once and never stored. Enter values manually and the prefilled-manifest link remain as fallback paths. The mock Slack harness gains an apps.manifest.create route with a separate config-token allowlist so the whole flow is testable without a real workspace. --- .agents/skills/mock-slack-testing/SKILL.md | 24 +- .changeset/slack-config-token-app-creation.md | 11 + apps/docs/providers/communications/slack.mdx | 22 +- .../setup/ProviderSetupExperience.tsx | 98 ++++++-- .../setup/StepAuthEnvVars.client.test.tsx | 119 +++++++++- .../(onboarding)/setup/StepAuthEnvVars.tsx | 39 ++++ .../settings/CommsProviderSection.test.tsx | 11 +- .../settings/CommsProviderSection.tsx | 22 ++ apps/web/src/trpc/commands/setup-new/index.ts | 14 ++ .../slack/create-app-from-manifest.test.ts | 219 ++++++++++++++++++ .../slack/create-app-from-manifest.ts | 190 +++++++++++++++ apps/web/src/trpc/commands/slack/index.ts | 2 + apps/web/src/trpc/routers/_app.ts | 19 ++ .../src/__tests__/mock-slack-server.test.ts | 139 +++++++++++ packages/slack/src/index.ts | 1 + packages/slack/src/mock-slack-server.ts | 111 ++++++++- 16 files changed, 1010 insertions(+), 31 deletions(-) create mode 100644 .changeset/slack-config-token-app-creation.md create mode 100644 apps/web/src/trpc/commands/slack/create-app-from-manifest.test.ts create mode 100644 apps/web/src/trpc/commands/slack/create-app-from-manifest.ts diff --git a/.agents/skills/mock-slack-testing/SKILL.md b/.agents/skills/mock-slack-testing/SKILL.md index afb316c7f..0abaebfd5 100644 --- a/.agents/skills/mock-slack-testing/SKILL.md +++ b/.agents/skills/mock-slack-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: mock-slack-testing -description: Run Roomote Slack integration flows through the existing mock Slack harness instead of a real Slack workspace. Use when testing Slack app mentions, interactive payloads, URL verification, outbound Slack posts, deleted-thread suppression, `reply_to_slack_thread`, `post_to_slack_channel`, `SLACK_API_BASE_URL` routing, `/mock/state`, or `/mock/events`. +description: Run Roomote Slack integration flows through the existing mock Slack harness instead of a real Slack workspace. Use when testing Slack app mentions, interactive payloads, URL verification, outbound Slack posts, deleted-thread suppression, `reply_to_slack_thread`, `post_to_slack_channel`, Slack app creation via `apps.manifest.create`, `SLACK_API_BASE_URL` routing, `/mock/state`, or `/mock/events`. --- # Mock Slack Testing @@ -131,6 +131,28 @@ curl -s http://127.0.0.1:3012/mock/state | jq '.messages' curl -s http://127.0.0.1:3012/mock/state | jq '.messages[] | select(.thread_ts != null)' ``` +## Testing Slack app creation (`apps.manifest.create`) + +The harness also mocks Slack's `apps.manifest.create` endpoint, which backs +the setup flow where an admin pastes an app configuration token and Roomote +creates the Slack app. Point the web server at the harness +(`SLACK_API_BASE_URL=http://127.0.0.1:3012/api/`), then drive the setup UI or +the `slack.createAppFromManifest` tRPC mutation. + +Optional state knobs: + +- `acceptedConfigTokens` — allowlist for config tokens; unknown tokens get + `{ "ok": false, "error": "invalid_auth" }`. Config tokens are a separate + token space from `acceptedBotTokens`. +- `manifestCredentials` — `{ appId, clientId, clientSecret, signingSecret, + verificationToken }` overrides for the returned credentials. + +Created apps are recorded in mock state: + +```bash +curl -s http://127.0.0.1:3012/mock/state | jq '.createdManifests' +``` + ## Scenario Selection See `references/scenarios.md` for the full catalog. Common picks: diff --git a/.changeset/slack-config-token-app-creation.md b/.changeset/slack-config-token-app-creation.md new file mode 100644 index 000000000..23531f56c --- /dev/null +++ b/.changeset/slack-config-token-app-creation.md @@ -0,0 +1,11 @@ +--- +'@roomote/web': minor +--- + +Slack setup can now create the Slack app for you: paste an app configuration +token and Roomote creates the app through Slack's `apps.manifest.create` API, +saves the client ID, client secret, and signing secret automatically, and +advances straight to the Connect to Slack install step. Entering values +manually and the prefilled-manifest path remain available as fallbacks, and +the mock Slack harness now covers `apps.manifest.create` so the flow is +testable without a real workspace. diff --git a/apps/docs/providers/communications/slack.mdx b/apps/docs/providers/communications/slack.mdx index 4bbf5b798..cc092b55e 100644 --- a/apps/docs/providers/communications/slack.mdx +++ b/apps/docs/providers/communications/slack.mdx @@ -18,11 +18,25 @@ or configured for your self-hosted deployment. ## Fast path -In `/setup`, choose **Create Slack app from manifest**. Roomote opens Slack's -create-app flow with redirect URLs, webhook URLs, interactivity, scopes, and -events prefilled for the current public URL. +In `/setup`, paste a Slack **app configuration token** and Roomote creates the +Slack app for you through Slack's `apps.manifest.create` API: -After Slack creates the app: +1. open [api.slack.com/apps](https://api.slack.com/apps) and click + **Generate Token** under **Your App Configuration Tokens**, picking the + workspace where Roomote should live +2. paste the access token into Roomote setup and click **Create Slack app** + +Roomote creates the app with redirect URLs, webhook URLs, interactivity, +scopes, and events preconfigured for the current public URL, then saves the +**Client ID**, **Client Secret**, and **Signing Secret** automatically. The +configuration token is used once to create the app and never stored. Finish +the Slack install step so Roomote can receive events and post replies. + +### Alternative: prefilled manifest + +If you prefer not to use a configuration token, choose the **prefilled +manifest** link instead. Roomote opens Slack's create-app flow with the same +manifest prefilled. After Slack creates the app: 1. copy **Client ID**, **Client Secret**, and **Signing Secret** from **Basic Information > App Credentials** diff --git a/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx b/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx index af79f78f0..474287e6f 100644 --- a/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx +++ b/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import Link from 'next/link'; import { MICROSOFT_SINGLE_APP_TEAMS_BOT_FIELD_SOURCES, @@ -19,6 +20,7 @@ import { Button, Pencil, Sparkles, + Spinner, } from '@/components/system'; import { StepTitle } from './StepTitle'; @@ -293,9 +295,11 @@ type ProviderSetupExperienceProps = { clearedSavedValues: Record; teamsAppPackageHref: string | null; showManualSlackValues: boolean; + createSlackAppPending?: boolean; showMicrosoftAdvancedConfig?: boolean; surface?: 'setup' | 'settings'; envVarsInfoNote?: React.ReactNode; + onCreateSlackApp?: (configToken: string) => void; onShowManualSlackValues: () => void; onToggleMicrosoftAdvancedConfig?: () => void; onValueChange: (envVarName: string, value: string) => void; @@ -329,6 +333,7 @@ function ProviderSetupTitle({ } function SlackSetupExperience(props: ProviderSetupExperienceProps) { + const [configToken, setConfigToken] = useState(''); const slackManifestPrefillUrl = buildSlackManifestPrefillUrl({ publicOrigin: props.publicOrigin, }); @@ -338,6 +343,20 @@ function SlackSetupExperience(props: ProviderSetupExperienceProps) { !props.provider.runtimeSatisfied && !props.provider.savedSatisfied ) { + const createSlackAppDisabled = + !props.onCreateSlackApp || + props.disabled || + props.createSlackAppPending === true; + const submitConfigToken = () => { + const normalizedConfigToken = configToken.trim(); + + if (createSlackAppDisabled || !normalizedConfigToken) { + return; + } + + props.onCreateSlackApp?.(normalizedConfigToken); + }; + return (
@@ -345,12 +364,49 @@ function SlackSetupExperience(props: ProviderSetupExperienceProps) {

Because Roomote is self-hosted, we can't offer you an - out-of-the-box Slack app – you need to create your own. -

-

- Roomote can create it for you automatically, and then you can enter - the config values manually. + out-of-the-box Slack app – you need your own. Paste an app + configuration token and Roomote creates the app for you.

+
    +
  1. + Open{' '} + + api.slack.com/apps + + {' '} + and click + Generate Token + {' '} + under "Your App Configuration Tokens", picking the + workspace where Roomote should live. +
  2. +
  3. Copy the access token and paste it here.
  4. +
+
+ +
+
App configuration token
+ setConfigToken(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + submitConfigToken(); + } + }} + placeholder="xoxe.xoxp-..." + disabled={props.disabled || props.createSlackAppPending} + data-1p-ignore + />
@@ -360,16 +416,13 @@ function SlackSetupExperience(props: ProviderSetupExperienceProps) { Back ) : null} -
+ +

+ Prefer not to use a configuration token? Create the app in + Slack's UI from a{' '} + + prefilled manifest + {' '} + and enter its credentials manually. +

); } diff --git a/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.client.test.tsx index 3be16ff2b..bc40c4662 100644 --- a/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.client.test.tsx @@ -48,6 +48,11 @@ vi.mock('@/lib/auth-client', () => ({ vi.mock('@/trpc/client', () => ({ useTRPC: () => ({ + slack: { + createAppFromManifest: { + mutationOptions: (options: Record) => options, + }, + }, setupNew: { saveAuthConfig: { mutationOptions: (options: Record) => options, @@ -60,6 +65,9 @@ vi.mock('@/trpc/client', () => ({ saveAuthConfig: { mutationOptions: (options: Record) => options, }, + createSlackAppFromManifest: { + mutationOptions: (options: Record) => options, + }, status: { queryKey: () => ['setupBootstrap.status'], }, @@ -382,7 +390,7 @@ describe('StepAuthEnvVars', () => { }); }); - it('defaults Slack to the manifest CTA and hides manual fields initially', () => { + it('defaults Slack to the config-token flow and hides manual fields initially', () => { render( { />, ); - const link = screen.getByRole('link', { - name: /create slack app/i, - }); - const url = new URL(link.getAttribute('href') ?? ''); - expect( screen.getByRole('heading', { name: /create slack app/i }), ).toBeInTheDocument(); + expect( + screen.getByLabelText('App configuration token'), + ).toBeInTheDocument(); + // The create action stays disabled until a token is entered. + expect( + screen.getByRole('button', { name: /create slack app/i }), + ).toBeDisabled(); + + // The prefilled-manifest link remains as a secondary escape hatch. + const link = screen.getByRole('link', { name: /prefilled manifest/i }); + const url = new URL(link.getAttribute('href') ?? ''); expect(url.origin + url.pathname).toBe('https://api.slack.com/apps'); expect(url.searchParams.get('new_app')).toBe('1'); expect( @@ -414,7 +428,7 @@ describe('StepAuthEnvVars', () => { ).not.toBeInTheDocument(); }); - it('shows the manual value form after opening the Slack app creation flow', () => { + it('shows the manual value form after opening the prefilled-manifest flow', () => { render( { />, ); - fireEvent.click(screen.getByRole('link', { name: /create slack app/i })); + fireEvent.click(screen.getByRole('link', { name: /prefilled manifest/i })); expect(screen.getByPlaceholderText('Slack Client ID')).toBeInTheDocument(); expect( screen.getByPlaceholderText('Slack Client Secret'), ).toBeInTheDocument(); expect( - screen.queryByRole('link', { name: /create slack app/i }), + screen.queryByRole('button', { name: /create slack app/i }), ).not.toBeInTheDocument(); }); + it('creates the Slack app from a config token and continues setup', async () => { + const invalidateQueries = vi.fn(); + mockUseQueryClient.mockReturnValue({ + invalidateQueries, + } as unknown as ReturnType); + + const recordedOptions: Array<{ + onSuccess?: (result: unknown) => Promise | void; + }> = []; + const mutate = vi.fn(); + const mutateAsync = vi.fn(async (input) => input); + mockUseMutation.mockImplementation((options) => { + recordedOptions.push( + options as { onSuccess?: (result: unknown) => Promise | void }, + ); + + return { + mutate, + mutateAsync, + isPending: false, + } as unknown as ReturnType; + }); + + render( + , + ); + + fireEvent.change(screen.getByLabelText('App configuration token'), { + target: { value: ' xoxe.xoxp-config-token ' }, + }); + fireEvent.click(screen.getByRole('button', { name: /create slack app/i })); + + expect(mutate).toHaveBeenCalledWith({ + configToken: 'xoxe.xoxp-config-token', + }); + + // The first useMutation registered by the component is createSlackApp. + await recordedOptions[0]?.onSuccess?.({ success: true, appId: 'A1' }); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['setupNew.status'], + }); + // With the credentials persisted server-side, the step saves the provider + // choice with no manual values and advances. + expect(mutateAsync).toHaveBeenCalledWith({ provider: 'slack', values: {} }); + }); + + it('surfaces Slack app creation failures without advancing', async () => { + const { toast } = await import('sonner'); + const recordedOptions: Array<{ + onSuccess?: (result: unknown) => Promise | void; + }> = []; + const mutate = vi.fn(); + const mutateAsync = vi.fn(async (input) => input); + mockUseMutation.mockImplementation((options) => { + recordedOptions.push( + options as { onSuccess?: (result: unknown) => Promise | void }, + ); + + return { + mutate, + mutateAsync, + isPending: false, + } as unknown as ReturnType; + }); + + render( + , + ); + + await recordedOptions[0]?.onSuccess?.({ + success: false, + error: 'Slack rejected the app configuration token.', + }); + + expect(toast.error).toHaveBeenCalledWith( + 'Slack rejected the app configuration token.', + ); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + it('reveals existing Slack inputs when entering values manually', () => { render( ({}); const [showMicrosoftAdvancedConfig, setShowMicrosoftAdvancedConfig] = useState(false); + const createSlackApp = useMutation( + (bootstrapMode + ? trpc.setupBootstrap.createSlackAppFromManifest + : trpc.slack.createAppFromManifest + ).mutationOptions({ + onSuccess: async (result) => { + if (!result.success) { + toast.error(result.error); + return; + } + + await queryClient.invalidateQueries({ + queryKey: bootstrapMode + ? trpc.setupBootstrap.status.queryKey() + : trpc.setupNew.status.queryKey(), + }); + + try { + // The credentials are already persisted, so this records the + // provider choice and advances (or, in bootstrap mode, signs in). + await handleContinue(); + } catch { + // saveAuthConfig's own onError already surfaced the failure. + } + }, + onError: (error) => { + toast.error(error.message); + }, + }), + ); const saveAuthConfig = useMutation( (bootstrapMode ? trpc.setupBootstrap.saveAuthConfig @@ -301,7 +331,16 @@ export function StepAuthEnvVars({ clearedSavedValues={clearedSavedValues} teamsAppPackageHref={teamsAppPackageHref} showManualSlackValues={showManualSlackValues} + createSlackAppPending={ + createSlackApp.isPending || saveAuthConfig.isPending + } showMicrosoftAdvancedConfig={showMicrosoftAdvancedConfig} + onCreateSlackApp={(configToken) => + createSlackApp.mutate({ + configToken, + ...(bootstrapMode && setupToken ? { setupToken } : {}), + }) + } onShowManualSlackValues={() => setShowManualSlackValues(true)} onToggleMicrosoftAdvancedConfig={() => setShowMicrosoftAdvancedConfig((current) => !current) diff --git a/apps/web/src/components/settings/CommsProviderSection.test.tsx b/apps/web/src/components/settings/CommsProviderSection.test.tsx index da1ea04ed..935af41c6 100644 --- a/apps/web/src/components/settings/CommsProviderSection.test.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.test.tsx @@ -184,6 +184,9 @@ vi.mock('@/trpc/client', () => ({ useTRPC: () => ({ slack: { installation: { queryKey: () => ['slack', 'installation'] }, + createAppFromManifest: { + mutationOptions: (options: unknown) => options, + }, }, comms: { status: { queryKey: () => ['comms', 'status'] }, @@ -563,7 +566,13 @@ describe('CommsProviderSection', () => { screen.queryByRole('heading', { name: 'Create Slack app' }), ).not.toBeInTheDocument(); expect( - screen.getByRole('link', { name: 'Create Slack app' }), + screen.getByRole('button', { name: 'Create Slack app' }), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('App configuration token'), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /prefilled manifest/ }), ).toBeInTheDocument(); fireEvent.click( diff --git a/apps/web/src/components/settings/CommsProviderSection.tsx b/apps/web/src/components/settings/CommsProviderSection.tsx index 05267f81b..7045c9bef 100644 --- a/apps/web/src/components/settings/CommsProviderSection.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.tsx @@ -505,6 +505,24 @@ export function CommsProviderSection({ onError: (error) => toast.error(error.message), }), ); + const createSlackApp = useMutation( + trpc.slack.createAppFromManifest.mutationOptions({ + onSuccess: async (result) => { + if (!result.success) { + toast.error(result.error); + return; + } + + toast.success( + 'Slack app created and credentials saved. Connect it to your workspace to finish.', + ); + await queryClient.invalidateQueries({ + queryKey: trpc.comms.status.queryKey(), + }); + }, + onError: (error) => toast.error(error.message), + }), + ); const isMicrosoftProvider = provider.id === 'microsoft'; const teamsIntegrationStatus = useTeamsIntegrationStatus({ enabled: isMicrosoftProvider, @@ -713,6 +731,7 @@ export function CommsProviderSection({ clearedSavedValues={clearedSavedValues} teamsAppPackageHref={teamsAppPackageHref} showManualSlackValues={showManualSlackValues} + createSlackAppPending={createSlackApp.isPending} surface="settings" envVarsInfoNote={ !provider.runtimeSatisfied && provider.id === 'telegram' @@ -721,6 +740,9 @@ export function CommsProviderSection({ ? 'Roomote validates the token, derives the bot identity, and registers /new, /link, and /help when you save.' : undefined } + onCreateSlackApp={(configToken) => + createSlackApp.mutate({ configToken }) + } onShowManualSlackValues={() => setShowManualSlackValues(true)} onValueChange={(envVarName, value) => setValues((current) => ({ ...current, [envVarName]: value })) diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index 001d8a7bc..d54762a25 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -138,6 +138,7 @@ import { prepareComputeProvisioningStart, runComputeProvisioning, } from '../compute/compute-provisioning'; +import { createSlackAppFromManifest } from '../slack/create-app-from-manifest'; import { assertValidSourceControlConfigInput, saveSourceControlConfigValues, @@ -2260,6 +2261,19 @@ export async function saveSetupBootstrapAuthConfigCommand(input: { }); } +export async function createSetupBootstrapSlackAppFromManifestCommand(input: { + configToken: string; + setupToken?: string; +}) { + assertSetupTokenValid(await resolveSetupTokenInput(input.setupToken)); + await assertSetupBootstrapOpen(); + + return createSlackAppFromManifest({ + configToken: input.configToken, + actorUserId: null, + }); +} + export async function saveSetupNewSelectionCommand( auth: UserAuthSuccess, input: { diff --git a/apps/web/src/trpc/commands/slack/create-app-from-manifest.test.ts b/apps/web/src/trpc/commands/slack/create-app-from-manifest.test.ts new file mode 100644 index 000000000..f33ffde4d --- /dev/null +++ b/apps/web/src/trpc/commands/slack/create-app-from-manifest.test.ts @@ -0,0 +1,219 @@ +import type { FeatureFlag } from '@roomote/feature-flags'; + +import type { UserAuthSuccess } from '@/types'; + +const { + mockDbTransaction, + mockFetch, + mockUpsertDeploymentEnvironmentVariables, +} = vi.hoisted(() => ({ + mockDbTransaction: vi.fn(), + mockFetch: vi.fn(), + mockUpsertDeploymentEnvironmentVariables: vi.fn(), +})); + +vi.mock('@roomote/slack', () => ({ + buildSlackApiUrl: (path: string) => `https://slack.example.test/api/${path}`, +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + transaction: mockDbTransaction, + }, +})); + +vi.mock('@/lib/server', () => ({ + Env: { + R_APP_URL: 'https://roomote.example.com/', + }, +})); + +vi.mock('../environment-variables', () => ({ + upsertDeploymentEnvironmentVariables: + mockUpsertDeploymentEnvironmentVariables, +})); + +vi.stubGlobal('fetch', mockFetch); + +import { createSlackAppFromManifestCommand } from './create-app-from-manifest'; + +function buildMockAuth( + overrides: Partial = {}, +): UserAuthSuccess { + return { + success: true, + userType: 'user', + userId: 'slack-manifest-test-user', + isAdmin: true, + name: 'Slack Manifest Tester', + primaryEmail: 'slack@example.com', + featureFlags: {} as Record, + resource: { + username: 'slack-manifest-tester', + fullName: 'Slack Manifest Tester', + firstName: 'Slack', + lastName: 'Tester', + primaryEmailAddress: { id: '1', emailAddress: 'slack@example.com' }, + emailAddresses: [{ id: '1', emailAddress: 'slack@example.com' }], + imageUrl: 'https://example.com/avatar.png', + createdAt: new Date(), + }, + ...overrides, + } as UserAuthSuccess; +} + +function mockSlackResponse(body: unknown, status = 200) { + mockFetch.mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }); +} + +describe('createSlackAppFromManifestCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDbTransaction.mockImplementation(async (callback) => + callback({ kind: 'tx' }), + ); + }); + + it('rejects non-admin users without calling Slack', async () => { + const result = await createSlackAppFromManifestCommand( + buildMockAuth({ isAdmin: false }), + { configToken: 'xoxe.xoxp-token' }, + ); + + expect(result).toEqual({ success: false, error: 'Unauthorized' }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('creates the app and persists the three Slack env vars in one transaction', async () => { + mockSlackResponse({ + ok: true, + app_id: 'A0NEWAPP', + credentials: { + client_id: 'new-client-id', + client_secret: 'new-client-secret', + verification_token: 'new-verification-token', + signing_secret: 'new-signing-secret', + }, + oauth_authorize_url: 'https://slack.com/oauth/v2/authorize?client_id=x', + }); + + const result = await createSlackAppFromManifestCommand(buildMockAuth(), { + configToken: ' xoxe.xoxp-token ', + }); + + expect(result).toEqual({ success: true, appId: 'A0NEWAPP' }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://slack.example.test/api/apps.manifest.create'); + expect(init.method).toBe('POST'); + expect((init.headers as Record).Authorization).toBe( + 'Bearer xoxe.xoxp-token', + ); + + const body = JSON.parse(String(init.body)) as { manifest: string }; + const manifest = JSON.parse(body.manifest); + expect(manifest).toMatchObject({ + display_information: { name: 'Roomote' }, + settings: { + event_subscriptions: { + request_url: 'https://roomote.example.com/api/webhooks/slack', + }, + }, + }); + + expect(mockDbTransaction).toHaveBeenCalledTimes(1); + expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( + { kind: 'tx' }, + { + userId: 'slack-manifest-test-user', + values: [ + { name: 'R_SLACK_CLIENT_ID', value: 'new-client-id' }, + { name: 'R_SLACK_CLIENT_SECRET', value: 'new-client-secret' }, + { name: 'R_SLACK_SIGNING_SECRET', value: 'new-signing-secret' }, + ], + }, + ); + }); + + it('maps configuration-token failures to an actionable error', async () => { + mockSlackResponse({ ok: false, error: 'invalid_auth' }); + + const result = await createSlackAppFromManifestCommand(buildMockAuth(), { + configToken: 'xoxe.xoxp-expired', + }); + + expect(result).toEqual({ + success: false, + error: + 'Slack rejected the app configuration token. Generate a fresh token at api.slack.com/apps and try again.', + }); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); + + it('surfaces structured manifest validation errors', async () => { + mockSlackResponse({ + ok: false, + error: 'invalid_manifest', + errors: [ + { message: 'invalid scope', pointer: '/oauth_config/scopes/bot' }, + { message: 'missing bot user' }, + ], + }); + + const result = await createSlackAppFromManifestCommand(buildMockAuth(), { + configToken: 'xoxe.xoxp-token', + }); + + expect(result).toEqual({ + success: false, + error: + 'Slack rejected the generated app manifest: invalid scope (/oauth_config/scopes/bot); missing bot user', + }); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); + + it('fails when Slack omits credentials from a successful response', async () => { + mockSlackResponse({ + ok: true, + app_id: 'A0NEWAPP', + credentials: { + client_id: 'new-client-id', + }, + }); + + const result = await createSlackAppFromManifestCommand(buildMockAuth(), { + configToken: 'xoxe.xoxp-token', + }); + + expect(result).toEqual({ + success: false, + error: 'Slack returned an incomplete app creation response.', + }); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); + + it('fails with an HTTP error when the response body is not JSON', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 502, + json: async () => { + throw new Error('not json'); + }, + }); + + const result = await createSlackAppFromManifestCommand(buildMockAuth(), { + configToken: 'xoxe.xoxp-token', + }); + + expect(result).toEqual({ + success: false, + error: 'Failed to create the Slack app (HTTP 502). Please try again.', + }); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/trpc/commands/slack/create-app-from-manifest.ts b/apps/web/src/trpc/commands/slack/create-app-from-manifest.ts new file mode 100644 index 000000000..6dafe9272 --- /dev/null +++ b/apps/web/src/trpc/commands/slack/create-app-from-manifest.ts @@ -0,0 +1,190 @@ +import { buildSlackApiUrl } from '@roomote/slack'; +import { db } from '@roomote/db/server'; + +import type { UserAuthSuccess } from '@/types'; +import { Env } from '@/lib/server'; +import { buildSlackAppManifest } from '@/lib/slack-app-manifest'; +import { upsertDeploymentEnvironmentVariables } from '../environment-variables'; + +type SlackManifestCreateError = { + message?: string; + pointer?: string; +}; + +type SlackManifestCreateResponse = { + ok?: boolean; + error?: string; + errors?: SlackManifestCreateError[]; + app_id?: string; + credentials?: { + client_id?: string; + client_secret?: string; + verification_token?: string; + signing_secret?: string; + }; + oauth_authorize_url?: string; +}; + +type CreateSlackAppFromManifestResult = + | { success: true; appId: string } + | { success: false; error: string }; + +const CONFIG_TOKEN_ERRORS = new Set([ + 'invalid_auth', + 'not_authed', + 'token_expired', + 'token_revoked', + 'token_rotated', +]); + +const CONFIG_TOKEN_ERROR_MESSAGE = + 'Slack rejected the app configuration token. Generate a fresh token at api.slack.com/apps and try again.'; + +function formatManifestErrors( + errors: SlackManifestCreateError[] | undefined, +): string | null { + const messages = (errors ?? []) + .map((error) => { + const message = error.message?.trim(); + + if (!message) { + return null; + } + + const pointer = error.pointer?.trim(); + return pointer ? `${message} (${pointer})` : message; + }) + .filter((message): message is string => message !== null); + + if (messages.length === 0) { + return null; + } + + return `Slack rejected the generated app manifest: ${messages.join('; ')}`; +} + +/** + * Creates a Slack app via `apps.manifest.create` using a user-supplied app + * configuration token, then persists the resulting credentials as deployment + * environment variables. The configuration token is used for this one call + * and never stored. + */ +export async function createSlackAppFromManifest({ + configToken, + actorUserId, +}: { + configToken: string; + actorUserId: string | null; +}): Promise { + try { + const normalizedConfigToken = configToken.trim(); + + if (!normalizedConfigToken) { + return { + success: false, + error: 'Enter a Slack app configuration token.', + }; + } + + const publicOrigin = Env.R_APP_URL?.trim(); + + if (!publicOrigin) { + return { + success: false, + error: + 'The deployment public URL is not configured, so the Slack app manifest cannot be built.', + }; + } + + const manifest = buildSlackAppManifest({ publicOrigin }); + + const response = await fetch(buildSlackApiUrl('apps.manifest.create'), { + method: 'POST', + headers: { + Authorization: `Bearer ${normalizedConfigToken}`, + 'Content-Type': 'application/json; charset=utf-8', + }, + body: JSON.stringify({ manifest: JSON.stringify(manifest) }), + }); + + let data: SlackManifestCreateResponse | null = null; + + try { + data = (await response.json()) as SlackManifestCreateResponse; + } catch { + data = null; + } + + if (!data) { + return { + success: false, + error: `Failed to create the Slack app (HTTP ${response.status}). Please try again.`, + }; + } + + if (!data.ok) { + if (data.error && CONFIG_TOKEN_ERRORS.has(data.error)) { + return { success: false, error: CONFIG_TOKEN_ERROR_MESSAGE }; + } + + const manifestError = formatManifestErrors(data.errors); + + if (manifestError) { + return { success: false, error: manifestError }; + } + + return { + success: false, + error: data.error + ? `Slack returned an error: ${data.error}` + : 'Slack returned an unknown error while creating the app.', + }; + } + + const appId = data.app_id?.trim() ?? ''; + const clientId = data.credentials?.client_id?.trim() ?? ''; + const clientSecret = data.credentials?.client_secret?.trim() ?? ''; + const signingSecret = data.credentials?.signing_secret?.trim() ?? ''; + + if (!appId || !clientId || !clientSecret || !signingSecret) { + return { + success: false, + error: 'Slack returned an incomplete app creation response.', + }; + } + + await db.transaction(async (tx) => { + await upsertDeploymentEnvironmentVariables(tx, { + userId: actorUserId, + values: [ + { name: 'R_SLACK_CLIENT_ID', value: clientId }, + { name: 'R_SLACK_CLIENT_SECRET', value: clientSecret }, + { name: 'R_SLACK_SIGNING_SECRET', value: signingSecret }, + ], + }); + }); + + return { success: true, appId }; + } catch (error) { + console.error('[createSlackAppFromManifest] Unhandled error:', error); + + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function createSlackAppFromManifestCommand( + auth: UserAuthSuccess, + input: { configToken: string }, +): Promise { + if (!auth.isAdmin) { + return { success: false, error: 'Unauthorized' }; + } + + return createSlackAppFromManifest({ + configToken: input.configToken, + actorUserId: auth.userId, + }); +} diff --git a/apps/web/src/trpc/commands/slack/index.ts b/apps/web/src/trpc/commands/slack/index.ts index 1e7858e4d..4f341c36c 100644 --- a/apps/web/src/trpc/commands/slack/index.ts +++ b/apps/web/src/trpc/commands/slack/index.ts @@ -34,6 +34,8 @@ import { } from '@/lib/server/slack-oauth-state'; import { encodeRecord } from '@/lib/url-coder'; +export { createSlackAppFromManifestCommand } from './create-app-from-manifest'; + interface SlackOAuthResponse { ok: boolean; access_token?: string; diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index a48fde9fa..b720b26f7 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -96,6 +96,7 @@ import { import { exchangeSlackOAuthCodeCommand, connectSlackAppCommand, + createSlackAppFromManifestCommand, disconnectSlackAppCommand, getSlackInstallationCommand, startAuthenticateSlackAccountCommand, @@ -218,6 +219,7 @@ import { import { getSetupNewStatusCommand, getSetupBootstrapStatusCommand, + createSetupBootstrapSlackAppFromManifestCommand, saveSetupBootstrapAuthConfigCommand, saveSetupBootstrapAuthProviderChoiceCommand, saveSetupNewAuthConfigCommand, @@ -886,6 +888,12 @@ export const appRouter = createRouter({ connectSlackAppCommand(auth, input), ), + createAppFromManifest: protectedProcedure + .input(z.object({ configToken: z.string().trim().min(1) })) + .mutation(({ ctx: { auth }, input }) => + createSlackAppFromManifestCommand(auth, input), + ), + disconnectApp: protectedProcedure.mutation(({ ctx: { auth } }) => disconnectSlackAppCommand(auth), ), @@ -1934,6 +1942,17 @@ export const appRouter = createRouter({ }), ) .mutation(({ input }) => saveSetupBootstrapAuthConfigCommand(input)), + + createSlackAppFromManifest: publicProcedure + .input( + z.object({ + configToken: z.string().trim().min(1), + setupToken: z.string().optional(), + }), + ) + .mutation(({ input }) => + createSetupBootstrapSlackAppFromManifestCommand(input), + ), }), deployment: createRouter({ diff --git a/packages/slack/src/__tests__/mock-slack-server.test.ts b/packages/slack/src/__tests__/mock-slack-server.test.ts index bc9d3afe7..452904a6e 100644 --- a/packages/slack/src/__tests__/mock-slack-server.test.ts +++ b/packages/slack/src/__tests__/mock-slack-server.test.ts @@ -489,6 +489,145 @@ describe('MockSlackServer', () => { } }); + it('creates an app through apps.manifest.create with a config token and records the manifest', async () => { + const server = new MockSlackServer({ + state: { + team: { id: 'T1', domain: 'mock-roomote' }, + acceptedBotTokens: ['xoxb-mock-token'], + acceptedConfigTokens: ['xoxe.xoxp-config-token'], + manifestCredentials: { + appId: 'A0MANIFEST', + clientId: 'manifest-client-id', + clientSecret: 'manifest-client-secret', + signingSecret: 'manifest-signing-secret', + }, + channels: [{ id: 'C1', name: 'product-debug', isMember: true }], + users: [{ id: 'U1', name: 'alex', displayName: 'Alex' }], + }, + }); + + try { + await server.start(); + + const manifest = { + display_information: { name: 'Roomote' }, + settings: { + event_subscriptions: { + request_url: 'https://roomote.example.com/api/webhooks/slack', + }, + }, + }; + + // Config-token routes must not require a bot token: the config token + // is the only credential on this request. + const response = await fetch( + `${server.baseUrl}/api/apps.manifest.create`, + { + method: 'POST', + headers: { + authorization: 'Bearer xoxe.xoxp-config-token', + 'content-type': 'application/json', + }, + body: JSON.stringify({ manifest: JSON.stringify(manifest) }), + }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + app_id: 'A0MANIFEST', + credentials: { + client_id: 'manifest-client-id', + client_secret: 'manifest-client-secret', + verification_token: 'mock-manifest-verification-token', + signing_secret: 'manifest-signing-secret', + }, + oauth_authorize_url: + 'https://slack.com/oauth/v2/authorize?client_id=manifest-client-id', + }); + + expect(server.getState().createdManifests).toEqual([ + { appId: 'A0MANIFEST', manifest }, + ]); + } finally { + await server.stop(); + } + }); + + it('rejects apps.manifest.create with invalid_auth for an unknown config token', async () => { + const server = new MockSlackServer({ + state: { + team: { id: 'T1', domain: 'mock-roomote' }, + acceptedConfigTokens: ['xoxe.xoxp-config-token'], + channels: [{ id: 'C1', name: 'product-debug', isMember: true }], + users: [{ id: 'U1', name: 'alex', displayName: 'Alex' }], + }, + }); + + try { + await server.start(); + + const response = await fetch( + `${server.baseUrl}/api/apps.manifest.create`, + { + method: 'POST', + headers: { + authorization: 'Bearer xoxe.xoxp-wrong-token', + 'content-type': 'application/json', + }, + body: JSON.stringify({ manifest: '{}' }), + }, + ); + + // Real Slack reports Web API failures as HTTP 200 with ok=false. + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: false, + error: 'invalid_auth', + }); + expect(server.getState().createdManifests ?? []).toEqual([]); + } finally { + await server.stop(); + } + }); + + it('rejects apps.manifest.create with invalid_manifest when the manifest is not JSON', async () => { + const server = new MockSlackServer({ + state: { + team: { id: 'T1', domain: 'mock-roomote' }, + channels: [{ id: 'C1', name: 'product-debug', isMember: true }], + users: [{ id: 'U1', name: 'alex', displayName: 'Alex' }], + }, + }); + + try { + await server.start(); + + const response = await fetch( + `${server.baseUrl}/api/apps.manifest.create`, + { + method: 'POST', + headers: { + authorization: 'Bearer xoxe.xoxp-any-token', + 'content-type': 'application/json', + }, + body: JSON.stringify({ manifest: 'not-json' }), + }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: false, + error: 'invalid_manifest', + errors: [ + { message: 'manifest must be a JSON object', pointer: '/manifest' }, + ], + }); + } finally { + await server.stop(); + } + }); + it('returns not_in_channel for conversations.members when the app is not in the channel', async () => { const server = new MockSlackServer({ state: { diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index 2d5bc070a..d74bcdd5a 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -16,6 +16,7 @@ export * from './manager-mcp-setup'; export * from './request-user-input-blocks'; export * from './request-user-input'; export * from './router-debug'; +export * from './slack-api-base-url'; export * from './slack-messages'; export * from './slack-notifier'; export * from './slack-system-messages'; diff --git a/packages/slack/src/mock-slack-server.ts b/packages/slack/src/mock-slack-server.ts index c1a7b8b63..9901b8cf5 100644 --- a/packages/slack/src/mock-slack-server.ts +++ b/packages/slack/src/mock-slack-server.ts @@ -56,6 +56,19 @@ export type MockSlackTeam = { timezone?: string; }; +export type MockSlackManifestCredentials = { + appId?: string; + clientId?: string; + clientSecret?: string; + signingSecret?: string; + verificationToken?: string; +}; + +export type MockSlackCreatedManifest = { + appId: string; + manifest: Record; +}; + export type MockSlackState = { team: MockSlackTeam; acceptedBotTokens?: string[]; @@ -70,6 +83,17 @@ export type MockSlackState = { channels: MockSlackChannel[]; users: MockSlackUser[]; messages?: MockSlackStoredMessage[]; + /** + * Bearer tokens accepted by app-config endpoints (`apps.manifest.create`). + * Slack app configuration tokens live in a different token space than bot + * tokens, so they get their own allowlist. Undefined accepts any token, + * mirroring `acceptedBotTokens`. + */ + acceptedConfigTokens?: string[]; + /** Credential overrides returned by `apps.manifest.create`. */ + manifestCredentials?: MockSlackManifestCredentials; + /** Apps created through `apps.manifest.create`, oldest first. */ + createdManifests?: MockSlackCreatedManifest[]; }; export type MockSlackRoomoteTarget = { @@ -423,7 +447,12 @@ export class MockSlackServer { return; } - if (!this.isAuthorized(request)) { + // App-config endpoints authenticate with configuration tokens instead of + // bot tokens, so they skip the bot check and validate in their handler. + if ( + url.pathname !== '/api/apps.manifest.create' && + !this.isAuthorized(request) + ) { json(response, 401, { ok: false, error: 'invalid_auth' }); return; } @@ -446,6 +475,21 @@ export class MockSlackServer { return allowedTokens.includes(token); } + private isConfigTokenAuthorized(request: IncomingMessage): boolean { + const allowedTokens = this.state.acceptedConfigTokens; + if (!allowedTokens?.length) { + return true; + } + + const authorization = request.headers.authorization; + if (!authorization?.startsWith('Bearer ')) { + return false; + } + + const token = authorization.slice('Bearer '.length).trim(); + return allowedTokens.includes(token); + } + private async handleControlRequest( request: IncomingMessage, response: ServerResponse, @@ -589,6 +633,71 @@ export class MockSlackServer { return; } + case 'POST apps.manifest.create': { + // Real Slack reports Web API failures as HTTP 200 with `ok: false`. + if (!this.isConfigTokenAuthorized(request)) { + json(response, 200, { ok: false, error: 'invalid_auth' }); + return; + } + + const manifestValue = jsonBody.manifest; + let manifest: JsonRecord | null = null; + + if (typeof manifestValue === 'string') { + try { + manifest = JSON.parse(manifestValue) as JsonRecord; + } catch { + manifest = null; + } + } else if ( + manifestValue && + typeof manifestValue === 'object' && + !Array.isArray(manifestValue) + ) { + manifest = manifestValue as JsonRecord; + } + + if (!manifest) { + json(response, 200, { + ok: false, + error: 'invalid_manifest', + errors: [ + { + message: 'manifest must be a JSON object', + pointer: '/manifest', + }, + ], + }); + return; + } + + const credentials = this.state.manifestCredentials ?? {}; + const appId = credentials.appId ?? 'AMOCKMANIFEST'; + const clientId = credentials.clientId ?? 'mock-manifest-client-id'; + + this.state.createdManifests = [ + ...(this.state.createdManifests ?? []), + { appId, manifest }, + ]; + + json(response, 200, { + ok: true, + app_id: appId, + credentials: { + client_id: clientId, + client_secret: + credentials.clientSecret ?? 'mock-manifest-client-secret', + verification_token: + credentials.verificationToken ?? + 'mock-manifest-verification-token', + signing_secret: + credentials.signingSecret ?? 'mock-manifest-signing-secret', + }, + oauth_authorize_url: `https://slack.com/oauth/v2/authorize?client_id=${encodeURIComponent(clientId)}`, + }); + return; + } + case 'POST conversations.open': { const users = String(jsonBody.users ?? '') .split(',') From 6fb3acd9d17e35351dc08402ecca31579161f665 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 16 Jul 2026 11:36:35 +0100 Subject: [PATCH 2/6] UX improvements --- .../setup/ProviderSetupExperience.tsx | 129 +++++++++--------- 1 file changed, 67 insertions(+), 62 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx b/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx index 474287e6f..9ee278ab1 100644 --- a/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx +++ b/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx @@ -334,9 +334,6 @@ function ProviderSetupTitle({ function SlackSetupExperience(props: ProviderSetupExperienceProps) { const [configToken, setConfigToken] = useState(''); - const slackManifestPrefillUrl = buildSlackManifestPrefillUrl({ - publicOrigin: props.publicOrigin, - }); if ( !props.showManualSlackValues && @@ -361,52 +358,57 @@ function SlackSetupExperience(props: ProviderSetupExperienceProps) {
-
+

Because Roomote is self-hosted, we can't offer you an - out-of-the-box Slack app – you need your own. Paste an app - configuration token and Roomote creates the app for you. + out-of-the-box Slack app – you need your own. But with just an app + configuration token, we'll create it for you.

-
    -
  1. - Open{' '} - - api.slack.com/apps - - {' '} - and click - Generate Token - {' '} - under "Your App Configuration Tokens", picking the - workspace where Roomote should live. -
  2. -
  3. Copy the access token and paste it here.
  4. -
-
- -
-
App configuration token
- setConfigToken(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - submitConfigToken(); - } - }} - placeholder="xoxe.xoxp-..." - disabled={props.disabled || props.createSlackAppPending} - data-1p-ignore - /> + +

Get an app token.

+
+

+ Go to the{' '} + + Slack Apps portal + + {' '} + → Your App Configuration Tokens →{' '} + Generate Token, picking + the workspace you want to connect. +

+
+
+ +

Copy it....

+

+ ...and paste it here: +

+
+
Access token
+ setConfigToken(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + submitConfigToken(); + } + }} + placeholder="xoxe.xoxp-..." + disabled={props.disabled || props.createSlackAppPending} + data-1p-ignore + /> +
+
@@ -430,24 +432,9 @@ function SlackSetupExperience(props: ProviderSetupExperienceProps) { onClick={props.onShowManualSlackValues} > - Enter values manually + Create app manually
- -

- Prefer not to use a configuration token? Create the app in - Slack's UI from a{' '} - - prefilled manifest - {' '} - and enter its credentials manually. -

); } @@ -626,6 +613,9 @@ function GenericSetupExperience(props: ProviderSetupExperienceProps) { const providerSetupLabel = providerSetupCopy?.setupLabel ?? `${props.provider.label} app`; const fields = getSetupVisibleFields(props.provider); + const slackManifestPrefillUrl = buildSlackManifestPrefillUrl({ + publicOrigin: props.publicOrigin, + }); return (
@@ -634,6 +624,21 @@ function GenericSetupExperience(props: ProviderSetupExperienceProps) { text={`Configure ${providerSetupLabel}`} /> +

+ Prefer not to use a configuration token? Create the app in Slack's + UI from a{' '} + + prefilled manifest + {' '} + and enter its credentials manually. +

+

Create a new {providerSetupCopy.setupLabel}. From 79986ab4bd90f160ce8d1924fe0cf87a5cd6c04d Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 16 Jul 2026 11:47:54 +0100 Subject: [PATCH 3/6] Github copy improvements --- .../src/app/(onboarding)/setup/StepSourceControlConnect.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx index 945fe287b..73d85a9be 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx @@ -48,7 +48,7 @@ function getTokenBackedConnectCopy({ providerLabel: string; }): string { if (lockedByRuntime) { - return 'Connect to continue'; + return "We've got all the config we need, just connect to continue."; } switch (provider) { @@ -168,7 +168,7 @@ export function StepSourceControlConnect({ const lockedByRuntime = providerStatus?.configSatisfiedByRuntimeEnv === true; const providerLabel = providerStatus?.label ?? provider; const githubCopy = lockedByRuntime - ? 'Connect to continue' + ? "We've got all the config we need, just connect to continue." : 'Connect the GitHub App to grant Roomote access to your repositories.'; const tokenBackedCopy = getTokenBackedConnectCopy({ From 56f082cde748e56369dfb36f3408a3a779306548 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 16 Jul 2026 12:06:12 +0100 Subject: [PATCH 4/6] Add optional Slack app logo step --- .../setup/ProviderSetupExperience.tsx | 33 ++++++ .../setup/StepAuthEnvVars.client.test.tsx | 100 ++++++++++++++---- .../(onboarding)/setup/StepAuthEnvVars.tsx | 25 +++-- .../settings/CommsProviderSection.test.tsx | 39 ++++++- .../settings/CommsProviderSection.tsx | 8 +- .../slack/create-app-from-manifest.test.ts | 6 +- .../slack/create-app-from-manifest.ts | 8 +- 7 files changed, 181 insertions(+), 38 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx b/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx index 9ee278ab1..9d37fd84e 100644 --- a/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx +++ b/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx @@ -295,6 +295,7 @@ type ProviderSetupExperienceProps = { clearedSavedValues: Record; teamsAppPackageHref: string | null; showManualSlackValues: boolean; + createdSlackAppSettingsUrl?: string | null; createSlackAppPending?: boolean; showMicrosoftAdvancedConfig?: boolean; surface?: 'setup' | 'settings'; @@ -334,6 +335,38 @@ function ProviderSetupTitle({ function SlackSetupExperience(props: ProviderSetupExperienceProps) { const [configToken, setConfigToken] = useState(''); + const createdSlackAppSettingsUrl = props.createdSlackAppSettingsUrl ?? null; + + if (createdSlackAppSettingsUrl) { + return ( +

+ + +
+

+ Your Slack app is ready. Adding a logo is optional: open{' '} + + the app + + , scroll down to App icon, and upload{' '} + + the Roomote logo + + . +

+
+
+ ); + } if ( !props.showManualSlackValues && diff --git a/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.client.test.tsx index bc40c4662..8ef02ff18 100644 --- a/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.client.test.tsx @@ -6,7 +6,13 @@ import type { ReactNode, SVGProps, } from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { SetupAuthStatus } from '@roomote/types'; @@ -33,6 +39,7 @@ vi.mock('next/navigation', () => ({ vi.mock('sonner', () => ({ toast: { + success: vi.fn(), error: vi.fn(), }, })); @@ -409,20 +416,9 @@ describe('StepAuthEnvVars', () => { screen.getByRole('button', { name: /create slack app/i }), ).toBeDisabled(); - // The prefilled-manifest link remains as a secondary escape hatch. - const link = screen.getByRole('link', { name: /prefilled manifest/i }); - const url = new URL(link.getAttribute('href') ?? ''); - expect(url.origin + url.pathname).toBe('https://api.slack.com/apps'); - expect(url.searchParams.get('new_app')).toBe('1'); expect( - JSON.parse(url.searchParams.get('manifest_json') ?? '{}'), - ).toMatchObject({ - settings: { - event_subscriptions: { - request_url: 'https://roomote.example.com/api/webhooks/slack', - }, - }, - }); + screen.getByRole('button', { name: /create app manually/i }), + ).toBeInTheDocument(); expect( screen.queryByPlaceholderText('Slack Client ID'), ).not.toBeInTheDocument(); @@ -436,7 +432,23 @@ describe('StepAuthEnvVars', () => { />, ); - fireEvent.click(screen.getByRole('link', { name: /prefilled manifest/i })); + fireEvent.click( + screen.getByRole('button', { name: /create app manually/i }), + ); + + const link = screen.getByRole('link', { name: /prefilled manifest/i }); + const url = new URL(link.getAttribute('href') ?? ''); + expect(url.origin + url.pathname).toBe('https://api.slack.com/apps'); + expect(url.searchParams.get('new_app')).toBe('1'); + expect( + JSON.parse(url.searchParams.get('manifest_json') ?? '{}'), + ).toMatchObject({ + settings: { + event_subscriptions: { + request_url: 'https://roomote.example.com/api/webhooks/slack', + }, + }, + }); expect(screen.getByPlaceholderText('Slack Client ID')).toBeInTheDocument(); expect( @@ -447,7 +459,7 @@ describe('StepAuthEnvVars', () => { ).not.toBeInTheDocument(); }); - it('creates the Slack app from a config token and continues setup', async () => { + it('creates the Slack app from a config token, shows optional icon guidance, and continues setup', async () => { const invalidateQueries = vi.fn(); mockUseQueryClient.mockReturnValue({ invalidateQueries, @@ -470,7 +482,7 @@ describe('StepAuthEnvVars', () => { } as unknown as ReturnType; }); - render( + const { rerender } = render( { }); // The first useMutation registered by the component is createSlackApp. - await recordedOptions[0]?.onSuccess?.({ success: true, appId: 'A1' }); + await act(async () => { + await recordedOptions[0]?.onSuccess?.({ + success: true, + appId: 'A1', + appSettingsUrl: 'https://api.slack.com/apps/A1', + }); + }); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['setupNew.status'], }); - // With the credentials persisted server-side, the step saves the provider - // choice with no manual values and advances. + expect( + await screen.findByRole('heading', { name: /finish slack app/i }), + ).toBeInTheDocument(); + expect(screen.getByText(/Your Slack app is ready/i)).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /the Roomote logo/i }), + ).toHaveAttribute('href', '/api/setup/roomote-logo'); + expect(screen.getByRole('link', { name: /the app/i })).toHaveAttribute( + 'href', + 'https://api.slack.com/apps/A1', + ); + expect( + screen.queryByPlaceholderText('Slack Client ID'), + ).not.toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + + const refreshedAuthSetup = buildAuthSetup('slack'); + rerender( + + provider.id === 'slack' + ? { ...provider, savedSatisfied: true, setupSatisfied: true } + : provider, + ), + }} + onContinue={vi.fn()} + />, + ); + + expect( + screen.getByRole('heading', { name: /finish slack app/i }), + ).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText('Slack Client ID'), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + expect(mutateAsync).toHaveBeenCalledWith({ provider: 'slack', values: {} }); }); @@ -543,7 +599,7 @@ describe('StepAuthEnvVars', () => { ); fireEvent.click( - screen.getByRole('button', { name: /enter values manually/i }), + screen.getByRole('button', { name: /create app manually/i }), ); expect(screen.getByPlaceholderText('Slack Client ID')).toBeInTheDocument(); @@ -973,7 +1029,7 @@ describe('StepAuthEnvVars', () => { ); fireEvent.click( - screen.getByRole('button', { name: /enter values manually/i }), + screen.getByRole('button', { name: /create app manually/i }), ); fireEvent.change(screen.getByPlaceholderText('Slack Client ID'), { target: { value: 'client-id' }, diff --git a/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.tsx b/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.tsx index 8785b6bda..482717de7 100644 --- a/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.tsx @@ -69,6 +69,9 @@ export function StepAuthEnvVars({ >({}); const [showMicrosoftAdvancedConfig, setShowMicrosoftAdvancedConfig] = useState(false); + const [createdSlackAppSettingsUrl, setCreatedSlackAppSettingsUrl] = useState< + string | null + >(null); const createSlackApp = useMutation( (bootstrapMode ? trpc.setupBootstrap.createSlackAppFromManifest @@ -85,14 +88,7 @@ export function StepAuthEnvVars({ ? trpc.setupBootstrap.status.queryKey() : trpc.setupNew.status.queryKey(), }); - - try { - // The credentials are already persisted, so this records the - // provider choice and advances (or, in bootstrap mode, signs in). - await handleContinue(); - } catch { - // saveAuthConfig's own onError already surfaced the failure. - } + setCreatedSlackAppSettingsUrl(result.appSettingsUrl); }, onError: (error) => { toast.error(error.message); @@ -156,6 +152,7 @@ export function StepAuthEnvVars({ setEditingSavedValues({}); setClearedSavedValues({}); setShowMicrosoftAdvancedConfig(false); + setCreatedSlackAppSettingsUrl(null); }, [effectiveSelectedProviderId]); const selectedProvider = useMemo( @@ -270,6 +267,7 @@ export function StepAuthEnvVars({ useEffect(() => { setShowManualSlackValues(false); + setCreatedSlackAppSettingsUrl(null); }, [effectiveSelectedProviderId]); const selectedProviderRuntimeConfigured = @@ -278,6 +276,8 @@ export function StepAuthEnvVars({ selectedProvider.runtimeSatisfied; const selectedProviderHasEditableFields = visibleFields.some((field) => !field.runtimeSatisfied) ?? false; + const showingCreatedSlackAppStep = + selectedProvider?.id === 'slack' && createdSlackAppSettingsUrl !== null; // Slack "owns" the step actions only while its intro screen is shown, which // is the same condition SlackSetupExperience uses to render that intro. If a // saved (or runtime) config is being edited instead, the intro is hidden and @@ -286,8 +286,12 @@ export function StepAuthEnvVars({ const providerOwnsActions = selectedProvider?.id === 'slack' && !showManualSlackValues && + !showingCreatedSlackAppStep && !selectedProvider.runtimeSatisfied && !selectedProvider.savedSatisfied; + const continueDisabled = showingCreatedSlackAppStep + ? saveAuthConfig.isPending + : isActionDisabled; if (bootstrapMode && selectedProviderRuntimeConfigured) { return ( @@ -331,6 +335,7 @@ export function StepAuthEnvVars({ clearedSavedValues={clearedSavedValues} teamsAppPackageHref={teamsAppPackageHref} showManualSlackValues={showManualSlackValues} + createdSlackAppSettingsUrl={createdSlackAppSettingsUrl} createSlackAppPending={ createSlackApp.isPending || saveAuthConfig.isPending } @@ -373,11 +378,11 @@ export function StepAuthEnvVars({
@@ -446,7 +445,12 @@ function SlackSetupExperience(props: ProviderSetupExperienceProps) {
{props.onBack ? ( - @@ -456,13 +460,14 @@ function SlackSetupExperience(props: ProviderSetupExperienceProps) { onClick={submitConfigToken} disabled={createSlackAppDisabled || configToken.trim().length === 0} > - {props.createSlackAppPending ? : } + {createSlackAppPending ? : } Create Slack app