Files

130 lines
3.6 KiB
TypeScript

import type { Session, User } from '@supabase/supabase-js';
import { fetchUserKeyBlob } from '@chat-app/shared/auth';
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { supabase } from './supabase';
import { cachedUserKey, ensureLegacyMigrated } from './userIdentity';
export type UserKeyState =
| { status: 'loading' }
| { status: 'needs-setup' }
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
| { status: 'unlocked' };
interface AuthContextValue {
session: Session | null;
user: User | null;
userId: string | null;
ownPrivateKey: Uint8Array | null;
userKeyState: UserKeyState;
ready: boolean;
refreshUserKeyState: () => Promise<void>;
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: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [ownPrivateKey, setOwnPrivateKey] = useState<Uint8Array | null>(null);
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
const [ready, setReady] = useState(false);
const refreshUserKeyState = useCallback(async () => {
const s = session;
if (!s) {
setUserKeyState({ status: 'loading' });
setOwnPrivateKey(null);
return;
}
setUserKeyState({ status: 'loading' });
const cached = await cachedUserKey(s.user.id);
if (cached) {
setOwnPrivateKey(cached);
setUserKeyState({ status: 'unlocked' });
void ensureLegacyMigrated(s.user.id).catch((err) => {
console.warn('legacy migration on auth-resume failed', err);
});
return;
}
const blob = await fetchUserKeyBlob(supabase, s.user.id);
if (!blob || !blob.exists) {
setUserKeyState({ status: 'needs-setup' });
return;
}
if (blob.locked) {
setUserKeyState({
status: 'needs-unlock',
lockedUntil: blob.lockedUntil,
hasRecovery: false,
});
return;
}
setUserKeyState({
status: 'needs-unlock',
lockedUntil: null,
hasRecovery: blob.recoverySealedPrivateKey !== null,
});
}, [session]);
useEffect(() => {
let cancelled = false;
void (async () => {
const { data } = await supabase.auth.getSession();
if (cancelled) return;
setSession(data.session);
setReady(true);
})();
const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession);
setReady(true);
if (!nextSession) {
setOwnPrivateKey(null);
setUserKeyState({ status: 'loading' });
}
});
return () => {
cancelled = true;
sub.subscription.unsubscribe();
};
}, []);
useEffect(() => {
void refreshUserKeyState().catch((err) => {
console.warn('refreshUserKeyState failed', err);
setUserKeyState({ status: 'needs-setup' });
});
}, [session, refreshUserKeyState]);
const signOut = useCallback(async () => {
await supabase.auth.signOut();
setOwnPrivateKey(null);
setUserKeyState({ status: 'loading' });
}, []);
const value: AuthContextValue = {
session,
user: session?.user ?? null,
userId: session?.user.id ?? null,
ownPrivateKey,
userKeyState,
ready,
refreshUserKeyState,
signOut,
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}