Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

View file

@ -1,13 +1,3 @@
/* =====================================================================
Vignette AuthContext
- user/role , login/logout.
- mock ( ) + /auth/me ( ).
- role <body data-role>·data-theme tokens.css §6.2 accent .
Role enum(deps.py): learner | teacher | admin.
accent(DESIGN_CONCEPT §6.2): learner | instructor | admin.
teacher data-role="instructor" (-).
===================================================================== */
import {
createContext,
useCallback,
@ -17,16 +7,14 @@ import {
useState,
type ReactNode,
} from "react";
import { api, ApiError, type MeResponse } from "./api";
import { api, type MeResponse } from "./api";
/** 인증·인가 도메인 역할 (백엔드 deps.py Role 미러). */
export type Role = "learner" | "teacher" | "admin";
/** tokens.css §6.2 accent 스왑용 data-role 값. */
export type DesignRole = "learner" | "instructor" | "admin";
export interface AuthUser {
userId: string;
email: string;
name: string;
role: Role;
cohortIds: string[];
@ -35,26 +23,19 @@ export interface AuthUser {
export interface AuthContextValue {
user: AuthUser | null;
role: Role | null;
/** 부트스트랩(/auth/me) 진행 여부 — 가드 라우트의 깜빡임 방지 */
loading: boolean;
/** 개발용 mock 로그인: 역할 선택으로 즉시 인증 상태 진입 */
login: (role: Role, opts?: { name?: string; userId?: string }) => void;
/** 로그아웃 — 서버 세션 무효화 시도 후 로컬 상태 클리어 */
login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise<AuthUser>;
logout: () => Promise<void>;
}
const STORAGE_KEY = "vignette.dev-auth";
/** API role → 디자인 data-role 매핑. */
export function designRoleOf(role: Role): DesignRole {
return role === "teacher" ? "instructor" : role;
}
/** 역할별 한국어 컨텍스트 라벨 (톱바 좌측, §6.3). */
export function roleLabel(role: Role): string {
switch (role) {
case "learner":
return "학습 대시보드";
return "학습자 공간";
case "teacher":
return "교수 콘솔";
case "admin":
@ -62,7 +43,6 @@ export function roleLabel(role: Role): string {
}
}
/** 역할 진입 기본 경로. */
export function roleHomePath(role: Role): string {
switch (role) {
case "learner":
@ -74,50 +54,42 @@ export function roleHomePath(role: Role): string {
}
}
const ROLE_DEFAULT_NAME: Record<Role, string> = {
learner: "김수련",
teacher: "이교수",
admin: "운영자",
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 loadStored(): AuthUser | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AuthUser;
if (parsed && typeof parsed.role === "string") return parsed;
} catch {
/* 무시 */
}
return null;
function userFromMe(me: MeResponse): AuthUser {
return {
userId: me.user_id,
email: me.email,
name: me.display_name || me.email || me.user_id,
role: (me.role as Role) ?? "learner",
cohortIds: me.cohort_ids ?? [],
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(() => loadStored());
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
// 부트스트랩: 실서버 세션이 있으면 우선. 없으면(401/네트워크오류) 저장된 mock 유지.
useEffect(() => {
let alive = true;
(async () => {
try {
const me = await api.get<MeResponse>("/auth/me");
if (!alive) return;
const serverUser: AuthUser = {
userId: me.user_id,
name: me.user_id,
role: (me.role as Role) ?? "learner",
cohortIds: me.cohort_ids ?? [],
};
setUser(serverUser);
} catch (err) {
// 401(미인증) 또는 백엔드 미가동 → mock/로그아웃 상태 유지(에러 아님).
if (!(err instanceof ApiError) && !(err instanceof TypeError)) {
// 예기치 못한 오류는 콘솔로만 (UX 차단 안 함)
console.warn("[auth] /auth/me bootstrap failed", err);
}
if (alive) setUser(userFromMe(me));
} catch {
if (alive) setUser(null);
} finally {
if (alive) setLoading(false);
}
@ -127,42 +99,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, []);
// role → <body data-role> 반영 (accent 스왑). 미인증이면 속성 제거.
useEffect(() => {
const body = document.body;
if (user) {
body.setAttribute("data-role", designRoleOf(user.role));
} else {
body.removeAttribute("data-role");
}
if (user) body.setAttribute("data-role", designRoleOf(user.role));
else body.removeAttribute("data-role");
}, [user]);
const login = useCallback<AuthContextValue["login"]>((role, opts) => {
const next: AuthUser = {
userId: opts?.userId ?? `dev-${role}`,
name: opts?.name ?? ROLE_DEFAULT_NAME[role],
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,
cohortIds: [],
};
display_name: opts?.displayName ?? DEV_NAME_BY_ROLE[role],
});
const next = userFromMe(me);
setUser(next);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* 저장 실패 무시 */
}
return next;
}, []);
const logout = useCallback<AuthContextValue["logout"]>(async () => {
try {
await api.post("/auth/logout");
} catch {
// 서버 미가동/스텁이어도 로컬 클리어는 진행
}
setUser(null);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* 무시 */
} finally {
setUser(null);
}
}, []);
@ -177,7 +135,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth 는 <AuthProvider> 내부에서만 사용할 수 있습니다.");
throw new Error("useAuth must be used inside AuthProvider");
}
return ctx;
}