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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
getItemAsync: vi.fn<() => Promise<string | null>>(),
getItemAsync: vi.fn<(key: string) => Promise<string | null>>(),
getTokenQuery: vi.fn<() => Promise<{ token: string; userId: string; expiresAt: string }>>(),
}));

Expand All @@ -13,8 +13,11 @@ vi.mock('expo-secure-store', () => ({
getItemAsync: mocks.getItemAsync,
}));

vi.mock('@/lib/config', () => ({ E2E_SECURE_STORE_FAULT_MS: 0 }));

vi.mock('@/lib/storage-keys', () => ({
AUTH_TOKEN_KEY: 'auth-token',
NATIVE_CREDENTIAL_BUNDLE_KEY: 'native-credential-bundle',
}));

vi.mock('@/lib/trpc', () => ({
Expand All @@ -30,6 +33,8 @@ vi.mock('@/lib/trpc', () => ({
describe('useKiloChatTokenResponseGetter', () => {
beforeEach(async () => {
vi.clearAllMocks();
const { clearActiveToken } = await import('@/lib/auth/token-owner');
clearActiveToken();
const { clearKiloChatTokenCache } = await import('./use-kilo-chat-token');
clearKiloChatTokenCache();
});
Expand All @@ -42,7 +47,10 @@ describe('useKiloChatTokenResponseGetter', () => {
};
const seenUserIds: string[] = [];

mocks.getItemAsync.mockResolvedValue('auth-token-1');
mocks.getItemAsync.mockImplementation(async key => {
await Promise.resolve();
return key === 'auth-token' ? 'auth-token-1' : null;
});
mocks.getTokenQuery.mockRejectedValueOnce(new Error('network down'));
mocks.getTokenQuery.mockResolvedValueOnce(response);

Expand All @@ -69,7 +77,10 @@ describe('useKiloChatTokenResponseGetter', () => {
expiresAt: '2099-03-13 14:30:00+00',
};

mocks.getItemAsync.mockResolvedValue('auth-token-2');
mocks.getItemAsync.mockImplementation(async key => {
await Promise.resolve();
return key === 'auth-token' ? 'auth-token-2' : null;
});
mocks.getTokenQuery.mockResolvedValueOnce(response);

const { useKiloChatTokenResponseGetter } = await import('./use-kilo-chat-token');
Expand Down Expand Up @@ -98,7 +109,10 @@ describe('useKiloChatTokenResponseGetter', () => {
expiresAt: '2099-03-13 14:30:00+00',
};

mocks.getItemAsync.mockResolvedValue('auth-token-3');
mocks.getItemAsync.mockImplementation(async key => {
await Promise.resolve();
return key === 'auth-token' ? 'auth-token-3' : null;
});
mocks.getTokenQuery.mockResolvedValueOnce(firstResponse);
mocks.getTokenQuery.mockResolvedValueOnce(secondResponse);

Expand Down
16 changes: 4 additions & 12 deletions apps/mobile/src/components/login-screen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ import { errorMessage } from './login-screen-state';

const deviceAuth = vi.hoisted(() => ({
status: 'idle' as string,
token: undefined as string | undefined,
code: undefined as string | undefined,
refreshToken: undefined as string | undefined,
expiresIn: undefined as number | undefined,
credentials: undefined,
error: undefined as string | undefined,
verificationUrl: undefined as string | undefined,
resumed: false,
Expand Down Expand Up @@ -74,10 +72,8 @@ vi.mock('@/lib/auth/auth-context', () => ({
vi.mock('@/lib/auth/use-device-auth', () => ({
useDeviceAuth: () => ({
status: deviceAuth.status,
token: deviceAuth.token,
code: deviceAuth.code,
refreshToken: deviceAuth.refreshToken,
expiresIn: deviceAuth.expiresIn,
credentials: deviceAuth.credentials,
error: deviceAuth.error,
verificationUrl: deviceAuth.verificationUrl,
resumed: deviceAuth.resumed,
Expand Down Expand Up @@ -270,10 +266,8 @@ describe('login-screen language globe', () => {
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.credentials = undefined;
deviceAuth.error = undefined;
deviceAuth.verificationUrl = undefined;
deviceAuth.resumed = false;
Expand Down Expand Up @@ -350,10 +344,8 @@ describe('login-screen idle skeleton', () => {
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.credentials = undefined;
deviceAuth.error = undefined;
deviceAuth.verificationUrl = undefined;
deviceAuth.resumed = false;
Expand Down
58 changes: 14 additions & 44 deletions apps/mobile/src/components/login-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
/* eslint-disable max-lines -- The login screen keeps its device-auth branches, keyboard padding, and language picker together. */
import * as Clipboard from 'expo-clipboard';
import { type Href, useRouter } from 'expo-router';
import { ExternalLink, Globe } from '@/components/ui/icons';
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AppState,
I18nManager,
Keyboard,
KeyboardAvoidingView,
type KeyboardEvent,
Platform,
Pressable,
ScrollView,
Expand All @@ -33,60 +31,38 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { announcingToast } from '@/lib/a11y/announcing-toast';
import { useAuth } from '@/lib/auth/auth-context';
import { useDeviceApprovalPersistence } from '@/lib/auth/use-device-approval-persistence';
import { useDeviceAuth } from '@/lib/auth/use-device-auth';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import {
clearLoginDrafts,
clearPersistedLoginDrafts,
persistLoginDrafts,
restoreLoginDrafts,
type SsoRecoveryDraft,
} from '@/lib/login-draft';
import { setLanguagePickerBridge } from '@/lib/picker-bridge';

function keyboardHeightFromEvent(event: KeyboardEvent): number {
return event.endCoordinates.height;
}

export function LoginScreen() {
const { sessionEnded, signIn } = useAuth();
const router = useRouter();
const {
status,
token,
code,
refreshToken,
expiresIn,
error,
verificationUrl,
resumed,
start,
cancel,
openBrowser,
} = useDeviceAuth();
const { status, code, credentials, error, verificationUrl, resumed, start, cancel, openBrowser } =
useDeviceAuth();
const colors = useThemeColors();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [persistError, setPersistError] = useState<string | undefined>(undefined);
const [androidKeyboardHeight, setAndroidKeyboardHeight] = useState(0);
const [authFormBusy, setAuthFormBusy] = useState(false);
const [draft, setDraft] = useState<{
email: string;
ssoRecovery: SsoRecoveryDraft | null;
} | null>(null);

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]
);
const { persistError, isPersisting, persistToken } = useDeviceApprovalPersistence({
status,
credentials,
signIn,
couldNotCompleteSignIn: t('login.couldNotCompleteSignIn'),
});

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -116,13 +92,6 @@ export function LoginScreen() {
}
}, [sessionEnded, t]);

useEffect(() => {
if (status === 'approved' && token) {
void persistToken(token, refreshToken, expiresIn);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- persistToken is stable except for signIn identity; only re-run on a newly approved token
}, [status, token]);

// Android shell keyboard pad: under API 35+ EDGE_TO_EDGE_ENFORCED the window
// never resizes for the IME, so KeyboardAvoidingView is inert. keyboardDidShow
// still fires with real heights; consume them here (r0b: zero layout shift for
Expand All @@ -144,7 +113,7 @@ export function LoginScreen() {
currentPadding: current,
event: {
type: 'keyboard-visible',
keyboardHeight: keyboardHeightFromEvent(event),
keyboardHeight: event.endCoordinates.height,
},
})
);
Expand Down Expand Up @@ -181,11 +150,12 @@ export function LoginScreen() {
<Text className="text-center text-sm text-destructive">{persistError}</Text>
<Button
onPress={() => {
if (token) {
void persistToken(token, refreshToken, expiresIn);
if (credentials) {
void persistToken(credentials);
}
}}
accessibilityLabel={t('login.retrySignIn')}
disabled={isPersisting}
>
<Text>{t('common.retry')}</Text>
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,8 @@ vi.mock('@/lib/utils', () => ({
vi.mock('@/lib/auth/auth-context', () => ({
useAuth: () => ({ authEpoch: authEpoch.value, token: 'token' }),
}));
vi.mock('@/lib/auth/token-owner', () => ({
getAuthTokenForRequest: () => 'token-1',
}));
vi.mock('@/lib/auth/token-owner', () => ({}));
vi.mock('@/lib/auth/credentials', () => ({ getGatewayAuthTokenForRequest: () => 'token-1' }));
vi.mock('@/lib/organization-context', () => ({
useOrganization: () => ({
organizationId: organizationId.value,
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/components/quick-chat/use-quick-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ulid } from 'ulid';

import { i18n } from '@/i18n';
import { useAuth } from '@/lib/auth/auth-context';
import { getAuthTokenForRequest } from '@/lib/auth/token-owner';
import { getGatewayAuthTokenForRequest } from '@/lib/auth/credentials';
import { useOrganization } from '@/lib/organization-context';
import { trpcClient, useTRPC } from '@/lib/trpc';

Expand Down Expand Up @@ -266,7 +266,7 @@ export function useQuickChat(model: string) {

void (async () => {
try {
const authToken = await getAuthTokenForRequest();
const authToken = await getGatewayAuthTokenForRequest();
if (abortRef.current !== controller) {
return;
}
Expand Down
Loading
Loading