From 35e696f0b997d6d14363e405ebc125dc71c5f6e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:42:33 +0000 Subject: [PATCH 1/9] fix(mobile): keep the login email across rotation and show code-step feedback The login screen now writes the module-level drafts back to SecureStore when it unmounts, so a rotation that recreates the activity restores the typed email instead of blanking the field. Email-code verification returns a result object instead of toasting: a wrong, spent, or contested code renders its localized message inline above the verify button, cleared by editing the code, resending, going back, or requesting a new code. --- .../src/components/login-screen.test.ts | 19 +++- apps/mobile/src/components/login-screen.tsx | 12 +++ .../idle-auth-email-flow.mounted.test.tsx | 102 ++++++++++++++++++ .../src/components/login/email-otp-form.tsx | 16 ++- .../src/components/login/email-otp-state.ts | 7 +- .../mobile/src/components/login/idle-auth.tsx | 16 ++- .../src/lib/auth/use-native-auth.test.ts | 5 +- apps/mobile/src/lib/auth/use-native-auth.ts | 31 +++--- 8 files changed, 183 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/components/login-screen.test.ts b/apps/mobile/src/components/login-screen.test.ts index 40572713369..c0d92508391 100644 --- a/apps/mobile/src/components/login-screen.test.ts +++ b/apps/mobile/src/components/login-screen.test.ts @@ -95,7 +95,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', () => ({ @@ -512,6 +512,7 @@ describe('login-screen idle skeleton', () => { deviceAuth.resumed = false; 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 +564,22 @@ 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); + }); }); 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..d317608de07 100644 --- a/apps/mobile/src/components/login-screen.tsx +++ b/apps/mobile/src/components/login-screen.tsx @@ -90,6 +90,18 @@ 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. + 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..50ad5417f56 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,106 @@ describe('IdleAuth email sign-in flow (real useNativeAuth)', () => { 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}