initial
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user