130 lines
3.6 KiB
TypeScript
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>;
|
|
}
|