any2human / frontend /src /auth.tsx
idnameraj's picture
Upload 64 files
430efad verified
Raw
History Blame Contribute Delete
6.6 kB
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import type { Session, User } from "@supabase/supabase-js";
import { fetchMe, type AccountInfo } from "./api";
import {
getCachedAuthConfig,
getSupabase,
initSupabase,
type PlanCard,
} from "./supabase";
const ACTIVITY_EVENTS = [
"mousedown",
"mousemove",
"keydown",
"scroll",
"touchstart",
"click",
"visibilitychange",
] as const;
type AuthContextValue = {
ready: boolean;
authEnabled: boolean;
session: Session | null;
user: User | null;
account: AccountInfo | null;
plans: PlanCard[];
guestMaxWords: number;
sessionIdleMinutes: number;
idleSignedOut: boolean;
clearIdleNotice: () => void;
refreshAccount: () => Promise<void>;
setAccount: (account: AccountInfo | null) => void;
signInWithPassword: (email: string, password: string) => Promise<void>;
signUp: (email: string, password: string) => Promise<string>;
signInWithGoogle: () => Promise<void>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
function useIdleSessionLogout(
enabled: boolean,
idleMinutes: number,
hasSession: boolean,
onIdle: () => void,
) {
const timerRef = useRef<number | null>(null);
const onIdleRef = useRef(onIdle);
onIdleRef.current = onIdle;
useEffect(() => {
if (!enabled || !hasSession || idleMinutes <= 0) {
if (timerRef.current != null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
return;
}
const ms = idleMinutes * 60 * 1000;
const bump = () => {
if (document.visibilityState === "hidden") return;
if (timerRef.current != null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
onIdleRef.current();
}, ms);
};
bump();
for (const ev of ACTIVITY_EVENTS) {
window.addEventListener(ev, bump, { passive: true });
}
return () => {
if (timerRef.current != null) window.clearTimeout(timerRef.current);
for (const ev of ACTIVITY_EVENTS) {
window.removeEventListener(ev, bump);
}
};
}, [enabled, hasSession, idleMinutes]);
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [authEnabled, setAuthEnabled] = useState(false);
const [session, setSession] = useState<Session | null>(null);
const [account, setAccount] = useState<AccountInfo | null>(null);
const [plans, setPlans] = useState<PlanCard[]>([]);
const [guestMaxWords, setGuestMaxWords] = useState(100);
const [sessionIdleMinutes, setSessionIdleMinutes] = useState(30);
const [idleSignedOut, setIdleSignedOut] = useState(false);
const refreshAccount = useCallback(async () => {
if (!authEnabled) {
setAccount(null);
return;
}
try {
const me = await fetchMe(session?.access_token);
setAccount(me.account);
} catch {
setAccount(null);
}
}, [authEnabled, session]);
const signOut = useCallback(async () => {
const sb = getSupabase();
if (!sb) return;
await sb.auth.signOut();
setAccount(null);
}, []);
const handleIdleLogout = useCallback(async () => {
setIdleSignedOut(true);
await signOut();
}, [signOut]);
useIdleSessionLogout(
authEnabled,
sessionIdleMinutes,
Boolean(session),
() => {
void handleIdleLogout();
},
);
useEffect(() => {
let unsub: (() => void) | undefined;
void (async () => {
const config = await initSupabase();
setAuthEnabled(config.enabled);
setPlans(config.plans ?? []);
setGuestMaxWords(config.guest?.max_words_per_request ?? 100);
setSessionIdleMinutes(config.session_idle_minutes ?? 30);
const sb = getSupabase();
if (!config.enabled || !sb) {
setReady(true);
return;
}
const { data } = await sb.auth.getSession();
setSession(data.session);
const { data: listener } = sb.auth.onAuthStateChange((_event, next) => {
setSession(next);
if (next) setIdleSignedOut(false);
});
unsub = () => listener.subscription.unsubscribe();
setReady(true);
})();
return () => unsub?.();
}, []);
useEffect(() => {
void refreshAccount();
}, [refreshAccount]);
const value = useMemo<AuthContextValue>(
() => ({
ready,
authEnabled,
session,
user: session?.user ?? null,
account,
plans: plans.length ? plans : getCachedAuthConfig()?.plans ?? [],
guestMaxWords,
sessionIdleMinutes,
idleSignedOut,
clearIdleNotice: () => setIdleSignedOut(false),
refreshAccount,
setAccount,
async signInWithPassword(email, password) {
const sb = getSupabase();
if (!sb) throw new Error("Auth is not configured.");
const { error } = await sb.auth.signInWithPassword({ email, password });
if (error) throw error;
setIdleSignedOut(false);
},
async signUp(email, password) {
const sb = getSupabase();
if (!sb) throw new Error("Auth is not configured.");
const { data, error } = await sb.auth.signUp({ email, password });
if (error) throw error;
setIdleSignedOut(false);
if (data.session) return "signed_in";
return "check_email";
},
async signInWithGoogle() {
const sb = getSupabase();
if (!sb) throw new Error("Auth is not configured.");
const { error } = await sb.auth.signInWithOAuth({
provider: "google",
options: { redirectTo: window.location.origin },
});
if (error) throw error;
},
async signOut() {
setIdleSignedOut(false);
await signOut();
await refreshAccount();
},
}),
[
ready,
authEnabled,
session,
account,
plans,
guestMaxWords,
sessionIdleMinutes,
idleSignedOut,
refreshAccount,
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 within AuthProvider");
return ctx;
}