관리자 흰 화면 진단 보강
This commit is contained in:
parent
e9efaa8031
commit
287029f2a2
6 changed files with 307 additions and 25 deletions
|
|
@ -160,6 +160,7 @@ async function mockAdminSession(
|
||||||
}> = {},
|
}> = {},
|
||||||
options: {
|
options: {
|
||||||
adminUsers?: unknown[];
|
adminUsers?: unknown[];
|
||||||
|
adminTickets?: unknown;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const seenAdminEndpoints = new Set<string>();
|
const seenAdminEndpoints = new Set<string>();
|
||||||
|
|
@ -268,18 +269,20 @@ async function mockAdminSession(
|
||||||
|
|
||||||
if (method === "GET" && path.endsWith("/admin/tickets")) {
|
if (method === "GET" && path.endsWith("/admin/tickets")) {
|
||||||
seenAdminEndpoints.add("tickets");
|
seenAdminEndpoints.add("tickets");
|
||||||
await fulfillJson({
|
await fulfillJson(
|
||||||
source: "database",
|
options.adminTickets ?? {
|
||||||
durable: true,
|
source: "database",
|
||||||
tickets: [],
|
durable: true,
|
||||||
summary: {
|
tickets: [],
|
||||||
total: 0,
|
summary: {
|
||||||
open_count: 0,
|
total: 0,
|
||||||
high_priority_count: 0,
|
open_count: 0,
|
||||||
stale_count: 0,
|
high_priority_count: 0,
|
||||||
by_category: {},
|
stale_count: 0,
|
||||||
|
by_category: {},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -704,6 +707,36 @@ test.describe("admin route guards", () => {
|
||||||
await expect(page.locator(".ad-diagnostic")).toContainText("active_sessions");
|
await expect(page.locator(".ad-diagnostic")).toContainText("active_sessions");
|
||||||
await expect(page.getByText("Bad User")).toBeVisible();
|
await expect(page.getByText("Bad User")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows ticket payload diagnostics instead of a white page", async ({ page }) => {
|
||||||
|
await mockAdminSession(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
user_id: "ticket-diagnostic-admin",
|
||||||
|
email: "ticket-diagnostic-admin@twentyoz.kr",
|
||||||
|
display_name: "Ticket Diagnostic Admin",
|
||||||
|
role: "admin",
|
||||||
|
admin_access: true,
|
||||||
|
super_admin: true,
|
||||||
|
onboarding_completed_at: 1_782_900_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adminTickets: {
|
||||||
|
source: "database",
|
||||||
|
durable: true,
|
||||||
|
tickets: [],
|
||||||
|
summary: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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("admin.tickets.summary");
|
||||||
|
await expect(page.locator("body")).not.toHaveText(/^$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("admin route", () => {
|
test.describe("admin route", () => {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
보호 라우트는 AuthContext 기반 간단 가드. 과설계 금지.
|
보호 라우트는 AuthContext 기반 간단 가드. 과설계 금지.
|
||||||
===================================================================== */
|
===================================================================== */
|
||||||
|
|
||||||
import { useEffect, type ReactNode } from "react";
|
import { Component, useEffect, type ErrorInfo, type ReactNode } from "react";
|
||||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { AuthProvider, canAccessRole, initialPathForUser, useAuth, roleHomePath, type AuthUser, type Role } from "./lib/auth";
|
import { AuthProvider, canAccessRole, initialPathForUser, useAuth, roleHomePath, type AuthUser, type Role } from "./lib/auth";
|
||||||
|
|
||||||
|
|
@ -141,6 +141,150 @@ function ScrollToTopOnPathChange() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 RouteErrorFallback({
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
}: {
|
||||||
|
error: Error;
|
||||||
|
errorInfo: ErrorInfo | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
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>
|
||||||
|
</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}>
|
||||||
|
<AppRoutes />
|
||||||
|
</RouteErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<PendingApprovalGate>
|
<PendingApprovalGate>
|
||||||
|
|
@ -311,7 +455,7 @@ export default function App() {
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ScrollToTopOnPathChange />
|
<ScrollToTopOnPathChange />
|
||||||
<AppRoutes />
|
<AppRoutesWithBoundary />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -451,6 +451,55 @@ function cohortIdsField(
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function numberRecordField(
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
field: string,
|
||||||
|
warnings: AdminDataWarning[],
|
||||||
|
label: string,
|
||||||
|
): Record<string, number> {
|
||||||
|
const value = source[field];
|
||||||
|
if (value == null) {
|
||||||
|
warnings.push({
|
||||||
|
key: `${label}.${field}`,
|
||||||
|
message: `${label} 응답에서 ${field} 값이 비어 있어 빈 집계로 표시합니다.`,
|
||||||
|
detail: `${field}=null`,
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
warnings.push({
|
||||||
|
key: `${label}.${field}`,
|
||||||
|
message: `${label} 응답에서 ${field} 타입이 잘못되어 빈 집계로 표시합니다.`,
|
||||||
|
detail: `${field}=${typeof value}`,
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>);
|
||||||
|
const normalized: Record<string, number> = {};
|
||||||
|
for (const [key, raw] of entries) {
|
||||||
|
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||||
|
normalized[key] = raw;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = typeof raw === "string" ? Number(raw) : Number.NaN;
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
normalized[key] = parsed;
|
||||||
|
warnings.push({
|
||||||
|
key: `${label}.${field}.${key}`,
|
||||||
|
message: `${label} 응답에서 ${field}.${key}가 문자열이라 숫자로 변환했습니다.`,
|
||||||
|
detail: `${field}.${key}=string`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
warnings.push({
|
||||||
|
key: `${label}.${field}.${key}`,
|
||||||
|
message: `${label} 응답에서 ${field}.${key} 숫자 값이 없어 제외했습니다.`,
|
||||||
|
detail: `${field}.${key}=${raw == null ? "null" : typeof raw}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function roleField(
|
function roleField(
|
||||||
source: Record<string, unknown>,
|
source: Record<string, unknown>,
|
||||||
warnings: AdminDataWarning[],
|
warnings: AdminDataWarning[],
|
||||||
|
|
@ -539,6 +588,52 @@ function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAdminTicketsResponse(rawResponse: AdminTicketsResponse): {
|
||||||
|
response: AdminTicketsResponse;
|
||||||
|
warnings: AdminDataWarning[];
|
||||||
|
} {
|
||||||
|
const warnings: AdminDataWarning[] = [];
|
||||||
|
const root = asRecord(rawResponse);
|
||||||
|
const source: AdminTicketsResponse["source"] = root.source === "database" ? "database" : "unavailable";
|
||||||
|
const durable = typeof root.durable === "boolean" ? root.durable : source === "database";
|
||||||
|
const rawTickets = Array.isArray(root.tickets) ? root.tickets : [];
|
||||||
|
if (!Array.isArray(root.tickets)) {
|
||||||
|
warnings.push({
|
||||||
|
key: "admin.tickets",
|
||||||
|
message: "/admin/tickets 응답에서 tickets 배열이 없어 빈 목록으로 표시합니다.",
|
||||||
|
detail: `tickets=${root.tickets == null ? "null" : typeof root.tickets}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryRoot = asRecord(root.summary);
|
||||||
|
if (!root.summary || typeof root.summary !== "object" || Array.isArray(root.summary)) {
|
||||||
|
warnings.push({
|
||||||
|
key: "admin.tickets.summary",
|
||||||
|
message: "/admin/tickets 응답에서 summary 객체가 없어 기본 집계로 표시합니다.",
|
||||||
|
detail: `summary=${root.summary == null ? "null" : typeof root.summary}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
source,
|
||||||
|
durable,
|
||||||
|
generated_at: numberField(root, "generated_at", 0, warnings, "admin.tickets"),
|
||||||
|
tickets: rawTickets as AdminSupportTicket[],
|
||||||
|
summary: {
|
||||||
|
total: numberField(summaryRoot, "total", rawTickets.length, warnings, "admin.tickets.summary"),
|
||||||
|
open_count: numberField(summaryRoot, "open_count", 0, warnings, "admin.tickets.summary"),
|
||||||
|
high_priority_count: numberField(summaryRoot, "high_priority_count", 0, warnings, "admin.tickets.summary"),
|
||||||
|
stale_count: numberField(summaryRoot, "stale_count", 0, warnings, "admin.tickets.summary"),
|
||||||
|
by_category: numberRecordField(summaryRoot, "by_category", warnings, "admin.tickets.summary"),
|
||||||
|
by_priority: numberRecordField(summaryRoot, "by_priority", warnings, "admin.tickets.summary"),
|
||||||
|
by_status: numberRecordField(summaryRoot, "by_status", warnings, "admin.tickets.summary"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isOnline(seconds?: number | null): boolean {
|
function isOnline(seconds?: number | null): boolean {
|
||||||
if (!seconds || seconds <= 0) return false;
|
if (!seconds || seconds <= 0) return false;
|
||||||
return Date.now() / 1000 - seconds < 15 * 60;
|
return Date.now() / 1000 - seconds < 15 * 60;
|
||||||
|
|
@ -774,6 +869,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
const [tickets, setTickets] = useState<AdminTicketsResponse | null>(null);
|
const [tickets, setTickets] = useState<AdminTicketsResponse | null>(null);
|
||||||
const [ticketsLoading, setTicketsLoading] = useState(true);
|
const [ticketsLoading, setTicketsLoading] = useState(true);
|
||||||
const [ticketsError, setTicketsError] = useState<string | null>(null);
|
const [ticketsError, setTicketsError] = useState<string | null>(null);
|
||||||
|
const [ticketsDiagnostics, setTicketsDiagnostics] = useState<AdminDataWarning[]>([]);
|
||||||
const [updatingTicketId, setUpdatingTicketId] = useState<string | null>(null);
|
const [updatingTicketId, setUpdatingTicketId] = useState<string | null>(null);
|
||||||
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>("all");
|
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>("all");
|
||||||
const [ticketCategoryFilter, setTicketCategoryFilter] = useState<TicketCategoryFilter>("all");
|
const [ticketCategoryFilter, setTicketCategoryFilter] = useState<TicketCategoryFilter>("all");
|
||||||
|
|
@ -867,9 +963,13 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
search: ticketSearch,
|
search: ticketSearch,
|
||||||
windowDays: 30,
|
windowDays: 30,
|
||||||
};
|
};
|
||||||
setTickets(await adminApi.tickets(filters));
|
const next = await adminApi.tickets(filters);
|
||||||
|
const { response: normalized, warnings } = normalizeAdminTicketsResponse(next);
|
||||||
|
setTickets(normalized);
|
||||||
|
setTicketsDiagnostics(warnings);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setTicketsError(err instanceof Error ? err.message : "운영 티켓을 불러오지 못했습니다.");
|
setTicketsError(err instanceof Error ? err.message : "운영 티켓을 불러오지 못했습니다.");
|
||||||
|
setTicketsDiagnostics([]);
|
||||||
} finally {
|
} finally {
|
||||||
setTicketsLoading(false);
|
setTicketsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1199,20 +1299,23 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
const hasUserSearch = userSearch.trim().length > 0;
|
const hasUserSearch = userSearch.trim().length > 0;
|
||||||
const visibleUsers = filteredUsers.slice(0, MAX_RENDERED_USERS);
|
const visibleUsers = filteredUsers.slice(0, MAX_RENDERED_USERS);
|
||||||
const renderAdminDiagnostics = () =>
|
const renderAdminDiagnostics = () =>
|
||||||
usersDiagnostics.length > 0 ? (
|
[...usersDiagnostics, ...ticketsDiagnostics].length > 0 ? (
|
||||||
<AdminDiagnosticPanel
|
<AdminDiagnosticPanel
|
||||||
title="관리자 데이터 진단"
|
title="관리자 데이터 진단"
|
||||||
body="/admin/users 응답 일부가 화면 계약과 달라 기본값으로 보정했습니다. 빈 화면 대신 어떤 필드가 문제인지 표시합니다."
|
body="관리자 API 응답 일부가 화면 계약과 달라 기본값으로 보정했습니다. 빈 화면 대신 어떤 필드가 문제인지 표시합니다."
|
||||||
details={[
|
details={[
|
||||||
["section", section],
|
["section", section],
|
||||||
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
|
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
|
||||||
["asset", runtimeAssetLabel()],
|
["asset", runtimeAssetLabel()],
|
||||||
...usersDiagnostics.slice(0, 8).map((warning): [string, string] => [
|
...[...usersDiagnostics, ...ticketsDiagnostics].slice(0, 8).map((warning): [string, string] => [
|
||||||
warning.key,
|
warning.key,
|
||||||
`${warning.message} (${warning.detail})`,
|
`${warning.message} (${warning.detail})`,
|
||||||
]),
|
]),
|
||||||
...(usersDiagnostics.length > 8
|
...(usersDiagnostics.length + ticketsDiagnostics.length > 8
|
||||||
? [["more", `${usersDiagnostics.length - 8}개 추가 진단 생략`] as [string, string]]
|
? [["more", `${usersDiagnostics.length + ticketsDiagnostics.length - 8}개 추가 진단 생략`] as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
]]
|
||||||
: []),
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
- M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속.
|
- M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속.
|
||||||
- 페르소나 저작/P4~P7 적재 2차: `data/personas/P4.json`~`P7.json`을 저장소 관리 `PersonaCard`로 읽어 `materialize_seed_personas()`와 seed fallback catalog에 포함했다. 검증: `python -m pytest app/test_persona_review.py -q` 22 passed, 로컬 dev DB materialize 결과 `approved_codes=P1,P2,P3,P4,P5,P6,P7`, 인증된 로컬 `GET /personas`도 `P1..P7` 반환.
|
- 페르소나 저작/P4~P7 적재 2차: `data/personas/P4.json`~`P7.json`을 저장소 관리 `PersonaCard`로 읽어 `materialize_seed_personas()`와 seed fallback catalog에 포함했다. 검증: `python -m pytest app/test_persona_review.py -q` 22 passed, 로컬 dev DB materialize 결과 `approved_codes=P1,P2,P3,P4,P5,P6,P7`, 인증된 로컬 `GET /personas`도 `P1..P7` 반환.
|
||||||
- Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다.
|
- Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다.
|
||||||
- 관리자 기본 진입 경로와 blank diagnostics: `initialPathForUser()`가 pending/onboarding/admin-entitled landing을 소유한다. 관리자 콘솔 접근권이 있는 계정은 primary role이 learner/teacher여도 `/` 또는 로그인 완료 뒤 `/admin`으로 들어간다. `App.tsx`는 pathname 변경마다 document scroll을 0으로 복원해 긴 관리자 화면에서 짧은 `/admin/access`로 이동해도 sticky shell만 보이는 빈 화면을 만들지 않는다. 관리자 화면은 `/admin/users` payload 계약 위반을 안전하게 정규화하고 `관리자 데이터 진단`으로 깨진 필드/path/asset을 표시하며, route render error와 0-height main도 진단 패널로 대체한다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"` 4 passed, custom domain 새 asset 확인.
|
- 관리자 기본 진입 경로와 blank diagnostics: `initialPathForUser()`가 pending/onboarding/admin-entitled landing을 소유한다. 관리자 콘솔 접근권이 있는 계정은 primary role이 learner/teacher여도 `/` 또는 로그인 완료 뒤 `/admin`으로 들어간다. `App.tsx`는 pathname 변경마다 document scroll을 0으로 복원해 긴 관리자 화면에서 짧은 `/admin/access`로 이동해도 sticky shell만 보이는 빈 화면을 만들지 않는다. 관리자 화면은 `/admin/users`와 `/admin/tickets` payload 계약 위반을 안전하게 정규화하고 `관리자 데이터 진단`으로 깨진 필드/path/asset을 표시한다. 앱 route-level error boundary도 렌더 예외를 full white page 대신 진단 화면으로 대체하며, 0-height main도 진단 패널로 노출한다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"` 5 passed, custom domain 새 asset 확인.
|
||||||
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
|
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
|
||||||
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다.
|
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다.
|
||||||
- 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010`도 `ENVIRONMENT=dev`로 떠 있다.
|
- 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010`도 `ENVIRONMENT=dev`로 떠 있다.
|
||||||
|
|
|
||||||
|
|
@ -961,9 +961,9 @@
|
||||||
<tr><td>Engine session reuse</td><td><code>engine_gateway.test_gateway_model</code></td><td>27 tests OK; shared engine contract, JSON Schema + golden fixture validation, Node.js artifact conformance runner, <code>GenerateResponse</code> response validation, structured payload fallback parser, current-turn prompt split, direct evaluator/live-coach parser ownership, SSE frames/decoder, live session_id reuse, <code>gateway-default</code> 기본 라우팅 sentinel 정규화, missing-user 400 guard, ephemeral close fixed</td></tr>
|
<tr><td>Engine session reuse</td><td><code>engine_gateway.test_gateway_model</code></td><td>27 tests OK; shared engine contract, JSON Schema + golden fixture validation, Node.js artifact conformance runner, <code>GenerateResponse</code> response validation, structured payload fallback parser, current-turn prompt split, direct evaluator/live-coach parser ownership, SSE frames/decoder, live session_id reuse, <code>gateway-default</code> 기본 라우팅 sentinel 정규화, missing-user 400 guard, ephemeral close fixed</td></tr>
|
||||||
<tr><td>P1/H4 masking gate</td><td><code>app/test_pii_masking_eval.py app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py</code> / <code>PLAYWRIGHT_PORT=5244 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "Korean PII"</code></td><td>47 passed; 최신 보정 회귀 59 passed + DB-backed browser PII E2E 1 passed. phone/email/RRN 및 한국어 NAME/ORG raw 값이 generate/stream/evaluator payload와 client <code>text_masked</code>에 남지 않음. Optional ko recognizer fake span은 <code>[NAME]</code>/<code>[ORG]</code>로 마스킹되고 같은 문장의 phone은 후단 regex가 처리하며, adapter 실패 시에도 regex fallback이 유지된다. Synthetic ko fixture 16/16 pass, 자연 발화형 이름 라벨·자기소개와 negative control 포함, input/report schema validation, summary-only report(<code>evidence_text_included=false</code>), entity recall 1.0, forbidden substring removal 1.0, unexpected entity violations 0. 최신 브라우저 stream E2E는 한국어 이름·기관·전화번호 발화가 DB-backed <code>GET /sessions/{id}</code> 상세와 <code>/review</code> 모두에서 raw 값 없이 <code>[NAME]</code>/<code>[ORG]</code>/<code>[PHONE]</code>으로 남는지 확인한다.</td></tr>
|
<tr><td>P1/H4 masking gate</td><td><code>app/test_pii_masking_eval.py app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py</code> / <code>PLAYWRIGHT_PORT=5244 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "Korean PII"</code></td><td>47 passed; 최신 보정 회귀 59 passed + DB-backed browser PII E2E 1 passed. phone/email/RRN 및 한국어 NAME/ORG raw 값이 generate/stream/evaluator payload와 client <code>text_masked</code>에 남지 않음. Optional ko recognizer fake span은 <code>[NAME]</code>/<code>[ORG]</code>로 마스킹되고 같은 문장의 phone은 후단 regex가 처리하며, adapter 실패 시에도 regex fallback이 유지된다. Synthetic ko fixture 16/16 pass, 자연 발화형 이름 라벨·자기소개와 negative control 포함, input/report schema validation, summary-only report(<code>evidence_text_included=false</code>), entity recall 1.0, forbidden substring removal 1.0, unexpected entity violations 0. 최신 브라우저 stream E2E는 한국어 이름·기관·전화번호 발화가 DB-backed <code>GET /sessions/{id}</code> 상세와 <code>/review</code> 모두에서 raw 값 없이 <code>[NAME]</code>/<code>[ORG]</code>/<code>[PHONE]</code>으로 남는지 확인한다.</td></tr>
|
||||||
<tr><td>P2a RBAC/audit/visibility</td><td><code>app.test_rbac_idor</code></td><td>11 tests OK; other learner 403, read_session audit, evaluator-only hidden, teacher session review read allowed, super_admin learner review principal is promoted to admin for evaluation/worksheet/review-status loaders, admin_access-only learner remains blocked, and learner worksheet write remains 403</td></tr>
|
<tr><td>P2a RBAC/audit/visibility</td><td><code>app.test_rbac_idor</code></td><td>11 tests OK; other learner 403, read_session audit, evaluator-only hidden, teacher session review read allowed, super_admin learner review principal is promoted to admin for evaluation/worksheet/review-status loaders, admin_access-only learner remains blocked, and learner worksheet write remains 403</td></tr>
|
||||||
<tr><td>Admin access delegation</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_admin_ops.py app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_admin_console_even_with_stale_access_flag app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_all_role_spaces_without_super_admin app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_access_flag_without_admin_role_does_not_enter_learner_space -q</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --grep "approved admin without onboarding"</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"</code> / <code>npm run typecheck</code></td><td>13 backend focused passed + admin onboarding guard E2E 2 passed + latest admin route guards 4 passed + web typecheck/build passed. 실제 <code>admin</code> role은 <code>admin_access=false</code> 세션이어도 <code>can_access_role(Role.ADMIN)</code>으로 관리자 API를 열고, 승인된 관리자 또는 <code>admin_access</code> 계정은 <code>/admin*</code>에서 학습자 온보딩으로 우회하지 않는다. 최초 진입 경로도 <code>initialPathForUser</code>가 소유해 primary role이 learner/teacher인 관리자 권한 계정이 <code>/</code> 또는 로그인 완료 후 <code>/admin</code>으로 들어간다. 관리자 sidebar와 기존 workspace 전환 계약은 유지된다.</td></tr>
|
<tr><td>Admin access delegation</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_admin_ops.py app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_admin_console_even_with_stale_access_flag app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_all_role_spaces_without_super_admin app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_access_flag_without_admin_role_does_not_enter_learner_space -q</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --grep "approved admin without onboarding"</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"</code> / <code>npm run typecheck</code></td><td>13 backend focused passed + admin onboarding guard E2E 2 passed + latest admin route guards 5 passed + web typecheck/build passed. 실제 <code>admin</code> role은 <code>admin_access=false</code> 세션이어도 <code>can_access_role(Role.ADMIN)</code>으로 관리자 API를 열고, 승인된 관리자 또는 <code>admin_access</code> 계정은 <code>/admin*</code>에서 학습자 온보딩으로 우회하지 않는다. 최초 진입 경로도 <code>initialPathForUser</code>가 소유해 primary role이 learner/teacher인 관리자 권한 계정이 <code>/</code> 또는 로그인 완료 후 <code>/admin</code>으로 들어간다. 관리자 sidebar와 기존 workspace 전환 계약은 유지된다.</td></tr>
|
||||||
<tr><td>SPA route scroll reset</td><td><code>npm run typecheck</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "resets scroll"</code> / Pages deploy</td><td>Typecheck passed and focused E2E 1 passed. <code>App.tsx</code> resets document scroll to top on <code>pathname</code> changes, so moving from a long admin page to short <code>/admin/access</code> no longer preserves stale <code>scrollY</code> and shows only the sticky topbar/sidebar over an empty main area. Probe proof: previous <code>scrollY=1000</code>, after clicking <code>권한</code> <code>scrollY=0</code>, heading <code>역할, 그룹, 접근 범위</code> in viewport. Live custom domain now serves <code>assets/index-DY61vYjT.js</code>/<code>assets/index-DzdAkRsn.css</code>.</td></tr>
|
<tr><td>SPA route scroll reset</td><td><code>npm run typecheck</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "resets scroll"</code> / Pages deploy</td><td>Typecheck passed and focused E2E 1 passed. <code>App.tsx</code> resets document scroll to top on <code>pathname</code> changes, so moving from a long admin page to short <code>/admin/access</code> no longer preserves stale <code>scrollY</code> and shows only the sticky topbar/sidebar over an empty main area. Probe proof: previous <code>scrollY=1000</code>, after clicking <code>권한</code> <code>scrollY=0</code>, heading <code>역할, 그룹, 접근 범위</code> in viewport. Live custom domain now serves <code>assets/index-DY61vYjT.js</code>/<code>assets/index-DzdAkRsn.css</code>.</td></tr>
|
||||||
<tr><td>Admin silent-blank diagnostics</td><td><code>npm run typecheck</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"</code></td><td>Typecheck passed and route-guard E2E 4 passed. Admin now normalizes malformed <code>/admin/users</code> payload fields such as <code>cohort_ids=null</code>, <code>active_sessions=null</code>, and <code>created_at=null</code> instead of throwing during render. The screen shows <code>관리자 데이터 진단</code> with section/path/asset and broken field keys, while route render errors and zero-height main content fall back to a diagnostic panel instead of a blank shell.</td></tr>
|
<tr><td>Admin silent-blank diagnostics</td><td><code>npm run typecheck</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"</code></td><td>Typecheck passed and route-guard E2E 5 passed. Admin now normalizes malformed <code>/admin/users</code> payload fields such as <code>cohort_ids=null</code>, <code>active_sessions=null</code>, and <code>created_at=null</code>, plus malformed <code>/admin/tickets</code> payloads such as <code>summary=null</code>, instead of throwing during render. The screen shows <code>관리자 데이터 진단</code> with section/path/asset and broken field keys. App-level route render errors now fall back to a full-page diagnostic instead of a white screen, and zero-height main content still falls back to the admin diagnostic panel.</td></tr>
|
||||||
<tr><td>OAuth/SAML admin landing</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_auth_providers.py -q</code> / public runtime restart</td><td>36 passed; OAuth/SAML callback now normalizes generic saved <code>next</code> paths such as <code>/learn</code> and <code>/teach</code> to <code>/admin</code> for admin-entitled users while preserving deep links such as <code>/learn/session/...</code>. Live proof after commits <code>998acb44</code>/<code>6b6241f4</code> and latest admin diagnostic deploy <code>50524f14</code>: local/public <code>/health</code> returned <code>prod</code>, <code>db=true</code>, <code>engine=true</code>; custom domain now serves <code>assets/index-DY61vYjT.js</code>/<code>assets/index-DzdAkRsn.css</code>; unauthenticated <code>/personas</code> remains <code>401</code>.</td></tr>
|
<tr><td>OAuth/SAML admin landing</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_auth_providers.py -q</code> / public runtime restart</td><td>36 passed; OAuth/SAML callback now normalizes generic saved <code>next</code> paths such as <code>/learn</code> and <code>/teach</code> to <code>/admin</code> for admin-entitled users while preserving deep links such as <code>/learn/session/...</code>. Live proof after commits <code>998acb44</code>/<code>6b6241f4</code> and latest admin diagnostic deploy <code>50524f14</code>: local/public <code>/health</code> returned <code>prod</code>, <code>db=true</code>, <code>engine=true</code>; custom domain now serves <code>assets/index-DY61vYjT.js</code>/<code>assets/index-DzdAkRsn.css</code>; unauthenticated <code>/personas</code> remains <code>401</code>.</td></tr>
|
||||||
<tr><td>Session read-model DB readiness</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / <code>https://api-vignette.chanpaca.net/health</code></td><td>2026-06-29 prod 503 원인은 운영 DB의 <code>app.turns.provider_events</code> 컬럼 누락이었다. 운영 DB hotfix 후 <code>/teacher/dashboard</code> code path는 <code>source=database</code>로 복구됐다. 현재 <code>db.healthcheck()</code>, runtime table readiness, deploy preflight DB mode는 <code>app.turns</code> 음성 메타 컬럼 5개(<code>audio_ref</code>, <code>silence_ms</code>, <code>speech_rate</code>, <code>barge_in</code>, <code>provider_events</code>), <code>app.session_review_status</code> worksheet 컬럼, <code>app.safety_events</code> 필수 컬럼을 함께 검증한다. 2026-06-30에는 Docker Desktop/DB 중단으로 public API 530/error code 1033이 재발했지만, Docker Desktop/DB 재기동 뒤 public health가 <code>environment=prod</code>, <code>db=true</code>, <code>engine=true</code>로 복구됐다.</td></tr>
|
<tr><td>Session read-model DB readiness</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / <code>https://api-vignette.chanpaca.net/health</code></td><td>2026-06-29 prod 503 원인은 운영 DB의 <code>app.turns.provider_events</code> 컬럼 누락이었다. 운영 DB hotfix 후 <code>/teacher/dashboard</code> code path는 <code>source=database</code>로 복구됐다. 현재 <code>db.healthcheck()</code>, runtime table readiness, deploy preflight DB mode는 <code>app.turns</code> 음성 메타 컬럼 5개(<code>audio_ref</code>, <code>silence_ms</code>, <code>speech_rate</code>, <code>barge_in</code>, <code>provider_events</code>), <code>app.session_review_status</code> worksheet 컬럼, <code>app.safety_events</code> 필수 컬럼을 함께 검증한다. 2026-06-30에는 Docker Desktop/DB 중단으로 public API 530/error code 1033이 재발했지만, Docker Desktop/DB 재기동 뒤 public health가 <code>environment=prod</code>, <code>db=true</code>, <code>engine=true</code>로 복구됐다.</td></tr>
|
||||||
<tr><td>Admin usage persistence</td><td><code>python -m pytest app/test_admin_ops.py app/test_runtime_policy.py -q</code> / authenticated local public-API smoke</td><td>26 passed; <code>/admin/usage</code> returns 200 with <code>source=database</code>, <code>durable=true</code>. Cloudflare blocked raw Python public smoke with 1010, so app-level HTTP was verified against <code>127.0.0.1:8001</code> using the same prod process.</td></tr>
|
<tr><td>Admin usage persistence</td><td><code>python -m pytest app/test_admin_ops.py app/test_runtime_policy.py -q</code> / authenticated local public-API smoke</td><td>26 passed; <code>/admin/usage</code> returns 200 with <code>source=database</code>, <code>durable=true</code>. Cloudflare blocked raw Python public smoke with 1010, so app-level HTTP was verified against <code>127.0.0.1:8001</code> using the same prod process.</td></tr>
|
||||||
|
|
|
||||||
|
|
@ -727,9 +727,11 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`):
|
||||||
- SPA 라우트 전환은 `App.tsx`가 `pathname` 변경마다 문서 스크롤을 맨 위로 복원한다. 긴 관리자 목록에서
|
- SPA 라우트 전환은 `App.tsx`가 `pathname` 변경마다 문서 스크롤을 맨 위로 복원한다. 긴 관리자 목록에서
|
||||||
짧은 `/admin/access` 정책 화면으로 이동할 때 이전 `scrollY`가 유지되어 sticky 셸만 보이는 빈 화면을 막기 위한 계약이다.
|
짧은 `/admin/access` 정책 화면으로 이동할 때 이전 `scrollY`가 유지되어 sticky 셸만 보이는 빈 화면을 막기 위한 계약이다.
|
||||||
- 관리자 콘솔은 shell만 남고 본문이 비는 silent failure를 허용하지 않는다. `/admin/users` 응답의
|
- 관리자 콘솔은 shell만 남고 본문이 비는 silent failure를 허용하지 않는다. `/admin/users` 응답의
|
||||||
`cohort_ids`, `active_sessions`, `created_at` 같은 필드가 런타임 계약과 다르면 프론트가 안전한 기본값으로
|
`cohort_ids`, `active_sessions`, `created_at`와 `/admin/tickets` 응답의 `summary`, `by_status`,
|
||||||
정규화하고 `관리자 데이터 진단` 패널에 깨진 필드와 asset/path를 표시한다. 렌더 예외나 본문 0-height 상태도
|
`by_priority` 같은 필드가 런타임 계약과 다르면 프론트가 안전한 기본값으로 정규화하고 `관리자 데이터 진단`
|
||||||
route-scoped diagnostic panel로 노출해 운영자가 원인 없이 빈칸만 보지 않게 한다.
|
패널에 깨진 필드와 asset/path를 표시한다. 앱 route-level error boundary는 관리자 컴포넌트 바깥에서
|
||||||
|
터진 렌더 예외도 full white page 대신 진단 화면으로 노출하고, 본문 0-height 상태도 route-scoped
|
||||||
|
diagnostic panel로 표시해 운영자가 원인 없이 빈칸만 보지 않게 한다.
|
||||||
- OAuth/SAML callback은 저장된 `next`가 일반 진입 경로(`/`, `/learn`, `/teach`, `/login`, `/onboarding`)이고
|
- OAuth/SAML callback은 저장된 `next`가 일반 진입 경로(`/`, `/learn`, `/teach`, `/login`, `/onboarding`)이고
|
||||||
로그인 사용자가 관리자 콘솔 접근권을 가지면 `/admin`으로 정규화한다. 단, `/learn/session/...` 같은 깊은
|
로그인 사용자가 관리자 콘솔 접근권을 가지면 `/admin`으로 정규화한다. 단, `/learn/session/...` 같은 깊은
|
||||||
링크는 사용자가 의도적으로 연 URL일 수 있으므로 보존한다.
|
링크는 사용자가 의도적으로 연 URL일 수 있으므로 보존한다.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue