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:
parent
859ab26314
commit
24b1b7a6e1
84 changed files with 19645 additions and 107 deletions
183
apps/web/src/lib/auth.tsx
Normal file
183
apps/web/src/lib/auth.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue