관리자 기본 진입 경로 수정

This commit is contained in:
Yun Chan 2026-07-02 10:52:12 +09:00
parent 778e8526d4
commit bd046c4501
6 changed files with 62 additions and 25 deletions

View file

@ -147,7 +147,18 @@ async function expectResponseOk(response: APIResponse | Response) {
} }
} }
async function mockApprovedAdminWithoutOnboarding(page: Page) { async function mockAdminSession(
page: Page,
authUser: Partial<{
user_id: string;
email: string;
display_name: string;
role: "learner" | "teacher" | "admin";
admin_access: boolean;
super_admin: boolean;
onboarding_completed_at: number | null;
}> = {},
) {
const seenAdminEndpoints = new Set<string>(); const seenAdminEndpoints = new Set<string>();
const json = (body: unknown) => JSON.stringify(body); const json = (body: unknown) => JSON.stringify(body);
@ -165,17 +176,17 @@ async function mockApprovedAdminWithoutOnboarding(page: Page) {
if (method === "GET" && path.endsWith("/auth/me")) { if (method === "GET" && path.endsWith("/auth/me")) {
await fulfillJson({ await fulfillJson({
user_id: "stale-admin", user_id: authUser.user_id ?? "stale-admin",
email: "stale-admin@twentyoz.kr", email: authUser.email ?? "stale-admin@twentyoz.kr",
display_name: "Stale Admin", display_name: authUser.display_name ?? "Stale Admin",
role: "admin", role: authUser.role ?? "admin",
admin_access: false, admin_access: authUser.admin_access ?? false,
super_admin: false, super_admin: authUser.super_admin ?? false,
account_status: "approved", account_status: "approved",
approval_required: false, approval_required: false,
cohort_ids: [], cohort_ids: [],
consent_at: null, consent_at: null,
onboarding_completed_at: null, onboarding_completed_at: authUser.onboarding_completed_at ?? null,
nickname: "", nickname: "",
self_introduction: "", self_introduction: "",
avatar_url: "", avatar_url: "",
@ -275,6 +286,10 @@ async function mockApprovedAdminWithoutOnboarding(page: Page) {
return seenAdminEndpoints; return seenAdminEndpoints;
} }
async function mockApprovedAdminWithoutOnboarding(page: Page) {
return mockAdminSession(page);
}
async function signInAsAdmin(page: Page) { async function signInAsAdmin(page: Page) {
let res: APIResponse | null = null; let res: APIResponse | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) { for (let attempt = 0; attempt < 3; attempt += 1) {
@ -593,6 +608,27 @@ test.describe("admin route guards", () => {
.poll(() => Array.from(seenAdminEndpoints).sort()) .poll(() => Array.from(seenAdminEndpoints).sort())
.toEqual(["health", "tickets", "uptime", "usage", "users"]); .toEqual(["health", "tickets", "uptime", "usage", "users"]);
}); });
test("routes admin-entitled primary-role users from the root to the admin console", async ({ page }) => {
const seenAdminEndpoints = await mockAdminSession(page, {
user_id: "learner-admin",
email: "learner-admin@twentyoz.kr",
display_name: "Learner Admin",
role: "learner",
admin_access: true,
super_admin: false,
onboarding_completed_at: 1_782_900_000,
});
await page.goto("/");
await expect(page).toHaveURL(/\/admin$/);
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 홈" })).toBeVisible();
await expect
.poll(() => Array.from(seenAdminEndpoints).sort())
.toEqual(["health", "tickets", "uptime", "usage", "users"]);
});
}); });
test.describe("admin route", () => { test.describe("admin route", () => {

View file

@ -17,7 +17,7 @@
import type { ReactNode } from "react"; import 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, useAuth, roleHomePath, type AuthUser, type Role } from "./lib/auth"; import { AuthProvider, canAccessRole, initialPathForUser, useAuth, roleHomePath, type AuthUser, type Role } from "./lib/auth";
import Login from "./pages/Login"; import Login from "./pages/Login";
import Onboarding from "./pages/Onboarding"; import Onboarding from "./pages/Onboarding";
@ -79,10 +79,7 @@ function isAdminWorkspacePath(path: string) {
} }
function approvedHomePath(user: AuthUser) { function approvedHomePath(user: AuthUser) {
if (user.onboardingCompletedAt == null) { return initialPathForUser(user);
return canAccessRole(user, "admin") ? "/admin" : "/onboarding";
}
return roleHomePath(user.role);
} }
function OnboardingGate({ children }: { children: ReactNode }) { function OnboardingGate({ children }: { children: ReactNode }) {
@ -99,7 +96,7 @@ function OnboardingGate({ children }: { children: ReactNode }) {
return <Navigate to="/onboarding" replace state={{ from: path }} />; return <Navigate to="/onboarding" replace state={{ from: path }} />;
} }
if (user && user.onboardingCompletedAt != null && (path === "/login" || path === "/onboarding")) { if (user && user.onboardingCompletedAt != null && (path === "/login" || path === "/onboarding")) {
return <Navigate to={roleHomePath(user.role)} replace />; return <Navigate to={initialPathForUser(user)} replace />;
} }
return <>{children}</>; return <>{children}</>;
} }
@ -129,7 +126,7 @@ function RootRedirect() {
if (user && user.onboardingCompletedAt == null) { if (user && user.onboardingCompletedAt == null) {
return <Navigate to={approvedHomePath(user)} replace />; return <Navigate to={approvedHomePath(user)} replace />;
} }
return <Navigate to={user ? roleHomePath(user.role) : "/login"} replace />; return <Navigate to={user ? initialPathForUser(user) : "/login"} replace />;
} }
function AppRoutes() { function AppRoutes() {

View file

@ -74,6 +74,14 @@ export function canAccessRole(user: AuthUser, role: Role): boolean {
return role === "admin" && user.adminAccess; return role === "admin" && user.adminAccess;
} }
export function initialPathForUser(user: AuthUser): string {
if (user.accountStatus !== "approved") return "/pending";
if (user.onboardingCompletedAt == null) {
return canAccessRole(user, "admin") ? "/admin" : "/onboarding";
}
return canAccessRole(user, "admin") ? "/admin" : roleHomePath(user.role);
}
export function accessibleRolesFor(user: AuthUser): Role[] { export function accessibleRolesFor(user: AuthUser): Role[] {
if (user.superAdmin || user.role === "admin") return ["learner", "teacher", "admin"]; if (user.superAdmin || user.role === "admin") return ["learner", "teacher", "admin"];
if (user.adminAccess) return ["admin"]; if (user.adminAccess) return ["admin"];

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { roleHomePath, useAuth, type Role } from "../lib/auth"; import { initialPathForUser, useAuth, type Role } from "../lib/auth";
import { apiUrl, authApi, type AuthConfigResponse } from "../lib/api"; import { apiUrl, authApi, type AuthConfigResponse } from "../lib/api";
import { LoginBrand } from "./login/LoginBrand"; import { LoginBrand } from "./login/LoginBrand";
import { LoginPanel, type LoginRoleOption } from "./login/LoginPanel"; import { LoginPanel, type LoginRoleOption } from "./login/LoginPanel";
@ -172,14 +172,7 @@ export default function Login() {
setLoginErrorReason(null); setLoginErrorReason(null);
try { try {
const signedIn = await login(role); const signedIn = await login(role);
navigate( navigate(initialPathForUser(signedIn), { replace: true });
signedIn.accountStatus !== "approved"
? "/pending"
: signedIn.onboardingCompletedAt == null
? "/onboarding"
: roleHomePath(signedIn.role),
{ replace: true },
);
} catch (err) { } catch (err) {
setLoginError(err instanceof Error ? err.message : "로그인에 실패했습니다."); setLoginError(err instanceof Error ? err.message : "로그인에 실패했습니다.");
} finally { } finally {

View file

@ -960,7 +960,7 @@
<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>npm run typecheck</code></td><td>13 backend focused passed + admin onboarding guard E2E 2 passed + web typecheck 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>에서 학습자 온보딩으로 우회하지 않는다. 관리자 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 2 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>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>
<tr><td>Synthetic health sampler</td><td><code>record_admin_health_sample()</code>, <code>record-admin-health-sample.py</code>, <code>install-health-sampler-task.ps1</code></td><td>Backend focused 31 passed. Python compile/help passed; PowerShell parser + <code>-PrintOnly</code> passed. Local one-shot appended 5 service rows to <code>app.admin_health_event</code>: status ok, engine_mode claude_cli. This remains sample history, not an SLA claim.</td></tr> <tr><td>Synthetic health sampler</td><td><code>record_admin_health_sample()</code>, <code>record-admin-health-sample.py</code>, <code>install-health-sampler-task.ps1</code></td><td>Backend focused 31 passed. Python compile/help passed; PowerShell parser + <code>-PrintOnly</code> passed. Local one-shot appended 5 service rows to <code>app.admin_health_event</code>: status ok, engine_mode claude_cli. This remains sample history, not an SLA claim.</td></tr>

View file

@ -721,6 +721,9 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`):
`AUTH_EMAIL_COHORT_MAP``AUTH_DOMAIN_COHORT_MAP` 설정, SAML fixture의 `cohort` claim을 합쳐 `AUTH_EMAIL_COHORT_MAP``AUTH_DOMAIN_COHORT_MAP` 설정, SAML fixture의 `cohort` claim을 합쳐
`cohort_ids`로 세션에 저장한다. 관리 사용자 `app_user.external_id`는 provider subject 기반 `cohort_ids`로 세션에 저장한다. 관리 사용자 `app_user.external_id`는 provider subject 기반
(`google:`/`saml:`/`dev:`)으로 저장해 email 변경 리스크를 줄인다. (`google:`/`saml:`/`dev:`)으로 저장해 email 변경 리스크를 줄인다.
- 프론트의 최초 진입 경로(`initialPathForUser`)는 pending이면 `/pending`, 온보딩 미완료 일반 사용자는
`/onboarding`, 관리자 콘솔 접근권이 있는 사용자는 기본 역할이 learner/teacher여도 `/admin`을 우선한다.
역할 전환용 `roleHomePath`는 그대로 역할별 홈(`/learn`, `/teach`, `/admin`)만 소유한다.
- `AUTH_ALLOWED_EMAIL_DOMAINS`는 기본 도메인 게이트다. 단, 슈퍼 관리자/관리자가 `/admin/users` - `AUTH_ALLOWED_EMAIL_DOMAINS`는 기본 도메인 게이트다. 단, 슈퍼 관리자/관리자가 `/admin/users`
미리 만든 정확한 이메일은 도메인 밖이어도 Google/SAML/dev-login의 이메일 검증을 통과한다. 미리 만든 정확한 이메일은 도메인 밖이어도 Google/SAML/dev-login의 이메일 검증을 통과한다.
이 예외는 도메인 전체를 열지 않고, provider 로그인 시 기존 `email:<주소>` 관리 row를 이 예외는 도메인 전체를 열지 않고, provider 로그인 시 기존 `email:<주소>` 관리 row를