Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/mobile/src/app/(auth)/login.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next';
import { LoginScreen } from '@/components/login-screen';

export default function LoginRoute() {
return <LoginScreen />;
const { i18n } = useTranslation();
return <LoginScreen key={i18n.language} />;
Comment thread
iscekic marked this conversation as resolved.
}
74 changes: 73 additions & 1 deletion apps/mobile/src/components/login-screen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -156,6 +156,19 @@ async function mountLoginScreen(): Promise<TestRenderer.ReactTestRenderer> {
return renderer;
}

async function mountLoginRoute(): Promise<TestRenderer.ReactTestRenderer> {
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({
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 3 additions & 6 deletions apps/mobile/src/components/login-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
Expand Down
42 changes: 42 additions & 0 deletions apps/mobile/src/lib/auth/auth-context.lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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();

Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/lib/auth/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
});
},
[]
Expand Down
56 changes: 56 additions & 0 deletions apps/mobile/src/lib/login-draft.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
11 changes: 7 additions & 4 deletions apps/mobile/src/lib/login-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ export async function persistLoginDrafts(): Promise<void> {
}
}

/** 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;
Expand All @@ -59,7 +61,8 @@ export async function restoreLoginDrafts(): Promise<{
// Corrupt draft: ignore it.
}
}
return { email: email ?? '', ssoRecovery };
const restoredEmail = emailDraft.length > 0 ? emailDraft : (email ?? '');
Comment thread
iscekic marked this conversation as resolved.
return { email: restoredEmail, ssoRecovery: ssoRecoveryDraft ?? ssoRecovery };
}

/** Delete the persisted drafts after the caller applies the restored values. */
Expand Down
Loading