vignette/apps/web/src/lib/auth.tsx
2026-06-29 08:12:14 +09:00

216 lines
5.8 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { AUTH_EXPIRED_EVENT, api, apiUrl, authApi, type MeResponse } from "./api";
export type Role = "learner" | "teacher" | "admin";
export type AccountStatus = "pending" | "approved" | "suspended";
export type DesignRole = "learner" | "instructor" | "admin";
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;
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;
return role === "admin" && user.adminAccess;
}
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);
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(),
avatarUrl: avatarUrl ? apiUrl(avatarUrl) : "",
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const handleAuthExpired = () => {
setUser(null);
setLoading(false);
};
window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired);
return () => {
window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired);
};
}, []);
useEffect(() => {
let alive = true;
(async () => {
try {
const me = await api.get<MeResponse>("/auth/me");
if (alive) setUser(userFromMe(me));
} catch {
if (alive) setUser(null);
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, []);
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,
login,
logout,
refresh,
acceptConsent,
withdrawConsent,
}),
[user, loading, 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;
}