306 lines
8.4 KiB
TypeScript
306 lines
8.4 KiB
TypeScript
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<void>;
|
|
login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise<AuthUser>;
|
|
logout: () => Promise<void>;
|
|
refresh: () => Promise<AuthUser | null>;
|
|
acceptConsent: () => Promise<void>;
|
|
withdrawConsent: () => Promise<void>;
|
|
}
|
|
|
|
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<Role, string> = {
|
|
learner: "learner@hs.ac.kr",
|
|
teacher: "teacher@hs.ac.kr",
|
|
admin: "admin@twentyoz.kr",
|
|
};
|
|
|
|
const DEV_NAME_BY_ROLE: Record<Role, string> = {
|
|
learner: "학습자",
|
|
teacher: "교수자",
|
|
admin: "관리자",
|
|
};
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(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<void>((resolve) => window.setTimeout(resolve, ms));
|
|
}
|
|
|
|
function isSignedOut(error: unknown) {
|
|
return error instanceof ApiError && error.status === 401;
|
|
}
|
|
|
|
/**
|
|
* 재부팅 직후 API가 아직 올라오는 중이어도 세션을 곧바로 버리지 않는다.
|
|
* 각 요청은 유한 시간 안에 끝내고, 짧은 재시도 뒤에도 연결되지 않을 때만
|
|
* 명시적인 복구 화면으로 넘긴다.
|
|
*/
|
|
async function restoreCurrentUser(onRetry: () => void): Promise<MeResponse> {
|
|
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<MeResponse>("/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<AuthUser | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [restoreState, setRestoreState] = useState<AuthRestoreState>("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<AuthContextValue["login"]>(async (role, opts) => {
|
|
const me = await api.post<MeResponse>("/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<AuthContextValue["refresh"]>(async () => {
|
|
try {
|
|
const me = await api.get<MeResponse>("/auth/me");
|
|
const next = userFromMe(me);
|
|
setUser(next);
|
|
return next;
|
|
} catch {
|
|
setUser(null);
|
|
return null;
|
|
}
|
|
}, []);
|
|
|
|
const logout = useCallback<AuthContextValue["logout"]>(async () => {
|
|
try {
|
|
await api.post("/auth/logout");
|
|
} finally {
|
|
setUser(null);
|
|
}
|
|
}, []);
|
|
|
|
const acceptConsent = useCallback<AuthContextValue["acceptConsent"]>(async () => {
|
|
const response = await authApi.acceptConsent();
|
|
setUser((current) =>
|
|
current ? { ...current, consentAt: response.consent_at ?? null } : current,
|
|
);
|
|
}, []);
|
|
|
|
const withdrawConsent = useCallback<AuthContextValue["withdrawConsent"]>(async () => {
|
|
await authApi.withdrawConsent();
|
|
setUser((current) => (current ? { ...current, consentAt: null } : current));
|
|
}, []);
|
|
|
|
const value = useMemo<AuthContextValue>(
|
|
() => ({
|
|
user,
|
|
role: user?.role ?? null,
|
|
loading,
|
|
restoreState,
|
|
retryRestore,
|
|
login,
|
|
logout,
|
|
refresh,
|
|
acceptConsent,
|
|
withdrawConsent,
|
|
}),
|
|
[
|
|
user,
|
|
loading,
|
|
restoreState,
|
|
retryRestore,
|
|
login,
|
|
logout,
|
|
refresh,
|
|
acceptConsent,
|
|
withdrawConsent,
|
|
],
|
|
);
|
|
|
|
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;
|
|
}
|