Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c9f48ca
feat(auth): extract remaining token families
pandemicsyn Sep 8, 2026
e453510
docs(auth): clarify stacked rollout and issuance defaults
pandemicsyn Sep 8, 2026
d20fb1d
refactor(auth): remove unused additions from token rollout
pandemicsyn Sep 8, 2026
7351ece
fix(gastown): retain admin access to unmigrated towns
pandemicsyn Sep 9, 2026
4ccf1ff
fix(gastown): harden runtime reauthorization and legacy renewal
pandemicsyn Sep 9, 2026
f619a46
Merge main into remaining token families
pandemicsyn Sep 10, 2026
0fb74be
Merge current main into remaining token families
pandemicsyn Sep 14, 2026
c254a98
Merge main before final token-family compatibility review
pandemicsyn Sep 14, 2026
16d32fa
fix(auth): preserve mobile behavior with native adoption disabled
pandemicsyn Sep 14, 2026
2c2a55e
fix(auth): enforce remaining-family consumer contracts
pandemicsyn Sep 14, 2026
45ccd5b
fix(gastown): preserve legacy peppers and fence runtime renewal
pandemicsyn Sep 14, 2026
edde179
docs(auth): clarify remaining-family rollout and consumer coverage
pandemicsyn Sep 14, 2026
0161d1c
refactor(auth): extract Gastown and webhook issuance into independent…
pandemicsyn Sep 14, 2026
e64d1a5
refactor(auth): extract Security Auto Analysis into its own PR
pandemicsyn Sep 14, 2026
c159f0f
refactor(auth): extract explicit resource delegation from mobile PR
pandemicsyn Sep 14, 2026
c8c4f97
refactor(auth): extract benchmark and move Wasteland to Gastown PR
pandemicsyn Sep 14, 2026
6c3e4f2
Merge main and migrate device approval test to mounted renderer
pandemicsyn Sep 14, 2026
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 @@ -22,10 +22,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 @@ -73,10 +71,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 @@ -269,10 +265,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 @@ -349,10 +343,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 @@ -115,9 +115,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