Files
ChatApp/apps/mobile/lib/authContext.tsx
T
byGalax b1cdc4d184 chore(mobile): phase 1 quality-review fixups
Three small follow-ups from the post-Phase-1 quality review:

  * authContext.tsx — drop the dead `userId` extraction + the `void
    userId` suppressor that masked an unused-locals warning. The session
    is already implicitly threaded through the supabase client, so no
    consumer of ensureDevice needed the value.
  * authContext.tsx — switch the device-name string concat to a
    template literal for consistency with the rest of the codebase.
  * ErrorBoundary.tsx — replace the four inline hex literals with their
    `theme/colors.ts` constants. The boundary was authored in Phase 0
    before the theme module existed; this brings it in line with every
    Phase 1 screen.
  * apps/mobile/README.md — drop the stale Phase-0 paragraph about the
    `lib/sharedSmoke.ts` canary (deleted in Phase 1) and add a short
    pointer to the env-var setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:08:22 +02:00

123 lines
4.0 KiB
TypeScript

import type { Session, User } from '@supabase/supabase-js';
import { auth, crypto } from '@chat-app/shared';
import type { DeviceRecord } from '@chat-app/shared/auth';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { Platform } from 'react-native';
import { secretStore } from './secretStore';
import { supabase } from './supabase';
// Locally-stored secrets keyed by stable names. Mirrors the desktop
// convention so the migration tests (later) can compare snapshots.
const KEY_DEVICE_ID = 'device.id';
const KEY_DEVICE_PRIVKEY = 'device.privateKey';
interface AuthContextValue {
session: Session | null;
user: User | null;
device: DeviceRecord | null;
ownPrivateKey: Uint8Array | null;
loading: boolean;
signOut: () => Promise<void>;
}
const Ctx = createContext<AuthContextValue | null>(null);
export function useAuth(): AuthContextValue {
const v = useContext(Ctx);
if (!v) throw new Error('useAuth() called outside <AuthProvider>');
return v;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [device, setDevice] = useState<DeviceRecord | null>(null);
const [ownPrivateKey, setOwnPrivateKey] = useState<Uint8Array | null>(null);
const [loading, setLoading] = useState(true);
// Resolve or create the device record for this install given an active
// session. Stores the private key in expo-secure-store on first run.
const ensureDevice = useCallback(async (_currentSession: Session): Promise<void> => {
const savedDeviceId = await secretStore.getSecret(KEY_DEVICE_ID);
const savedPrivKey = await secretStore.getSecret(KEY_DEVICE_PRIVKEY);
if (savedDeviceId && savedPrivKey) {
const devices = await auth.listOwnDevices(supabase);
const deviceIdStr = new TextDecoder().decode(savedDeviceId);
const match = devices.find((d) => d.id === deviceIdStr);
if (match) {
setDevice(match);
setOwnPrivateKey(savedPrivKey);
return;
}
// Stored id no longer matches any device on the server (revoked,
// wiped). Fall through to register a fresh one.
}
const backend = crypto.getCryptoBackend();
const kp = backend.generateKeyPair();
const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'linux';
const record = await auth.registerDevice(supabase, {
name: `Netralax Mobile (${Platform.OS})`,
platform,
publicKey: kp.publicKey,
});
await secretStore.setSecret(KEY_DEVICE_ID, new TextEncoder().encode(record.id));
await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey);
setDevice(record);
setOwnPrivateKey(kp.privateKey);
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
const { data } = await supabase.auth.getSession();
if (cancelled) return;
setSession(data.session);
if (data.session) {
try {
await ensureDevice(data.session);
} catch (err) {
console.warn('[auth] ensureDevice failed', err);
}
}
setLoading(false);
})();
const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession);
if (!nextSession) {
setDevice(null);
setOwnPrivateKey(null);
} else {
void ensureDevice(nextSession).catch((err) =>
console.warn('[auth] ensureDevice (state change) failed', err),
);
}
});
return () => {
cancelled = true;
sub.subscription.unsubscribe();
};
}, [ensureDevice]);
const signOut = useCallback(async (): Promise<void> => {
await supabase.auth.signOut();
await secretStore.removeSecret(KEY_DEVICE_ID);
await secretStore.removeSecret(KEY_DEVICE_PRIVKEY);
setDevice(null);
setOwnPrivateKey(null);
}, []);
const value: AuthContextValue = {
session,
user: session?.user ?? null,
device,
ownPrivateKey,
loading,
signOut,
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}