diff --git a/apps/mobile/src/app/(auth)/login.tsx b/apps/mobile/src/app/(auth)/login.tsx index 1e6c611c89b..9ed21a11db3 100644 --- a/apps/mobile/src/app/(auth)/login.tsx +++ b/apps/mobile/src/app/(auth)/login.tsx @@ -1,5 +1,7 @@ +import { useTranslation } from 'react-i18next'; import { LoginScreen } from '@/components/login-screen'; export default function LoginRoute() { - return ; + const { i18n } = useTranslation(); + return ; } diff --git a/apps/mobile/src/components/login-screen.test.ts b/apps/mobile/src/components/login-screen.test.ts index 62421446af2..c80d6b6df46 100644 --- a/apps/mobile/src/components/login-screen.test.ts +++ b/apps/mobile/src/components/login-screen.test.ts @@ -16,6 +16,7 @@ import { persistLoginDrafts, restoreLoginDrafts, } from '@/lib/login-draft'; +import LoginRoute from '@/app/(auth)/login'; import { LoginScreen } from './login-screen'; import { errorMessage, resolveKeyboardBottomPadding } from './login-screen-state'; @@ -101,7 +102,6 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ foreground: '#111827', mutedForeground: '#6b7280' }), })); vi.mock('@/lib/login-draft', () => ({ - clearLoginDrafts: vi.fn(), clearPersistedLoginDrafts: vi.fn(), persistLoginDrafts: vi.fn().mockResolvedValue(undefined), restoreLoginDrafts: vi.fn().mockResolvedValue(null), @@ -156,6 +156,19 @@ async function mountLoginScreen(): Promise { return renderer; } +async function mountLoginRoute(): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + ref.current = TestRenderer.create(createElement(LoginRoute)); + await Promise.resolve(); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + describe('login-screen refresh boundary', () => { it('passes refreshToken and expiresIn through the approved token response', () => { const result = parseDeviceAuthTokenResponse({ @@ -406,6 +419,65 @@ describe('login-screen language globe', () => { }); }); +describe('login route language refresh', () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + deviceAuth.status = 'idle'; + deviceAuth.token = undefined; + deviceAuth.code = undefined; + deviceAuth.refreshToken = undefined; + deviceAuth.expiresIn = undefined; + deviceAuth.error = undefined; + deviceAuth.verificationUrl = undefined; + deviceAuth.resumed = false; + }); + + it('remounts the sign-in screen and re-labels it in the applied language', async () => { + vi.mocked(restoreLoginDrafts).mockClear(); + vi.mocked(restoreLoginDrafts).mockResolvedValue({ + email: 'typed@example.com', + ssoRecovery: null, + }); + const renderer = await mountLoginRoute(); + const mountsBeforeSwitch = vi.mocked(restoreLoginDrafts).mock.calls.length; + expect(mountsBeforeSwitch).toBeGreaterThan(0); + + await act(async () => { + await i18n.changeLanguage('ja'); + }); + try { + // A fresh mount re-runs the draft restore, so the count is how a remount + // is observable: the route's `useTranslation` re-render alone would keep + // the same screen instance. + expect(vi.mocked(restoreLoginDrafts).mock.calls.length).toBeGreaterThan(mountsBeforeSwitch); + // The fresh mount restores its drafts asynchronously, so flush that + // before reading the revealed form. + await act(async () => { + await Promise.resolve(); + }); + const pressableLabels = renderer.root + .findAll(node => typeof node.type === 'string' && (node.type as string) === 'Pressable') + .map(node => node.props.accessibilityLabel); + expect(pressableLabels).toContain(i18n.t('common.language')); + expect(pressableLabels).not.toContain('Language'); + // The remount keeps the address the person had typed, so a language + // change does not blank the field it re-labels. + const idleAuth = findByType(renderer.root, 'IdleAuth')[0]; + expect(idleAuth?.props.initialEmail).toBe('typed@example.com'); + } finally { + await act(async () => { + await i18n.changeLanguage('en'); + }); + vi.mocked(restoreLoginDrafts).mockResolvedValue({ + email: '', + ssoRecovery: null, + }); + } + + renderer.unmount(); + }); +}); + describe('login-screen device-code actions', () => { beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/apps/mobile/src/components/login-screen.tsx b/apps/mobile/src/components/login-screen.tsx index 0e4c5a0597c..b23017fcf7d 100644 --- a/apps/mobile/src/components/login-screen.tsx +++ b/apps/mobile/src/components/login-screen.tsx @@ -25,7 +25,6 @@ import { useAuth } from '@/lib/auth/auth-context'; import { useDeviceAuth } from '@/lib/auth/use-device-auth'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { - clearLoginDrafts, clearPersistedLoginDrafts, persistLoginDrafts, restoreLoginDrafts, @@ -82,13 +81,11 @@ export function LoginScreen() { // 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. + // one watch covers them all: `signIn` drops the module drafts, and this + // flag keeps the unmount persistence off, or the next login mount would + // pre-fill the previous account's address. useEffect(() => { if (publishedToken !== undefined) { - clearLoginDrafts(); signedInRef.current = true; } }, [publishedToken]); diff --git a/apps/mobile/src/lib/auth/auth-context.lifecycle.test.tsx b/apps/mobile/src/lib/auth/auth-context.lifecycle.test.tsx index c450fe9c840..357ba2453a4 100644 --- a/apps/mobile/src/lib/auth/auth-context.lifecycle.test.tsx +++ b/apps/mobile/src/lib/auth/auth-context.lifecycle.test.tsx @@ -465,6 +465,8 @@ vi.mock('@/lib/storage-keys', () => ({ KEEP_SCREEN_ON_KEY: 'keep-session-screen-on', KILOCLAW_OWNED_KEY: 'kiloclaw-owned', LEGACY_EXCHANGE_DONE_KEY: 'legacy-exchange-done', + LOGIN_EMAIL_DRAFT_KEY: 'login-email-draft', + LOGIN_SSO_RECOVERY_DRAFT_KEY: 'login-sso-recovery-draft', NOTIFICATION_PROMPT_SEEN_KEY: 'notification-prompt-seen', ORGANIZATION_STORAGE_KEY: 'organization', ORGANIZATION_PERSONAL_STORAGE_KEY: 'selected-organization-personal', @@ -816,6 +818,46 @@ describe('sign-out teardown ordering', () => { unmount(); }); + it('retires the sign-in form drafts once a sign-in publishes its token', async () => { + const { ctx, unmount } = await mountAndGetContext(); + // The mount loads auth-context from a fresh module registry, so the drafts + // module the provider closes over is the one imported after it. + const drafts = await import('@/lib/login-draft'); + drafts.setLoginEmailDraft('typed@example.com'); + drafts.setSsoRecoveryDraft({ email: 'typed@example.com', ssoOrganizationId: 'org-1' }); + + await act(async () => { + await ctx.signIn('signed-in-token'); + }); + + // Every provider ends in signIn, so a published token is the one place + // that may drop what the form was holding: the next signed-out screen + // must not pre-fill the previous attempt's address or SSO banner. + await expect(drafts.restoreLoginDrafts()).resolves.toEqual({ email: '', ssoRecovery: null }); + + unmount(); + }); + + it('keeps the sign-in form drafts when the credential write fails', async () => { + const { ctx, unmount } = await mountAndGetContext(); + const drafts = await import('@/lib/login-draft'); + drafts.setLoginEmailDraft('typed@example.com'); + drafts.setSsoRecoveryDraft({ email: 'typed@example.com', ssoOrganizationId: 'org-1' }); + hoisted.secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable')); + + await act(async () => { + await expect(ctx.signIn('signed-in-token')).rejects.toThrow('keychain unavailable'); + }); + + // A failed attempt published nothing, so the person keeps what they typed. + await expect(drafts.restoreLoginDrafts()).resolves.toEqual({ + email: 'typed@example.com', + ssoRecovery: { email: 'typed@example.com', ssoOrganizationId: 'org-1' }, + }); + + unmount(); + }); + it('unregisters the prior account activity tokens on sign-in (account switch)', async () => { const { ctx, unmount } = await mountAndGetContext(); diff --git a/apps/mobile/src/lib/auth/auth-context.tsx b/apps/mobile/src/lib/auth/auth-context.tsx index c3b293b56f7..1159d270f54 100644 --- a/apps/mobile/src/lib/auth/auth-context.tsx +++ b/apps/mobile/src/lib/auth/auth-context.tsx @@ -71,6 +71,7 @@ import { clearKiloClawOwned, gateKiloClawOwned } from '@/lib/kiloclaw-tab-owners import { clearLastActiveInstance } from '@/lib/last-active-instance'; import { clearLastRoute } from '@/lib/last-route'; import { clearLastOpenedSession } from '@/lib/last-opened-session'; +import { clearLoginDrafts } from '@/lib/login-draft'; import { clearLauncherSurfaces } from '@/lib/native-launcher-surfaces'; import { resetPurchaseErrorToastDedup } from '@/lib/credits/use-store-credit-purchase'; import { @@ -467,6 +468,10 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { // A direct account switch must not keep the prior account's session // state: trusted hosts, image confirms, media caches, temp copies. clearSessionScopedState(); + // The sign-in form's drafts belong to the attempt that just succeeded, + // whatever provider finished it; a later signed-out screen must not + // prefill the previous attempt's address or SSO banner. + clearLoginDrafts(); }); }, [] diff --git a/apps/mobile/src/lib/login-draft.test.ts b/apps/mobile/src/lib/login-draft.test.ts new file mode 100644 index 00000000000..80c6493b691 --- /dev/null +++ b/apps/mobile/src/lib/login-draft.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + clearLoginDrafts, + restoreLoginDrafts, + setLoginEmailDraft, + setSsoRecoveryDraft, +} from './login-draft'; + +const stored = vi.hoisted(() => ({ + email: null as string | null, + sso: null as string | null, +})); + +vi.mock('@/lib/auth/secure-store', () => ({ + getItemAsync: vi.fn((key: string) => + Promise.resolve(key.includes('sso') ? stored.sso : stored.email) + ), + setItemAsync: vi.fn(() => Promise.resolve()), + deleteItemAsync: vi.fn(() => Promise.resolve()), +})); + +describe('login drafts across a remount', () => { + beforeEach(() => { + stored.email = null; + stored.sso = null; + clearLoginDrafts(); + }); + + it('keeps the drafts the form is holding in memory', async () => { + setLoginEmailDraft('typed@example.com'); + setSsoRecoveryDraft({ email: 'typed@example.com', ssoOrganizationId: 'org-1' }); + stored.email = 'older@example.com'; + + await expect(restoreLoginDrafts()).resolves.toEqual({ + email: 'typed@example.com', + ssoRecovery: { email: 'typed@example.com', ssoOrganizationId: 'org-1' }, + }); + }); + + it('falls back to the stored drafts when the form holds none', async () => { + stored.email = 'stored@example.com'; + stored.sso = JSON.stringify({ email: 'stored@example.com', ssoOrganizationId: 'org-2' }); + + await expect(restoreLoginDrafts()).resolves.toEqual({ + email: 'stored@example.com', + ssoRecovery: { email: 'stored@example.com', ssoOrganizationId: 'org-2' }, + }); + }); + + it('ignores a corrupt stored draft instead of failing the mount', async () => { + stored.sso = '{not json'; + + await expect(restoreLoginDrafts()).resolves.toEqual({ email: '', ssoRecovery: null }); + }); +}); diff --git a/apps/mobile/src/lib/login-draft.ts b/apps/mobile/src/lib/login-draft.ts index 86b56fa7d94..15b48a58012 100644 --- a/apps/mobile/src/lib/login-draft.ts +++ b/apps/mobile/src/lib/login-draft.ts @@ -33,9 +33,11 @@ export async function persistLoginDrafts(): Promise { } } -/** Read the persisted drafts on login mount. The caller deletes only after it - * applies the restored values, because a cancelled mount must not lose the - * drafts. */ +/** Read the persisted drafts on login mount. A draft the form is holding in + * memory wins over the stored copy: a remount inside the same app process, such + * as the one a language change causes, must not drop what the person already + * typed. The caller deletes the stored copies only after it applies the + * restored values, because a cancelled mount must not lose them. */ export async function restoreLoginDrafts(): Promise<{ email: string; ssoRecovery: SsoRecoveryDraft | null; @@ -59,7 +61,8 @@ export async function restoreLoginDrafts(): Promise<{ // Corrupt draft: ignore it. } } - return { email: email ?? '', ssoRecovery }; + const restoredEmail = emailDraft.length > 0 ? emailDraft : (email ?? ''); + return { email: restoredEmail, ssoRecovery: ssoRecoveryDraft ?? ssoRecovery }; } /** Delete the persisted drafts after the caller applies the restored values. */