Skip to content
Open
80 changes: 77 additions & 3 deletions apps/mobile/src/components/login-screen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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: () => ({
Expand All @@ -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', () => ({
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
39 changes: 36 additions & 3 deletions apps/mobile/src/components/login-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -59,19 +59,36 @@ 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'));
}
},
[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) {
Comment thread
iscekic marked this conversation as resolved.
clearLoginDrafts();
signedInRef.current = true;
}
}, [publishedToken]);

useEffect(() => {
let cancelled = false;
const restoreDrafts = async () => {
Expand All @@ -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();
Comment thread
iscekic marked this conversation as resolved.
} catch {
// Swallow: nothing to show on an unmounting screen.
}
})();
};
}, []);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>();
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();
});
});
});
Loading
Loading