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 산출물은 커밋에서 제외했다.
664 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|