Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue