vignette/apps/web/src/App.tsx
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

664 lines
19 KiB
TypeScript

/* =====================================================================
App — 라우터. AuthProvider 로 감싸고 역할 가드(RequireAuth) 적용.
라우트 (task 명세):
/login -> Login
/learn -> LearnerHome (learner)
/learn/session/:sessionId -> Session
/learn/session/:sessionId/review -> SessionReview
/teach -> Professor (teacher → data-role=instructor)
/teach/analysis -> Professor (teacher learner analysis)
/teach/supervision -> SupervisionResearch (teacher/admin)
/teach/personas -> PersonaStudio (teacher/admin)
/teach/session/:sessionId/review -> SessionReview (teacher read-only)
/admin -> Admin (admin)
/settings -> Settings
/ -> Navigate(역할 홈, 미인증이면 /login)
보호 라우트는 AuthContext 기반 간단 가드. 과설계 금지.
===================================================================== */
import {
Component,
Suspense,
lazy,
useEffect,
useLayoutEffect,
type ErrorInfo,
type ReactNode,
} from "react";
import {
BrowserRouter,
Navigate,
Route,
Routes,
useLocation,
} from "react-router-dom";
import {
AuthProvider,
canAccessRole,
initialPathForUser,
useAuth,
roleHomePath,
type AuthUser,
type Role,
} from "./lib/auth";
import { runtimeAssetLabel } from "./lib/runtimeDiagnostics";
import { clearChunkRecoveryMarker } from "./lib/chunkRecovery";
const Login = lazy(() => import("./pages/Login"));
const Onboarding = lazy(() => import("./pages/Onboarding"));
const PendingApproval = lazy(() => import("./pages/PendingApproval"));
const LearnerHome = lazy(() => import("./pages/LearnerHome"));
const AvatarExpressionLab = lazy(() => import("./pages/AvatarExpressionLab"));
const AvatarPreview = lazy(() => import("./pages/AvatarPreview"));
const Session = lazy(() => import("./pages/Session"));
const SessionReview = lazy(() => import("./pages/SessionReview"));
const Professor = lazy(() => import("./pages/Professor"));
const SupervisionResearch = lazy(() => import("./pages/SupervisionResearch"));
const PersonaStudio = lazy(() => import("./pages/PersonaStudio"));
const Admin = lazy(() => import("./pages/Admin"));
const AdminAi = lazy(() => import("./pages/AdminAi"));
const AdminContinuousImprovement = lazy(
() => import("./pages/AdminContinuousImprovement"),
);
const Settings = lazy(() => import("./pages/Settings"));
/** 부트스트랩 로딩 동안 깜빡임 최소화용 중립 화면. */
function BootScreen() {
return (
<div
style={{
minHeight: "100dvh",
display: "grid",
placeItems: "center",
background: "var(--bg-app)",
color: "var(--text-muted)",
fontSize: "var(--fs-sm)",
}}
>
</div>
);
}
function AuthRestoreGate({ children }: { children: ReactNode }) {
const { loading, restoreState, retryRestore } = useAuth();
if (loading && restoreState === "loading") return <BootScreen />;
if (loading && restoreState === "retrying") {
return (
<main
role="status"
className="vg-route-error"
data-testid="auth-restore-retrying"
>
<div className="vg-route-error__panel">
<p className="vg-route-error__eyebrow"> </p>
<h1> .</h1>
<p>
.
.
</p>
</div>
</main>
);
}
if (restoreState === "failed") {
return (
<main
role="alert"
className="vg-route-error"
data-testid="auth-restore-failed"
>
<div className="vg-route-error__panel">
<p className="vg-route-error__eyebrow"> </p>
<h1> .</h1>
<p>
.
.
</p>
<button
type="button"
data-testid="auth-restore-retry"
onClick={() => void retryRestore()}
style={{
minHeight: 42,
padding: "0 16px",
border: "1px solid var(--accent)",
borderRadius: "var(--radius-sm)",
background: "var(--accent)",
color: "var(--text-on-accent)",
font: "inherit",
fontWeight: 700,
cursor: "pointer",
}}
>
</button>
</div>
</main>
);
}
return <>{children}</>;
}
/**
* RequireAuth — 미인증이면 /login 으로. (선택) roles 로 역할 제한.
* 권한 불일치 시 자신의 역할 홈으로 보냄(빈 화면/에러 대신).
*/
function RequireAuth({
children,
roles,
}: {
children: ReactNode;
roles?: Role[];
}) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <BootScreen />;
if (!user) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
if (roles && !roles.some((role) => canAccessRole(user, role))) {
return <Navigate to={roleHomePath(user.role)} replace />;
}
return <>{children}</>;
}
function isAdminWorkspacePath(path: string) {
return path === "/admin" || path.startsWith("/admin/");
}
function approvedHomePath(user: AuthUser) {
return initialPathForUser(user);
}
function OnboardingGate({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
const path = location.pathname;
if (loading) return <BootScreen />;
if (path === "/pending") return <>{children}</>;
if (user && user.onboardingCompletedAt == null && path !== "/onboarding") {
if (isAdminWorkspacePath(path) && canAccessRole(user, "admin")) {
return <>{children}</>;
}
return <Navigate to="/onboarding" replace state={{ from: path }} />;
}
if (
user &&
user.onboardingCompletedAt != null &&
(path === "/login" || path === "/onboarding")
) {
return <Navigate to={initialPathForUser(user)} replace />;
}
return <>{children}</>;
}
function PendingApprovalGate({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
const path = location.pathname;
if (loading) return <BootScreen />;
if (user && user.accountStatus !== "approved" && path !== "/pending") {
return <Navigate to="/pending" replace state={{ from: path }} />;
}
if (user && user.accountStatus === "approved" && path === "/pending") {
return <Navigate to={approvedHomePath(user)} replace />;
}
return <>{children}</>;
}
/** 루트(/) — 인증되면 역할 홈, 아니면 /login. */
function RootRedirect() {
const { user, loading } = useAuth();
if (loading) return <BootScreen />;
if (user && user.accountStatus !== "approved") {
return <Navigate to="/pending" replace />;
}
if (user && user.onboardingCompletedAt == null) {
return <Navigate to={approvedHomePath(user)} replace />;
}
return <Navigate to={user ? initialPathForUser(user) : "/login"} replace />;
}
function resetDocumentScroll() {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
}
function ScrollToTopOnPathChange() {
const { pathname } = useLocation();
useLayoutEffect(() => {
const previousRestoration = window.history.scrollRestoration;
window.history.scrollRestoration = "manual";
const handlePageShow = () => resetDocumentScroll();
window.addEventListener("pageshow", handlePageShow);
return () => {
window.removeEventListener("pageshow", handlePageShow);
window.history.scrollRestoration = previousRestoration;
};
}, []);
useLayoutEffect(() => {
resetDocumentScroll();
const frame = window.requestAnimationFrame(resetDocumentScroll);
return () => window.cancelAnimationFrame(frame);
}, [pathname]);
return null;
}
function RouteErrorFallback({
error,
errorInfo,
}: {
error: Error;
errorInfo: ErrorInfo | null;
}) {
const reloadLatestVersion = () => {
clearChunkRecoveryMarker(window.location.pathname);
window.location.reload();
};
return (
<main
role="alert"
style={{
minHeight: "100dvh",
padding: 32,
background: "var(--bg-app)",
color: "var(--text-strong)",
}}
>
<div
style={{
maxWidth: 920,
display: "grid",
gap: 16,
padding: 20,
border: "1px solid var(--hair)",
borderRadius: "var(--radius)",
background: "var(--bg-surface)",
boxShadow: "var(--shadow-sm)",
}}
>
<div>
<span
style={{
color: "var(--text-muted)",
fontSize: 12,
fontWeight: 700,
}}
>
</span>
<h1 style={{ margin: "6px 0 0", fontSize: "var(--fs-h2)" }}>
</h1>
<p
style={{
margin: "6px 0 0",
color: "var(--text-body)",
lineHeight: 1.55,
}}
>
React .
.
</p>
</div>
<dl
style={{
display: "grid",
gridTemplateColumns: "160px minmax(0, 1fr)",
gap: 0,
margin: 0,
border: "1px solid var(--hair)",
borderRadius: "var(--radius)",
overflow: "hidden",
fontSize: 13,
}}
>
{[
[
"path",
typeof window === "undefined"
? "unknown"
: window.location.pathname,
],
["error", error.message || error.name],
["asset", runtimeAssetLabel()],
[
"componentStack",
errorInfo?.componentStack?.trim() || "not captured",
],
].map(([key, value]) => (
<div key={key} style={{ display: "contents" }}>
<dt
style={{
padding: "9px 11px",
borderBottom: "1px solid var(--hair)",
background: "var(--bg-surface-2)",
color: "var(--text-muted)",
fontWeight: 700,
}}
>
{key}
</dt>
<dd
style={{
minWidth: 0,
margin: 0,
padding: "9px 11px",
borderBottom: "1px solid var(--hair)",
color: "var(--text-body)",
overflowWrap: "anywhere",
whiteSpace: "pre-wrap",
}}
>
{value}
</dd>
</div>
))}
</dl>
<div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
<button
type="button"
data-testid="route-error-reload"
onClick={reloadLatestVersion}
style={{
minHeight: 42,
padding: "0 16px",
border: "1px solid var(--accent)",
borderRadius: "var(--radius-sm)",
background: "var(--accent)",
color: "var(--text-on-accent)",
font: "inherit",
fontWeight: 700,
cursor: "pointer",
}}
>
</button>
<a
href="/"
style={{
minHeight: 42,
display: "inline-flex",
alignItems: "center",
padding: "0 16px",
border: "1px solid var(--hair)",
borderRadius: "var(--radius-sm)",
color: "var(--text-body)",
textDecoration: "none",
fontWeight: 700,
}}
>
</a>
</div>
</div>
</main>
);
}
class RouteErrorBoundary extends Component<
{ resetKey: string; children: ReactNode },
{ error: Error | null; errorInfo: ErrorInfo | null }
> {
state = { error: null, errorInfo: null };
static getDerivedStateFromError(error: Error) {
return { error, errorInfo: null };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState({ errorInfo });
console.error("[route-render-error]", error, errorInfo.componentStack);
}
componentDidUpdate(prevProps: { resetKey: string; children: ReactNode }) {
if (prevProps.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null, errorInfo: null });
}
}
render() {
if (this.state.error) {
return (
<RouteErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo}
/>
);
}
return this.props.children;
}
}
function AppRoutesWithBoundary() {
const { pathname } = useLocation();
return (
<RouteErrorBoundary resetKey={pathname}>
<Suspense fallback={<BootScreen />}>
<ChunkRecoveryReady pathname={pathname} />
<AppRoutes />
</Suspense>
</RouteErrorBoundary>
);
}
function ChunkRecoveryReady({ pathname }: { pathname: string }) {
useEffect(() => {
clearChunkRecoveryMarker(pathname);
}, [pathname]);
return null;
}
function AppRoutes() {
return (
<AuthRestoreGate>
<PendingApprovalGate>
<OnboardingGate>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/pending"
element={
<RequireAuth>
<PendingApproval />
</RequireAuth>
}
/>
{/* dev: 인증 없는 아바타 컴포지션 튜닝 페이지 (실서비스 아님) */}
<Route path="/dev/avatar-preview" element={<AvatarPreview />} />
<Route
path="/onboarding"
element={
<RequireAuth>
<Onboarding />
</RequireAuth>
}
/>
{/* 학습자 */}
<Route
path="/learn"
element={
<RequireAuth roles={["learner"]}>
<LearnerHome view="dashboard" />
</RequireAuth>
}
/>
<Route
path="/learn/practice"
element={
<RequireAuth roles={["learner"]}>
<LearnerHome view="practice" />
</RequireAuth>
}
/>
<Route
path="/learn/history"
element={
<RequireAuth roles={["learner"]}>
<LearnerHome view="history" />
</RequireAuth>
}
/>
<Route
path="/learn/avatar-expressions"
element={
<RequireAuth roles={["learner"]}>
<AvatarExpressionLab />
</RequireAuth>
}
/>
<Route
path="/learn/session/:sessionId"
element={
<RequireAuth roles={["learner"]}>
<Session />
</RequireAuth>
}
/>
<Route
path="/learn/session/:sessionId/review"
element={
<RequireAuth roles={["learner"]}>
<SessionReview />
</RequireAuth>
}
/>
{/* 교수자 */}
<Route
path="/teach"
element={
<RequireAuth roles={["teacher"]}>
<Professor />
</RequireAuth>
}
/>
<Route
path="/teach/analysis"
element={
<RequireAuth roles={["teacher"]}>
<Professor view="analysis" />
</RequireAuth>
}
/>
<Route
path="/teach/supervision"
element={
<RequireAuth roles={["teacher", "admin"]}>
<SupervisionResearch />
</RequireAuth>
}
/>
<Route
path="/teach/personas"
element={
<RequireAuth roles={["teacher", "admin"]}>
<PersonaStudio />
</RequireAuth>
}
/>
<Route
path="/teach/session/:sessionId/review"
element={
<RequireAuth roles={["teacher"]}>
<SessionReview />
</RequireAuth>
}
/>
{/* 관리자 */}
<Route
path="/admin"
element={
<RequireAuth roles={["admin"]}>
<Admin section="overview" />
</RequireAuth>
}
/>
<Route
path="/admin/ai"
element={
<RequireAuth roles={["admin"]}>
<AdminAi />
</RequireAuth>
}
/>
<Route
path="/admin/continuous-improvement"
element={
<RequireAuth roles={["admin"]}>
<AdminContinuousImprovement />
</RequireAuth>
}
/>
<Route
path="/admin/users"
element={
<RequireAuth roles={["admin"]}>
<Admin section="users" />
</RequireAuth>
}
/>
<Route
path="/admin/access"
element={
<RequireAuth roles={["admin"]}>
<Admin section="access" />
</RequireAuth>
}
/>
<Route
path="/admin/tickets"
element={
<RequireAuth roles={["admin"]}>
<Admin section="tickets" />
</RequireAuth>
}
/>
{/* 설정 — 3역할 공통 */}
<Route
path="/settings"
element={
<RequireAuth>
<Settings />
</RequireAuth>
}
/>
<Route path="/" element={<RootRedirect />} />
{/* 미정의 경로 → 루트로 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</OnboardingGate>
</PendingApprovalGate>
</AuthRestoreGate>
);
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<ScrollToTopOnPathChange />
<AppRoutesWithBoundary />
</AuthProvider>
</BrowserRouter>
);
}