import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { AUTH_EXPIRED_EVENT, ApiError, api, authApi, type MeResponse, } from "./api"; export type Role = "learner" | "teacher" | "admin"; export type AccountStatus = "pending" | "approved" | "suspended"; export type DesignRole = "learner" | "instructor" | "admin"; export type AuthRestoreState = "loading" | "retrying" | "ready" | "failed"; export interface AuthUser { userId: string; email: string; name: string; role: Role; adminAccess: boolean; superAdmin: boolean; accountStatus: AccountStatus; approvalRequired: boolean; cohortIds: string[]; consentAt: number | null; onboardingCompletedAt: number | null; nickname: string; selfIntroduction: string; avatarUrl: string; } export interface AuthContextValue { user: AuthUser | null; role: Role | null; loading: boolean; restoreState: AuthRestoreState; retryRestore: () => Promise; login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise; logout: () => Promise; refresh: () => Promise; acceptConsent: () => Promise; withdrawConsent: () => Promise; } export function designRoleOf(role: Role): DesignRole { return role === "teacher" ? "instructor" : role; } export function roleLabel(role: Role): string { switch (role) { case "learner": return "학습자 공간"; case "teacher": return "교수 콘솔"; case "admin": return "운영 콘솔"; } } export function roleHomePath(role: Role): string { switch (role) { case "learner": return "/learn"; case "teacher": return "/teach"; case "admin": return "/admin"; } } export function canAccessRole(user: AuthUser, role: Role): boolean { if (user.role === role) return true; if (user.superAdmin) return true; if (user.role === "admin") return true; return role === "admin" && user.adminAccess; } export function initialPathForUser(user: AuthUser): string { if (user.accountStatus !== "approved") return "/pending"; if (user.onboardingCompletedAt == null) { return canAccessRole(user, "admin") ? "/admin" : "/onboarding"; } return canAccessRole(user, "admin") ? "/admin" : roleHomePath(user.role); } export function accessibleRolesFor(user: AuthUser): Role[] { if (user.superAdmin || user.role === "admin") return ["learner", "teacher", "admin"]; if (user.adminAccess) return ["admin"]; return []; } const DEV_EMAIL_BY_ROLE: Record = { learner: "learner@hs.ac.kr", teacher: "teacher@hs.ac.kr", admin: "admin@twentyoz.kr", }; const DEV_NAME_BY_ROLE: Record = { learner: "학습자", teacher: "교수자", admin: "관리자", }; const AuthContext = createContext(null); const AUTH_RESTORE_TIMEOUT_MS = 5_000; const AUTH_RESTORE_RETRY_DELAYS_MS = [0, 400, 1_000] as const; function wait(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function isSignedOut(error: unknown) { return error instanceof ApiError && error.status === 401; } /** * 재부팅 직후 API가 아직 올라오는 중이어도 세션을 곧바로 버리지 않는다. * 각 요청은 유한 시간 안에 끝내고, 짧은 재시도 뒤에도 연결되지 않을 때만 * 명시적인 복구 화면으로 넘긴다. */ async function restoreCurrentUser(onRetry: () => void): Promise { let lastError: unknown = new Error("auth restore failed"); for (let attempt = 0; attempt < AUTH_RESTORE_RETRY_DELAYS_MS.length; attempt += 1) { const delayMs = AUTH_RESTORE_RETRY_DELAYS_MS[attempt]; if (delayMs > 0) { onRetry(); await wait(delayMs); } const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), AUTH_RESTORE_TIMEOUT_MS); try { return await api.get("/auth/me", { signal: controller.signal }); } catch (error) { lastError = error; if (isSignedOut(error)) throw error; } finally { window.clearTimeout(timeout); } } throw lastError; } function userFromMe(me: MeResponse): AuthUser { const onboarding = me as MeResponse & { onboarding_completed_at?: number | null }; const nickname = (me.nickname ?? "").trim(); const avatarUrl = (me.avatar_url ?? "").trim(); return { userId: me.user_id, email: me.email, name: nickname || me.display_name || me.email || me.user_id, role: (me.role as Role) ?? "learner", adminAccess: Boolean(me.admin_access), superAdmin: Boolean(me.super_admin), accountStatus: (me.account_status as AccountStatus | undefined) ?? "approved", approvalRequired: me.approval_required ?? false, cohortIds: me.cohort_ids ?? [], consentAt: me.consent_at ?? null, onboardingCompletedAt: onboarding.onboarding_completed_at ?? null, nickname, selfIntroduction: (me.self_introduction ?? "").trim(), // 이미지 URL의 scheme·상대경로 검증은 실제 렌더 경계(ResilientImage)가 소유한다. avatarUrl, }; } export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [restoreState, setRestoreState] = useState("loading"); const initialRestoreStarted = useRef(false); useEffect(() => { const handleAuthExpired = () => { setUser(null); setLoading(false); setRestoreState("ready"); }; window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired); return () => { window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired); }; }, []); const retryRestore = useCallback(async () => { setLoading(true); setRestoreState("loading"); try { const me = await restoreCurrentUser(() => setRestoreState("retrying")); setUser(userFromMe(me)); setRestoreState("ready"); } catch (error) { if (isSignedOut(error)) { setUser(null); setRestoreState("ready"); } else { setRestoreState("failed"); } } finally { setLoading(false); } }, []); useEffect(() => { if (initialRestoreStarted.current) return; initialRestoreStarted.current = true; void retryRestore(); }, [retryRestore]); useEffect(() => { const body = document.body; if (user) body.setAttribute("data-role", designRoleOf(user.role)); else body.removeAttribute("data-role"); }, [user]); const login = useCallback(async (role, opts) => { const me = await api.post("/auth/dev-login", { email: opts?.email ?? DEV_EMAIL_BY_ROLE[role], role, display_name: opts?.displayName ?? DEV_NAME_BY_ROLE[role], }); const next = userFromMe(me); setUser(next); return next; }, []); const refresh = useCallback(async () => { try { const me = await api.get("/auth/me"); const next = userFromMe(me); setUser(next); return next; } catch { setUser(null); return null; } }, []); const logout = useCallback(async () => { try { await api.post("/auth/logout"); } finally { setUser(null); } }, []); const acceptConsent = useCallback(async () => { const response = await authApi.acceptConsent(); setUser((current) => current ? { ...current, consentAt: response.consent_at ?? null } : current, ); }, []); const withdrawConsent = useCallback(async () => { await authApi.withdrawConsent(); setUser((current) => (current ? { ...current, consentAt: null } : current)); }, []); const value = useMemo( () => ({ user, role: user?.role ?? null, loading, restoreState, retryRestore, login, logout, refresh, acceptConsent, withdrawConsent, }), [ user, loading, restoreState, retryRestore, login, logout, refresh, acceptConsent, withdrawConsent, ], ); return {children}; } export function useAuth(): AuthContextValue { const ctx = useContext(AuthContext); if (!ctx) { throw new Error("useAuth must be used inside AuthProvider"); } return ctx; }