diff --git a/apps/mobile/src/components/login-screen.test.ts b/apps/mobile/src/components/login-screen.test.ts index 40572713369..21033947d53 100644 --- a/apps/mobile/src/components/login-screen.test.ts +++ b/apps/mobile/src/components/login-screen.test.ts @@ -39,7 +39,10 @@ const setLanguagePickerBridge = vi.hoisted(() => vi.fn()); const announcingWarning = vi.hoisted(() => vi.fn()); // `sessionEnded` is toggled per test to drive the login screen's announcement // effect without re-mocking the module. -const authState = vi.hoisted(() => ({ sessionEnded: false })); +const authState = vi.hoisted(() => ({ + sessionEnded: false, + token: undefined as string | undefined, +})); vi.mock('expo-router', () => ({ useRouter: () => ({ push }), @@ -71,7 +74,11 @@ vi.mock('@/lib/a11y/announcing-toast', () => ({ announcingToast: { warning: announcingWarning }, })); vi.mock('@/lib/auth/auth-context', () => ({ - useAuth: () => ({ sessionEnded: authState.sessionEnded, signIn: vi.fn() }), + useAuth: () => ({ + sessionEnded: authState.sessionEnded, + signIn: vi.fn(), + token: authState.token, + }), })); vi.mock('@/lib/auth/use-device-auth', () => ({ useDeviceAuth: () => ({ @@ -95,7 +102,7 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({ vi.mock('@/lib/login-draft', () => ({ clearLoginDrafts: vi.fn(), clearPersistedLoginDrafts: vi.fn(), - persistLoginDrafts: vi.fn(), + persistLoginDrafts: vi.fn().mockResolvedValue(undefined), restoreLoginDrafts: vi.fn().mockResolvedValue(null), })); vi.mock('@/lib/picker-bridge', () => ({ @@ -322,6 +329,7 @@ describe('login-screen language globe', () => { deviceAuth.error = undefined; deviceAuth.verificationUrl = undefined; deviceAuth.resumed = false; + authState.token = undefined; push.mockClear(); setLanguagePickerBridge.mockClear(); }); @@ -510,8 +518,10 @@ describe('login-screen idle skeleton', () => { deviceAuth.error = undefined; deviceAuth.verificationUrl = undefined; deviceAuth.resumed = false; + authState.token = undefined; vi.mocked(restoreLoginDrafts).mockResolvedValue({ email: '', ssoRecovery: null }); vi.mocked(clearPersistedLoginDrafts).mockClear(); + vi.mocked(persistLoginDrafts).mockClear(); }); it('shows a form skeleton until the draft restore finishes', async () => { @@ -563,6 +573,70 @@ describe('login-screen idle skeleton', () => { renderer.unmount(); }); + + it('persists the drafts when the screen unmounts so a rotation remount restores them', async () => { + const renderer = await mountLoginScreen(); + + await act(async () => { + await Promise.resolve(); + }); + expect(persistLoginDrafts).not.toHaveBeenCalled(); + + renderer.unmount(); + await act(async () => { + await Promise.resolve(); + }); + + expect(persistLoginDrafts).toHaveBeenCalledTimes(1); + }); + + it('skips unmount persistence once the mount completed a sign-in', async () => { + deviceAuth.status = 'approved'; + deviceAuth.token = 'tok'; + // signIn's mock does not publish through the context; the completed + // sign-in publishes the token there, which is what retires the drafts. + authState.token = 'tok'; + + const renderer = await mountLoginScreen(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + renderer.unmount(); + await act(async () => { + await Promise.resolve(); + }); + + expect(persistLoginDrafts).not.toHaveBeenCalled(); + }); + + it('skips unmount persistence when a non-device sign-in published its token', async () => { + // Email-code, Apple, Google, and passkey sign-ins complete without the + // device-auth approved path: they publish the token through the auth + // context directly. The unmount persistence must stay off for them too, + // or the typed email would pre-fill the next login as the previous + // account's address. + const renderer = await mountLoginScreen(); + + await act(async () => { + await Promise.resolve(); + }); + + authState.token = 'provider-token'; + await act(async () => { + renderer.update(createElement(LoginScreen)); + await Promise.resolve(); + }); + + renderer.unmount(); + await act(async () => { + await Promise.resolve(); + }); + + expect(persistLoginDrafts).not.toHaveBeenCalled(); + }); }); describe('login-screen approved wait surface', () => { diff --git a/apps/mobile/src/components/login-screen.tsx b/apps/mobile/src/components/login-screen.tsx index ac5c0386874..9e3f14e3002 100644 --- a/apps/mobile/src/components/login-screen.tsx +++ b/apps/mobile/src/components/login-screen.tsx @@ -2,7 +2,7 @@ import * as Clipboard from 'expo-clipboard'; import { type Href, useRouter } from 'expo-router'; import { Globe } from '@/components/ui/icons'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { I18nManager, Pressable, ScrollView, View } from 'react-native'; import { useKeyboardState } from 'react-native-keyboard-controller'; @@ -33,7 +33,7 @@ import { setLanguagePickerBridge } from '@/lib/picker-bridge'; import { useScreenInsets } from '@/lib/screen-insets'; export function LoginScreen() { - const { sessionEnded, signIn } = useAuth(); + const { sessionEnded, signIn, token: publishedToken } = useAuth(); const router = useRouter(); const { status, @@ -59,12 +59,16 @@ export function LoginScreen() { ssoRecovery: SsoRecoveryDraft | null; } | null>(null); + // Set once this mount completes a sign-in: the unmount persistence then + // stays off, because the signed-in session owns the screen and a typed + // email from the abandoned attempt must not survive into the next login. + const signedInRef = useRef(false); + const persistToken = useCallback( async (tokenValue: string, refreshTokenValue?: string, expiresInValue?: number) => { setPersistError(undefined); try { await signIn(tokenValue, refreshTokenValue, expiresInValue); - clearLoginDrafts(); } catch { setPersistError(t('login.couldNotCompleteSignIn')); } @@ -72,6 +76,19 @@ export function LoginScreen() { [signIn, t] ); + // Every provider's completed sign-in — device code, email code, Apple, + // Google, passkey — publishes its token through the auth context, so this + // one watch covers them all: the module drafts are dropped (a later + // sign-out must not re-persist the previous account's address) and the + // unmount persistence stays off, or the next login mount would pre-fill + // the previous account's address. + useEffect(() => { + if (publishedToken !== undefined) { + clearLoginDrafts(); + signedInRef.current = true; + } + }, [publishedToken]); + useEffect(() => { let cancelled = false; const restoreDrafts = async () => { @@ -90,6 +107,22 @@ export function LoginScreen() { void restoreDrafts(); return () => { cancelled = true; + // Write the module-level drafts back before this mount's state dies, so + // the next mount restores what the user had typed — a device rotation + // that recreates the activity must not blank the email field. A failed + // write leaves the field empty on the next mount; the restore path + // already tolerates missing drafts. After a completed sign-in the + // drafts were cleared on purpose, so persistence stays off. + if (signedInRef.current) { + return; + } + void (async () => { + try { + await persistLoginDrafts(); + } catch { + // Swallow: nothing to show on an unmounting screen. + } + })(); }; }, []); diff --git a/apps/mobile/src/components/login/__tests__/idle-auth-email-flow.mounted.test.tsx b/apps/mobile/src/components/login/__tests__/idle-auth-email-flow.mounted.test.tsx index b6568a5df69..0ffe59d591a 100644 --- a/apps/mobile/src/components/login/__tests__/idle-auth-email-flow.mounted.test.tsx +++ b/apps/mobile/src/components/login/__tests__/idle-auth-email-flow.mounted.test.tsx @@ -323,4 +323,167 @@ describe('IdleAuth email sign-in flow (real useNativeAuth)', () => { renderer.unmount(); }); }); + + it('drops a refusal that settles after the user edited the code', async () => { + mockPostAuth.mockResolvedValue({ + ok: true, + data: { success: true, challengeId: 'c0000000-0000-4000-8000-000000000001' }, + }); + + const renderer = await mountIdleAuth(); + + const field = findAll(renderer.root, 'FormField')[0]; + if (!field) { + throw new Error('FormField not found'); + } + act(() => { + (field.props.onChangeText as (value: string) => void)('late@example.com'); + }); + + await act(async () => { + (continueButton(renderer.root).props.onPress as () => void)(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const otpForm = findAll(renderer.root, 'EmailOtpForm')[0]; + if (!otpForm) { + throw new Error('EmailOtpForm not found'); + } + + // The refusal resolves only after the user has edited the code. + const verifyGate = Promise.withResolvers(); + mockPostAuth.mockReturnValue(verifyGate.promise as never); + + await act(async () => { + (otpForm.props.onVerify as (code: string) => void)('000000'); + await Promise.resolve(); + await Promise.resolve(); + }); + + // The user edits the code while the attempt is in flight. + act(() => { + (otpForm.props.onCodeChange as () => void)(); + }); + + await act(async () => { + verifyGate.resolve({ + ok: false, + errorCode: 'INVALID_CODE', + ssoOrganizationId: undefined, + }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(findAll(renderer.root, 'EmailOtpForm')[0]?.props.error).toBeUndefined(); + + act(() => { + renderer.unmount(); + }); + }); + + it('renders a wrong-code refusal inline on the code step and clears it on edit', async () => { + mockPostAuth.mockResolvedValue({ + ok: true, + data: { success: true, challengeId: 'c0000000-0000-4000-8000-000000000001' }, + }); + + const renderer = await mountIdleAuth(); + + const field = findAll(renderer.root, 'FormField')[0]; + if (!field) { + throw new Error('FormField not found'); + } + act(() => { + (field.props.onChangeText as (value: string) => void)('wrong@example.com'); + }); + + await act(async () => { + (continueButton(renderer.root).props.onPress as () => void)(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const otpForm = findAll(renderer.root, 'EmailOtpForm')[0]; + if (!otpForm) { + throw new Error('EmailOtpForm not found'); + } + expect(otpForm.props.error).toBeUndefined(); + + mockPostAuth.mockResolvedValue({ + ok: false, + errorCode: 'INVALID_CODE', + ssoOrganizationId: undefined, + }); + await act(async () => { + (otpForm.props.onVerify as (code: string) => void)('000000'); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(findAll(renderer.root, 'EmailOtpForm')[0]?.props.error).toBe( + 'That code is incorrect. Please try again.' + ); + + // Editing the code retires the refusal message. + act(() => { + (otpForm.props.onCodeChange as () => void)(); + }); + expect(findAll(renderer.root, 'EmailOtpForm')[0]?.props.error).toBeUndefined(); + + act(() => { + renderer.unmount(); + }); + }); + + it('signs in when the correct code verifies and stays silent on success', async () => { + mockPostAuth.mockResolvedValue({ + ok: true, + data: { success: true, challengeId: 'c0000000-0000-4000-8000-000000000001' }, + }); + + const renderer = await mountIdleAuth(); + + const field = findAll(renderer.root, 'FormField')[0]; + if (!field) { + throw new Error('FormField not found'); + } + act(() => { + (field.props.onChangeText as (value: string) => void)('correct@example.com'); + }); + + await act(async () => { + (continueButton(renderer.root).props.onPress as () => void)(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const otpForm = findAll(renderer.root, 'EmailOtpForm')[0]; + if (!otpForm) { + throw new Error('EmailOtpForm not found'); + } + + mockPostAuth.mockResolvedValue({ + ok: true, + data: { token: 'at', refreshToken: 'rt', expiresIn: 3600 }, + }); + await act(async () => { + (otpForm.props.onVerify as (code: string) => void)('123456'); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(findAll(renderer.root, 'EmailOtpForm')[0]?.props.error).toBeUndefined(); + + act(() => { + renderer.unmount(); + }); + }); }); diff --git a/apps/mobile/src/components/login/email-otp-form.tsx b/apps/mobile/src/components/login/email-otp-form.tsx index b9a27299154..a1876928d2e 100644 --- a/apps/mobile/src/components/login/email-otp-form.tsx +++ b/apps/mobile/src/components/login/email-otp-form.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; import { formFieldA11y } from '@/components/ui/form-field-a11y'; import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; -import { type useNativeAuth } from '@/lib/auth/use-native-auth'; +import { type BusyAction } from '@/lib/auth/use-native-auth'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { canSubmitEmailCode } from './email-otp-state'; @@ -35,13 +35,19 @@ function formatCodeDestination(message: string, email: string): string { export function EmailOtpForm({ email, busy, + error, onVerify, + onCodeChange, onResend, onBack, }: Readonly<{ email: string; - busy: ReturnType['busy']; + busy: BusyAction; + /** A verify refusal in the active language, rendered above the verify button. */ + error?: string; onVerify: (code: string) => void; + /** Fires on every code-input change so the caller can retire a stale refusal message. */ + onCodeChange?: () => void; onResend: () => void; onBack: () => void; }>) { @@ -83,9 +89,15 @@ export function EmailOtpForm({ onChangeText={value => { codeRef.current = value; setHasCompleteCode(/^\d{6}$/.test(value)); + onCodeChange?.(); }} accessibilityLabel={formFieldA11y({ label: t('login.signInCodeField'), required: true })} /> + {error ? ( + + {error} + + ) : null}