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..74999a4b5 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,12 @@ type ProviderSetupExperienceProps = { clearedSavedValues: Record; teamsAppPackageHref: string | null; showManualSlackValues: boolean; + createdSlackAppSettingsUrl?: string | null; + 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,55 +334,143 @@ function ProviderSetupTitle({ } function SlackSetupExperience(props: ProviderSetupExperienceProps) { - const slackManifestPrefillUrl = buildSlackManifestPrefillUrl({ - publicOrigin: props.publicOrigin, - }); + 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 && !props.provider.runtimeSatisfied && !props.provider.savedSatisfied ) { + const createSlackAppPending = props.createSlackAppPending === true; + const createSlackAppDisabled = + !props.onCreateSlackApp || props.disabled || createSlackAppPending; + const submitConfigToken = () => { + const normalizedConfigToken = configToken.trim(); + + if (createSlackAppDisabled || !normalizedConfigToken) { + return; + } + + props.onCreateSlackApp?.(normalizedConfigToken); + }; + return (
-
+

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. But with just an app + configuration token, we'll create it for you.

+ +

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 || createSlackAppPending} + data-1p-ignore + /> +
+
{props.onBack ? ( - ) : null} -
@@ -558,6 +651,12 @@ function GenericSetupExperience(props: ProviderSetupExperienceProps) { const providerSetupLabel = providerSetupCopy?.setupLabel ?? `${props.provider.label} app`; const fields = getSetupVisibleFields(props.provider); + const slackManifestPrefillUrl = + props.provider.id === 'slack' + ? buildSlackManifestPrefillUrl({ + publicOrigin: props.publicOrigin, + }) + : null; return (
@@ -566,6 +665,23 @@ function GenericSetupExperience(props: ProviderSetupExperienceProps) { text={`Configure ${providerSetupLabel}`} /> + {slackManifestPrefillUrl ? ( +

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

+ ) : null} +

Create a new {providerSetupCopy.setupLabel}. 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..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(), }, })); @@ -48,6 +55,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 +72,9 @@ vi.mock('@/trpc/client', () => ({ saveAuthConfig: { mutationOptions: (options: Record) => options, }, + createSlackAppFromManifest: { + mutationOptions: (options: Record) => options, + }, status: { queryKey: () => ['setupBootstrap.status'], }, @@ -382,7 +397,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(); + + expect( + screen.getByRole('button', { name: /create app manually/i }), + ).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText('Slack Client ID'), + ).not.toBeInTheDocument(); + }); + + it('shows the manual value form after opening the prefilled-manifest flow', () => { + render( + , + ); + + 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( @@ -409,28 +449,145 @@ describe('StepAuthEnvVars', () => { }, }, }); + + expect(screen.getByPlaceholderText('Slack Client ID')).toBeInTheDocument(); expect( - screen.queryByPlaceholderText('Slack Client ID'), + screen.getByPlaceholderText('Slack Client Secret'), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /create slack app/i }), ).not.toBeInTheDocument(); }); - it('shows the manual value form after opening the Slack app creation flow', () => { - render( + it('creates the Slack app from a config token, shows optional icon guidance, 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; + }); + + const { rerender } = render( , ); - fireEvent.click(screen.getByRole('link', { name: /create slack app/i })); + fireEvent.change(screen.getByLabelText('App configuration token'), { + target: { value: ' xoxe.xoxp-config-token ' }, + }); + fireEvent.click(screen.getByRole('button', { name: /create slack app/i })); - expect(screen.getByPlaceholderText('Slack Client ID')).toBeInTheDocument(); + expect(mutate).toHaveBeenCalledWith({ + configToken: 'xoxe.xoxp-config-token', + }); + + // The first useMutation registered by the component is createSlackApp. + await act(async () => { + await recordedOptions[0]?.onSuccess?.({ + success: true, + appId: 'A1', + appSettingsUrl: 'https://api.slack.com/apps/A1', + }); + }); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['setupNew.status'], + }); expect( - screen.getByPlaceholderText('Slack Client Secret'), + await screen.findByRole('heading', { name: /finish slack app/i }), ).toBeInTheDocument(); + expect(screen.getByText(/Your Slack app is ready/i)).toBeInTheDocument(); expect( - screen.queryByRole('link', { name: /create slack app/i }), + 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: {} }); + }); + + 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', () => { @@ -442,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(); @@ -872,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 1fab57c3c..482717de7 100644 --- a/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepAuthEnvVars.tsx @@ -69,6 +69,32 @@ export function StepAuthEnvVars({ >({}); const [showMicrosoftAdvancedConfig, setShowMicrosoftAdvancedConfig] = useState(false); + const [createdSlackAppSettingsUrl, setCreatedSlackAppSettingsUrl] = useState< + string | null + >(null); + 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(), + }); + setCreatedSlackAppSettingsUrl(result.appSettingsUrl); + }, + onError: (error) => { + toast.error(error.message); + }, + }), + ); const saveAuthConfig = useMutation( (bootstrapMode ? trpc.setupBootstrap.saveAuthConfig @@ -126,6 +152,7 @@ export function StepAuthEnvVars({ setEditingSavedValues({}); setClearedSavedValues({}); setShowMicrosoftAdvancedConfig(false); + setCreatedSlackAppSettingsUrl(null); }, [effectiveSelectedProviderId]); const selectedProvider = useMemo( @@ -240,6 +267,7 @@ export function StepAuthEnvVars({ useEffect(() => { setShowManualSlackValues(false); + setCreatedSlackAppSettingsUrl(null); }, [effectiveSelectedProviderId]); const selectedProviderRuntimeConfigured = @@ -248,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 @@ -256,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 ( @@ -301,7 +335,17 @@ export function StepAuthEnvVars({ clearedSavedValues={clearedSavedValues} teamsAppPackageHref={teamsAppPackageHref} showManualSlackValues={showManualSlackValues} + createdSlackAppSettingsUrl={createdSlackAppSettingsUrl} + createSlackAppPending={ + createSlackApp.isPending || saveAuthConfig.isPending + } showMicrosoftAdvancedConfig={showMicrosoftAdvancedConfig} + onCreateSlackApp={(configToken) => + createSlackApp.mutate({ + configToken, + ...(bootstrapMode && setupToken ? { setupToken } : {}), + }) + } onShowManualSlackValues={() => setShowManualSlackValues(true)} onToggleMicrosoftAdvancedConfig={() => setShowMicrosoftAdvancedConfig((current) => !current) @@ -334,11 +378,11 @@ export function StepAuthEnvVars({