feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG

web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
Yun Chan 2026-06-25 23:37:22 +09:00
parent 859ab26314
commit 24b1b7a6e1
84 changed files with 19645 additions and 107 deletions

183
apps/web/src/lib/auth.tsx Normal file
View file

@ -0,0 +1,183 @@
/* =====================================================================
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,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api, ApiError, 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;
name: string;
role: Role;
cohortIds: string[];
}
export interface AuthContextValue {
user: AuthUser | null;
role: Role | null;
/** 부트스트랩(/auth/me) 진행 여부 — 가드 라우트의 깜빡임 방지 */
loading: boolean;
/** 개발용 mock 로그인: 역할 선택으로 즉시 인증 상태 진입 */
login: (role: Role, opts?: { name?: string; userId?: string }) => void;
/** 로그아웃 — 서버 세션 무효화 시도 후 로컬 상태 클리어 */
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 "학습 대시보드";
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";
}
}
const ROLE_DEFAULT_NAME: 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;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(() => loadStored());
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);
}
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, []);
// role → <body data-role> 반영 (accent 스왑). 미인증이면 속성 제거.
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"]>((role, opts) => {
const next: AuthUser = {
userId: opts?.userId ?? `dev-${role}`,
name: opts?.name ?? ROLE_DEFAULT_NAME[role],
role,
cohortIds: [],
};
setUser(next);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* 저장 실패 무시 */
}
}, []);
const logout = useCallback<AuthContextValue["logout"]>(async () => {
try {
await api.post("/auth/logout");
} catch {
// 서버 미가동/스텁이어도 로컬 클리어는 진행
}
setUser(null);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* 무시 */
}
}, []);
const value = useMemo<AuthContextValue>(
() => ({ user, role: user?.role ?? null, loading, login, logout }),
[user, loading, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth 는 <AuthProvider> 내부에서만 사용할 수 있습니다.");
}
return ctx;
}