This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
import {
type DeviceRecord,
getOwnProfile,
signOut as supabaseSignOut,
type Profile,
} from '@chat-app/shared/auth';
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
import type { Session } from '@supabase/supabase-js';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { findExistingDevice } from '../lib/device';
import { supabase } from '../lib/supabase';
interface AuthContextValue {
session: Session | null;
profile: Profile | null;
device: DeviceRecord | null;
// null while we're still resolving the very first auth state.
ready: boolean;
// null until a device lookup has finished for the current session.
deviceLookupDone: boolean;
refreshProfile: () => Promise<void>;
refreshDevice: () => Promise<void>;
setDevice: (device: DeviceRecord | null) => void;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const { i18n } = useTranslation();
const [session, setSession] = useState<Session | null>(null);
const [ready, setReady] = useState(false);
const [profile, setProfile] = useState<Profile | null>(null);
const [device, setDevice] = useState<DeviceRecord | null>(null);
const [deviceLookupDone, setDeviceLookupDone] = useState(false);
// Initial session + auth subscription. We verify the cached JWT against the
// server (via getUser) once on mount. Only purge the session on an
// unambiguous 401/403 — a network failure (Supabase stack offline) must not
// log the user out, otherwise every local `supabase stop` wipes their session.
useEffect(() => {
let cancelled = false;
(async () => {
const { data: sessionRes } = await supabase.auth.getSession();
if (cancelled) return;
if (sessionRes.session) {
const { error } = await supabase.auth.getUser();
if (cancelled) return;
if (error) {
const status = (error as { status?: number }).status;
if (status === 401 || status === 403) {
// Token genuinely invalid — wipe.
await supabase.auth.signOut().catch(() => {
/* ignore */
});
setSession(null);
} else {
// Network / server unreachable — keep cached session, let reads
// fail gracefully and recover when the stack is back.
console.warn('auth.getUser failed, keeping cached session:', error);
setSession(sessionRes.session);
}
} else {
setSession(sessionRes.session);
}
} else {
setSession(null);
}
setReady(true);
})();
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s);
setReady(true);
});
return () => {
cancelled = true;
sub.subscription.unsubscribe();
};
}, []);
const refreshProfile = useCallback(async () => {
if (!session) {
setProfile(null);
return;
}
const p = await getOwnProfile(supabase);
setProfile(p);
if (p && p.locale !== i18n.resolvedLanguage && isSupportedLocale(p.locale)) {
void changeLocale(p.locale);
}
}, [session, i18n.resolvedLanguage]);
const refreshDevice = useCallback(async () => {
if (!session) {
setDevice(null);
setDeviceLookupDone(false);
return;
}
setDeviceLookupDone(false);
const found = await findExistingDevice(session.user.id);
setDevice(found);
setDeviceLookupDone(true);
}, [session]);
// Re-pull profile + device whenever session flips.
useEffect(() => {
if (!session) {
setProfile(null);
setDevice(null);
setDeviceLookupDone(false);
return;
}
void refreshProfile().catch((err: unknown) => {
console.error('refreshProfile failed', err);
});
void refreshDevice().catch((err: unknown) => {
console.error('refreshDevice failed', err);
setDeviceLookupDone(true);
});
}, [session, refreshProfile, refreshDevice]);
const signOut = useCallback(async () => {
await supabaseSignOut(supabase);
}, []);
const value = useMemo<AuthContextValue>(
() => ({
session,
profile,
device,
ready,
deviceLookupDone,
refreshProfile,
refreshDevice,
setDevice,
signOut,
}),
[session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
}