From 484d9d29c7a4147efca7248c9927def828de9dff Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 14 Aug 2026 18:01:48 +0000 Subject: [PATCH] feat(web): reorder email setup providers --- .../(onboarding)/setup/SetupBootstrapFlow.tsx | 19 +++- .../(onboarding)/setup/SetupSignedInFlow.tsx | 5 ++ .../setup/bootstrapFlow.client.test.ts | 20 +++++ .../app/(onboarding)/setup/bootstrapFlow.ts | 25 ++++++ .../(onboarding)/setup/hooks.client.test.tsx | 65 ++++++++++++-- apps/web/src/app/(onboarding)/setup/hooks.ts | 88 +++++++++++++------ apps/web/src/app/(onboarding)/setup/types.ts | 24 ++--- 7 files changed, 194 insertions(+), 52 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/SetupBootstrapFlow.tsx b/apps/web/src/app/(onboarding)/setup/SetupBootstrapFlow.tsx index f023ac2e5..5b3602ab5 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupBootstrapFlow.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupBootstrapFlow.tsx @@ -28,6 +28,8 @@ import { getBootstrapStepFromSetupStepParam, getBootstrapStepAfterWelcome, getNextBootstrapStep, + clearSetupEmailPasswordAuthSelected, + markSetupEmailPasswordAuthSelected, type BootstrapStep, } from './bootstrapFlow'; import { useSetupFlow } from './hooks'; @@ -123,6 +125,15 @@ export function SetupBootstrapFlow() { useRedirectToSignIn(shouldRedirectToSignIn); + useEffect(() => { + if ( + bootstrapStep === 'auth-provider' || + bootstrapStep === 'auth-env-vars' + ) { + clearSetupEmailPasswordAuthSelected(); + } + }, [bootstrapStep]); + // Setup docs are server-rendered from this query parameter. Keep the // effective provider in the URL while signed out just as the signed-in flow // does, so provider-specific bootstrap instructions remain visible. @@ -241,14 +252,16 @@ export function SetupBootstrapFlow() { {bootstrapStep === 'email-account' && ( { + clearSetupEmailPasswordAuthSelected(); setPendingAuthProvider(provider); setBootstrapStepWithTransition( getNextBootstrapStep(bootstrapStatus.authSetup, provider), ); }} - onUseEmailPassword={() => - setBootstrapStepWithTransition('email-password') - } + onUseEmailPassword={() => { + markSetupEmailPasswordAuthSelected(); + setBootstrapStepWithTransition('email-password'); + }} /> )} {bootstrapStep === 'email-password' && ( diff --git a/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx b/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx index 9e2bfa3c8..92c7076cc 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx @@ -43,6 +43,7 @@ import { StepRepoSelection, type SetupRetryReason } from './StepRepoSelection'; import { StepAutomationRecommendations } from './StepAutomationRecommendations'; import { getSetupStepPath } from './types'; import { LoadingSetupFlow, stepTransitionVariants } from './SetupBootstrapFlow'; +import { consumeSetupEmailPasswordAuthSelection } from './bootstrapFlow'; function getSetupRetryReason(status: { onboardingFailed: boolean; @@ -81,6 +82,9 @@ export function SetupSignedInFlow() { useState(null); const [pendingModelProvider, setPendingModelProvider] = useState(null); + const [communicationAfterSourceControl] = useState( + consumeSetupEmailPasswordAuthSelection, + ); const trackWelcomeSeen = useMutation( trpc.setupNew.trackWelcomeSeen.mutationOptions(), ); @@ -97,6 +101,7 @@ export function SetupSignedInFlow() { const flow = useSetupFlow({ enabled: isSignedIn && isAdmin, pendingAuthProvider: pendingSetupAuthProvider, + communicationAfterSourceControl, }); const { step, diff --git a/apps/web/src/app/(onboarding)/setup/bootstrapFlow.client.test.ts b/apps/web/src/app/(onboarding)/setup/bootstrapFlow.client.test.ts index b43674804..fc596e740 100644 --- a/apps/web/src/app/(onboarding)/setup/bootstrapFlow.client.test.ts +++ b/apps/web/src/app/(onboarding)/setup/bootstrapFlow.client.test.ts @@ -1,10 +1,13 @@ import type { SetupAuthProviderId, SetupAuthStatus } from '@roomote/types'; import { + clearSetupEmailPasswordAuthSelected, + consumeSetupEmailPasswordAuthSelection, getBootstrapAuthProvider, getBootstrapStepFromSetupStepParam, getBootstrapStepAfterWelcome, getNextBootstrapStep, + markSetupEmailPasswordAuthSelected, shouldSkipBootstrapAccountStep, } from './bootstrapFlow'; @@ -24,6 +27,23 @@ function buildAuthSetup( } describe('bootstrapFlow', () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + it('carries the email/password account choice into the signed-in flow', () => { + markSetupEmailPasswordAuthSelected(); + expect(consumeSetupEmailPasswordAuthSelection()).toBe(true); + expect(consumeSetupEmailPasswordAuthSelection()).toBe(false); + }); + + it('clears a stale email/password choice before Slack or Teams account auth', () => { + markSetupEmailPasswordAuthSelected(); + clearSetupEmailPasswordAuthSelected(); + + expect(consumeSetupEmailPasswordAuthSelection()).toBe(false); + }); + it('routes runtime-configured Slack directly to auth env vars/sign-in', () => { const authSetup = buildAuthSetup({ selectedProvider: 'slack', diff --git a/apps/web/src/app/(onboarding)/setup/bootstrapFlow.ts b/apps/web/src/app/(onboarding)/setup/bootstrapFlow.ts index cce6da521..8b291064d 100644 --- a/apps/web/src/app/(onboarding)/setup/bootstrapFlow.ts +++ b/apps/web/src/app/(onboarding)/setup/bootstrapFlow.ts @@ -1,5 +1,30 @@ import type { SetupAuthProviderId, SetupAuthStatus } from '@roomote/types'; +const SETUP_ACCOUNT_AUTH_METHOD_KEY = 'roomote.setup.account-auth-method'; + +export function markSetupEmailPasswordAuthSelected() { + window.sessionStorage.setItem( + SETUP_ACCOUNT_AUTH_METHOD_KEY, + 'email-password', + ); +} + +export function clearSetupEmailPasswordAuthSelected() { + if (typeof window !== 'undefined') { + window.sessionStorage.removeItem(SETUP_ACCOUNT_AUTH_METHOD_KEY); + } +} + +export function consumeSetupEmailPasswordAuthSelection() { + const selected = + typeof window !== 'undefined' && + window.sessionStorage.getItem(SETUP_ACCOUNT_AUTH_METHOD_KEY) === + 'email-password'; + + clearSetupEmailPasswordAuthSelected(); + return selected; +} + export type BootstrapStep = | 'welcome' | 'email-account' diff --git a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx index 73b0ea737..86c7da427 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx @@ -277,10 +277,12 @@ describe('useSetupFlow', () => { window.history.replaceState = originalReplaceState; }); - it('starts with auth-provider when auth selection is still missing', async () => { + it('starts with inference when email/password auth leaves comms unselected', async () => { mockStatus(); - const { result } = renderHook(() => useSetupFlow()); + const { result } = renderHook(() => + useSetupFlow({ communicationAfterSourceControl: true }), + ); await waitFor(() => { expect(result.current.step).toBe('welcome'); @@ -289,7 +291,7 @@ describe('useSetupFlow', () => { act(() => { result.current.goToNextStep(); }); - expect(result.current.step).toBe('auth-provider'); + expect(result.current.step).toBe('env-vars'); }); it('skips the wizard welcome when the bootstrap flow already showed it', async () => { @@ -299,7 +301,50 @@ describe('useSetupFlow', () => { markSetupWelcomeSeen(); mockStatus(); - const { result } = renderHook(() => useSetupFlow()); + const { result } = renderHook(() => + useSetupFlow({ communicationAfterSourceControl: true }), + ); + + await waitFor(() => { + expect(result.current.step).toBe('env-vars'); + }); + }); + + it('offers comms after source control for email/password auth', async () => { + markSetupWelcomeSeen(); + mockStatus({ + modelSetup: { + setupSatisfied: true, + setupSatisfiedByRuntimeEnv: false, + preselectedProvider: 'openrouter', + }, + sourceControlSetup: { + setupSatisfied: true, + setupSatisfiedByRuntimeEnv: false, + selectedProvider: 'github', + preselectedProvider: 'github', + runtimeConfiguredProvider: null, + runtimeConfiguredProviders: [], + lockReason: null, + connectedProvider: 'github', + providers: [], + }, + setupNewState: { + authProvider: null, + modelProvider: 'openrouter', + computeProvider: null, + sourceControlProvider: 'github', + selectedRepositoryIds: [], + onboardingTaskId: null, + onboardingTaskStartedAt: null, + slackChannel: null, + slackThreadTs: null, + }, + }); + + const { result } = renderHook(() => + useSetupFlow({ communicationAfterSourceControl: true }), + ); await waitFor(() => { expect(result.current.step).toBe('auth-provider'); @@ -312,7 +357,9 @@ describe('useSetupFlow', () => { setupCompletedAt: '2024-01-01T00:00:00.000Z', }); - const { result } = renderHook(() => useSetupFlow()); + const { result } = renderHook(() => + useSetupFlow({ communicationAfterSourceControl: true }), + ); await waitFor(() => { expect(result.current.step).toBe('welcome'); @@ -1506,7 +1553,9 @@ describe('useSetupFlow', () => { it('pushes the next step URL when the user advances with goToNextStep', async () => { mockStatus(); - const { result } = renderHook(() => useSetupFlow()); + const { result } = renderHook(() => + useSetupFlow({ communicationAfterSourceControl: true }), + ); await waitFor(() => { expect(result.current.step).toBe('welcome'); @@ -1516,8 +1565,8 @@ describe('useSetupFlow', () => { result.current.goToNextStep(); }); - expect(result.current.step).toBe('auth-provider'); - expect(routerMock.push).toHaveBeenCalledWith('/setup?step=auth-provider'); + expect(result.current.step).toBe('env-vars'); + expect(routerMock.push).toHaveBeenCalledWith('/setup?step=env-vars'); }); it('merges a later URL write into a step navigation the address bar has not committed yet', async () => { diff --git a/apps/web/src/app/(onboarding)/setup/hooks.ts b/apps/web/src/app/(onboarding)/setup/hooks.ts index 6da27a5c4..76eacae09 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.ts +++ b/apps/web/src/app/(onboarding)/setup/hooks.ts @@ -66,6 +66,22 @@ const DEEP_LINK_REVISITABLE_SETUP_STEPS: readonly SetupStep[] = [ 'compute-config', ]; +const COMMUNICATION_AUTH_SETUP_STEPS: readonly SetupStep[] = [ + 'welcome', + 'auth-provider', + 'auth-env-vars', + 'slack', + 'env-vars', + 'source-control-provider', + 'source-control-config', + 'source-control-connect', + 'automation-recommendations', + 'compute-provider', + 'compute-config', + 'repo-selection', + 'invoke', +]; + function readUrlEntryContext(): SetupEntryContext { if (typeof window === 'undefined') { return { @@ -220,6 +236,7 @@ export function useSetupFlow( options: { enabled?: boolean; pendingAuthProvider?: SetupAuthProviderId | null; + communicationAfterSourceControl?: boolean; } = {}, ) { const trpc = useTRPC(); @@ -227,6 +244,8 @@ export function useSetupFlow( const { user } = useUser(); const queryEnabled = options.enabled ?? true; const pendingAuthProvider = options.pendingAuthProvider ?? null; + const communicationAfterSourceControl = + options.communicationAfterSourceControl ?? false; const { data: status, @@ -279,21 +298,27 @@ export function useSetupFlow( const setupSession = useSetupAsyncSession({ currentTaskId: status?.setupNewState.onboardingTaskId ?? null, }); + const setupSteps = communicationAfterSourceControl + ? SETUP_STEPS + : COMMUNICATION_AUTH_SETUP_STEPS; stepRef.current = step; - const setStepWithTransition = useCallback((nextStep: SetupStep) => { - const currentIndex = SETUP_STEPS.indexOf(stepRef.current); - const nextIndex = SETUP_STEPS.indexOf(nextStep); + const setStepWithTransition = useCallback( + (nextStep: SetupStep) => { + const currentIndex = setupSteps.indexOf(stepRef.current); + const nextIndex = setupSteps.indexOf(nextStep); - if (nextStep !== stepRef.current) { - setTransitionDirection( - nextIndex >= currentIndex ? 'forward' : 'backward', - ); - } + if (nextStep !== stepRef.current) { + setTransitionDirection( + nextIndex >= currentIndex ? 'forward' : 'backward', + ); + } - stepRef.current = nextStep; - setStep(nextStep); - }, []); + stepRef.current = nextStep; + setStep(nextStep); + }, + [setupSteps], + ); const communicationStepResolved = setupSession.session.communicationStep.state === 'skipped' || setupSession.session.communicationStep.state === 'completed'; @@ -507,8 +532,8 @@ export function useSetupFlow( const findNextStep = useCallback( (fromIndex: number): SetupStep => { - for (let index = fromIndex; index < SETUP_STEPS.length; index += 1) { - const candidate = SETUP_STEPS[index]; + for (let index = fromIndex; index < setupSteps.length; index += 1) { + const candidate = setupSteps[index]; if (candidate && !shouldSkip(candidate)) { return candidate; @@ -517,13 +542,13 @@ export function useSetupFlow( return hasPostOnboardingAccess() ? 'invoke' : 'repo-selection'; }, - [hasPostOnboardingAccess, shouldSkip], + [hasPostOnboardingAccess, setupSteps, shouldSkip], ); const findPreviousStep = useCallback( (fromIndex: number): SetupStep | null => { for (let index = fromIndex - 1; index >= 0; index -= 1) { - const candidate = SETUP_STEPS[index]; + const candidate = setupSteps[index]; if (candidate && !shouldSkip(candidate)) { return candidate; @@ -532,33 +557,33 @@ export function useSetupFlow( return null; }, - [shouldSkip], + [setupSteps, shouldSkip], ); const getPreviousNavigationStep = useCallback( (currentStep: SetupStep): SetupStep | null => { return ( navigationHistoryRef.current.at(-1) ?? - findPreviousStep(SETUP_STEPS.indexOf(currentStep)) ?? + findPreviousStep(setupSteps.indexOf(currentStep)) ?? (currentStep === 'source-control-connect' || currentStep === 'source-control-config' ? 'source-control-provider' : null) ); }, - [findPreviousStep], + [findPreviousStep, setupSteps], ); const findNextPostOnboardingStep = useCallback( ({ - fromIndex = SETUP_STEPS.indexOf('invoke'), + fromIndex = setupSteps.indexOf('invoke'), forceUnlocked, }: { fromIndex?: number; forceUnlocked?: boolean; } = {}): SetupStep => { - for (let index = fromIndex; index < SETUP_STEPS.length; index += 1) { - const candidate = SETUP_STEPS[index]; + for (let index = fromIndex; index < setupSteps.length; index += 1) { + const candidate = setupSteps[index]; if ( candidate && @@ -570,7 +595,7 @@ export function useSetupFlow( return 'invoke'; }, - [shouldSkipPostOnboarding], + [setupSteps, shouldSkipPostOnboarding], ); const readSetupSearchParams = useCallback(() => { @@ -643,7 +668,7 @@ export function useSetupFlow( const firstPendingStep = findNextStep(0); if ( - SETUP_STEPS.indexOf(requested) > SETUP_STEPS.indexOf(firstPendingStep) + setupSteps.indexOf(requested) > setupSteps.indexOf(firstPendingStep) ) { pinnedUrlStepRef.current = null; return firstPendingStep; @@ -659,12 +684,12 @@ export function useSetupFlow( pinnedUrlStepRef.current = null; if (shouldSkip(requested)) { - return findNextStep(SETUP_STEPS.indexOf(requested) + 1); + return findNextStep(setupSteps.indexOf(requested) + 1); } return requested; }, - [findNextStep, shouldSkip], + [findNextStep, setupSteps, shouldSkip], ); useEffect(() => { @@ -863,7 +888,7 @@ export function useSetupFlow( }, [getPreviousNavigationStep, pushStepUrl, shouldSkip, step]); const goToNextStep = useCallback(() => { - const currentIndex = SETUP_STEPS.indexOf(step); + const currentIndex = setupSteps.indexOf(step); const nextStep = findNextStep(currentIndex + 1); if (nextStep !== step) { navigationHistoryRef.current.push(step); @@ -871,7 +896,7 @@ export function useSetupFlow( pinnedUrlStepRef.current = null; setStepWithTransition(nextStep); pushStepUrl(nextStep); - }, [findNextStep, pushStepUrl, setStepWithTransition, step]); + }, [findNextStep, pushStepUrl, setStepWithTransition, setupSteps, step]); const goToNextPostOnboardingStep = useCallback( (forceUnlocked = false) => { @@ -889,7 +914,7 @@ export function useSetupFlow( const advancePostOnboardingStep = useCallback( (resolvedStep: SetupStep) => { const nextStep = findNextPostOnboardingStep({ - fromIndex: SETUP_STEPS.indexOf(resolvedStep) + 1, + fromIndex: setupSteps.indexOf(resolvedStep) + 1, forceUnlocked: true, }); if (nextStep !== resolvedStep) { @@ -899,7 +924,12 @@ export function useSetupFlow( setStepWithTransition(nextStep); pushStepUrl(nextStep); }, - [findNextPostOnboardingStep, pushStepUrl, setStepWithTransition], + [ + findNextPostOnboardingStep, + pushStepUrl, + setStepWithTransition, + setupSteps, + ], ); return { diff --git a/apps/web/src/app/(onboarding)/setup/types.ts b/apps/web/src/app/(onboarding)/setup/types.ts index 9127cd922..283554303 100644 --- a/apps/web/src/app/(onboarding)/setup/types.ts +++ b/apps/web/src/app/(onboarding)/setup/types.ts @@ -10,18 +10,6 @@ const SETUP_STEP_DEFINITIONS = [ id: 'welcome', title: `Welcome to ${PRODUCT_NAME}!`, }, - { - id: 'auth-provider', - title: 'Communication provider', - }, - { - id: 'auth-env-vars', - title: 'Configure comms', - }, - { - id: 'slack', - title: 'Connect Slack', - }, { id: 'env-vars', title: 'Configure inference', @@ -38,6 +26,18 @@ const SETUP_STEP_DEFINITIONS = [ id: 'source-control-connect', title: 'Connect source control', }, + { + id: 'auth-provider', + title: 'Communication provider', + }, + { + id: 'auth-env-vars', + title: 'Configure comms', + }, + { + id: 'slack', + title: 'Connect Slack', + }, { id: 'automation-recommendations', title: 'Automation recommendations',