diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index 85b891a..eea2c40 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -158,6 +158,9 @@ async function mockAdminSession( super_admin: boolean; onboarding_completed_at: number | null; }> = {}, + options: { + adminUsers?: unknown[]; + } = {}, ) { const seenAdminEndpoints = new Set(); const json = (body: unknown) => JSON.stringify(body); @@ -210,7 +213,7 @@ async function mockAdminSession( await fulfillJson({ source: "database", durable: true, - users: [], + users: options.adminUsers ?? [], }); return; } @@ -659,6 +662,48 @@ test.describe("admin route guards", () => { .toBe(0); await expect(page.getByRole("heading", { name: "역할, 그룹, 접근 범위" })).toBeInViewport(); }); + + test("shows admin data diagnostics instead of a blank main pane", async ({ page }) => { + await mockAdminSession( + page, + { + user_id: "diagnostic-admin", + email: "diagnostic-admin@twentyoz.kr", + display_name: "Diagnostic Admin", + role: "admin", + admin_access: true, + super_admin: true, + onboarding_completed_at: 1_782_900_000, + }, + { + adminUsers: [ + { + user_id: "bad-user", + email: "bad-user@hs.ac.kr", + display_name: "Bad User", + role: "learner", + admin_access: false, + super_admin: false, + account_status: "pending", + affiliation: null, + cohort_ids: null, + active_sessions: null, + created_at: null, + last_seen_at: null, + source: "database", + }, + ], + }, + ); + + await page.goto("/admin/users"); + + await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible(); + await expect(page.locator(".ad-diagnostic")).toContainText("관리자 데이터 진단"); + await expect(page.locator(".ad-diagnostic")).toContainText("cohort_ids"); + await expect(page.locator(".ad-diagnostic")).toContainText("active_sessions"); + await expect(page.getByText("Bad User")).toBeVisible(); + }); }); test.describe("admin route", () => { diff --git a/apps/web/src/pages/Admin.tsx b/apps/web/src/pages/Admin.tsx index 17f98dd..bbe0d75 100644 --- a/apps/web/src/pages/Admin.tsx +++ b/apps/web/src/pages/Admin.tsx @@ -1,4 +1,13 @@ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + Component, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ErrorInfo, + type ReactNode, +} from "react"; import { AppShell } from "../components/shell/AppShell"; import { Badge, Button, Dot, Icon, Kicker, ProgressBar } from "../components/ui"; import { @@ -31,6 +40,11 @@ type AccessTab = "roles" | "groups" | "matrix"; type TicketStatusFilter = AdminSupportTicket["status"] | "all"; type TicketCategoryFilter = AdminSupportTicket["category"] | "all"; type TicketPriorityFilter = AdminSupportTicket["priority"] | "all"; +type AdminDataWarning = { + key: string; + message: string; + detail: string; +}; const MAX_RENDERED_USERS = 40; const MAX_RESOLVED_TICKET_HISTORY = 6; @@ -330,6 +344,201 @@ function parseCohorts(value: string): string[] { .filter(Boolean); } +function asRecord(value: unknown): Record { + return value !== null && typeof value === "object" ? (value as Record) : {}; +} + +function stringField( + source: Record, + field: string, + fallback: string, + warnings: AdminDataWarning[], + label: string, +): string { + const value = source[field]; + if (typeof value === "string") return value; + if (value == null) { + warnings.push({ + key: `${label}.${field}`, + message: `${label} 응답에서 ${field} 값이 비어 있어 기본값으로 표시합니다.`, + detail: `${field}=null`, + }); + return fallback; + } + warnings.push({ + key: `${label}.${field}`, + message: `${label} 응답에서 ${field} 타입이 잘못되어 문자열로 변환했습니다.`, + detail: `${field}=${typeof value}`, + }); + return String(value); +} + +function booleanField( + source: Record, + field: string, + fallback: boolean, + warnings: AdminDataWarning[], + label: string, +): boolean { + const value = source[field]; + if (typeof value === "boolean") return value; + if (value == null) { + warnings.push({ + key: `${label}.${field}`, + message: `${label} 응답에서 ${field} 값이 비어 있어 ${fallback ? "true" : "false"}로 표시합니다.`, + detail: `${field}=null`, + }); + return fallback; + } + warnings.push({ + key: `${label}.${field}`, + message: `${label} 응답에서 ${field} 타입이 잘못되어 bool 기본값으로 표시합니다.`, + detail: `${field}=${typeof value}`, + }); + return fallback; +} + +function numberField( + source: Record, + field: string, + fallback: number, + warnings: AdminDataWarning[], + label: string, +): number { + const value = source[field]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + warnings.push({ + key: `${label}.${field}`, + message: `${label} 응답에서 ${field}가 문자열이라 숫자로 변환했습니다.`, + detail: `${field}=string`, + }); + return parsed; + } + } + warnings.push({ + key: `${label}.${field}`, + message: `${label} 응답에서 ${field} 숫자 값이 없어 ${fallback}으로 표시합니다.`, + detail: `${field}=${value == null ? "null" : typeof value}`, + }); + return fallback; +} + +function cohortIdsField( + source: Record, + warnings: AdminDataWarning[], + label: string, +): string[] { + const value = source.cohort_ids; + if (!Array.isArray(value)) { + warnings.push({ + key: `${label}.cohort_ids`, + message: `${label} 응답에서 cohort_ids가 배열이 아니라 빈 코호트로 표시합니다.`, + detail: `cohort_ids=${value == null ? "null" : typeof value}`, + }); + return []; + } + const normalized = value.filter((item): item is string => typeof item === "string"); + if (normalized.length !== value.length) { + warnings.push({ + key: `${label}.cohort_ids.items`, + message: `${label} 응답에서 cohort_ids 내부에 문자열이 아닌 값이 있어 제외했습니다.`, + detail: `kept=${normalized.length}, raw=${value.length}`, + }); + } + return normalized; +} + +function roleField( + source: Record, + warnings: AdminDataWarning[], + label: string, +): AdminManagedUser["role"] { + const value = source.role; + if (value === "learner" || value === "teacher" || value === "admin") return value; + warnings.push({ + key: `${label}.role`, + message: `${label} 응답에서 role 값이 계약과 달라 학습자로 표시합니다.`, + detail: `role=${String(value)}`, + }); + return "learner"; +} + +function accountStatusField( + source: Record, + warnings: AdminDataWarning[], + label: string, +): AdminManagedUser["account_status"] { + const value = source.account_status; + if (value === "pending" || value === "approved" || value === "suspended") return value; + warnings.push({ + key: `${label}.account_status`, + message: `${label} 응답에서 account_status 값이 계약과 달라 승인 대기로 표시합니다.`, + detail: `account_status=${String(value)}`, + }); + return "pending"; +} + +function userSourceField( + source: Record, + parentSource: AdminUsersResponse["source"], +): AdminManagedUser["source"] { + const value = source.source; + return value === "server_session_registry" || value === "database" ? value : parentSource; +} + +function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): { + response: AdminUsersResponse; + warnings: AdminDataWarning[]; +} { + const warnings: AdminDataWarning[] = []; + const root = asRecord(rawResponse); + const source: AdminUsersResponse["source"] = + root.source === "server_session_registry" ? "server_session_registry" : "database"; + const durable = typeof root.durable === "boolean" ? root.durable : source === "database"; + const rawUsers = Array.isArray(root.users) ? root.users : []; + + if (!Array.isArray(root.users)) { + warnings.push({ + key: "admin.users", + message: "/admin/users 응답에서 users 배열이 없어 빈 목록으로 표시합니다.", + detail: `users=${root.users == null ? "null" : typeof root.users}`, + }); + } + + const users = rawUsers.map((item, index): AdminManagedUser => { + const sourceRecord = asRecord(item); + const label = `users[${index}]`; + const userId = stringField(sourceRecord, "user_id", `unknown-${index + 1}`, warnings, label); + return { + user_id: userId, + email: stringField(sourceRecord, "email", userId, warnings, label), + display_name: stringField(sourceRecord, "display_name", userId, warnings, label), + role: roleField(sourceRecord, warnings, label), + admin_access: booleanField(sourceRecord, "admin_access", false, warnings, label), + super_admin: booleanField(sourceRecord, "super_admin", false, warnings, label), + account_status: accountStatusField(sourceRecord, warnings, label), + cohort_ids: cohortIdsField(sourceRecord, warnings, label), + affiliation: stringField(sourceRecord, "affiliation", "", warnings, label), + active_sessions: numberField(sourceRecord, "active_sessions", 0, warnings, label), + created_at: numberField(sourceRecord, "created_at", 0, warnings, label), + last_seen_at: numberField(sourceRecord, "last_seen_at", 0, warnings, label), + source: userSourceField(sourceRecord, source), + }; + }); + + return { + response: { + source, + durable, + users, + }, + warnings, + }; +} + function isOnline(seconds?: number | null): boolean { if (!seconds || seconds <= 0) return false; return Date.now() / 1000 - seconds < 15 * 60; @@ -355,6 +564,187 @@ function PageHeader({ kicker, title, description, children }: PageHeaderProps) { ); } +function runtimeAssetLabel(): string { + if (typeof document === "undefined") return "unknown"; + const script = Array.from(document.scripts) + .map((item) => item.getAttribute("src") ?? "") + .find((src) => src.includes("/assets/index-") && src.endsWith(".js")); + if (!script) return "unknown"; + return script.split("/").pop() ?? script; +} + +function AdminDiagnosticPanel({ + title, + body, + details, +}: { + title: string; + body: string; + details: Array<[string, string]>; +}) { + return ( +
+
+ 화면 진단 +

{title}

+

{body}

+
+
+ {details.map(([key, value]) => ( +
+
{key}
+
{value}
+
+ ))} +
+
+ ); +} + +interface AdminErrorBoundaryProps { + section: AdminSection; + children: ReactNode; +} + +interface AdminErrorBoundaryState { + error: Error | null; + errorInfo: ErrorInfo | null; +} + +class AdminErrorBoundary extends Component { + state: AdminErrorBoundaryState = { error: null, errorInfo: null }; + + static getDerivedStateFromError(error: Error): AdminErrorBoundaryState { + return { error, errorInfo: null }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.setState({ errorInfo }); + console.error("[admin-render-error]", error, errorInfo.componentStack); + } + + componentDidUpdate(prevProps: AdminErrorBoundaryProps) { + if (prevProps.section !== this.props.section && this.state.error) { + this.setState({ error: null, errorInfo: null }); + } + } + + render() { + if (this.state.error) { + return ( + + ); + } + return this.props.children; + } +} + +function AdminBlankContentProbe({ section }: { section: AdminSection }) { + const markerRef = useRef(null); + const [diagnostic, setDiagnostic] = useState | null>(null); + + useEffect(() => { + setDiagnostic(null); + const timer = window.setTimeout(() => { + const root = markerRef.current?.closest(".ad-root") as HTMLElement | null; + if (!root) return; + const text = root.innerText.replace(/\s+/g, " ").trim(); + const rootRect = root.getBoundingClientRect(); + const visibleNodes = Array.from( + root.querySelectorAll( + "h1,h2,p,button,input,select,article,section,.ad-head,.ad-panel,.ad-section,.ad-users-note,.ad-status", + ), + ).filter((element) => { + if (element.classList.contains("ad-diagnostic")) return false; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return ( + rect.width > 1 && + rect.height > 1 && + style.display !== "none" && + style.visibility !== "hidden" && + style.opacity !== "0" + ); + }); + + if (text.length === 0 || rootRect.height < 12 || visibleNodes.length === 0) { + setDiagnostic([ + ["section", section], + ["path", window.location.pathname], + ["rootHeight", `${Math.round(rootRect.height)}px`], + ["visibleNodes", String(visibleNodes.length)], + ["scrollY", String(Math.round(window.scrollY))], + ["asset", runtimeAssetLabel()], + ]); + } + }, 800); + return () => window.clearTimeout(timer); + }, [section]); + + return ( + <> +