From bd046c45011eedfbe728aeaf56d1ee66789e38fc Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Thu, 2 Jul 2026 10:52:12 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=A7=84=EC=9E=85=20=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/e2e/admin.spec.ts | 52 ++++++++++++++++++++++++++++++------ apps/web/src/App.tsx | 11 +++----- apps/web/src/lib/auth.tsx | 8 ++++++ apps/web/src/pages/Login.tsx | 11 ++------ docs/dev_dashboard.html | 2 +- docs/guides/architecture.md | 3 +++ 6 files changed, 62 insertions(+), 25 deletions(-) diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index a5dacba..7242de5 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -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(); const json = (body: unknown) => JSON.stringify(body); @@ -165,17 +176,17 @@ async function mockApprovedAdminWithoutOnboarding(page: Page) { if (method === "GET" && path.endsWith("/auth/me")) { await fulfillJson({ - user_id: "stale-admin", - email: "stale-admin@twentyoz.kr", - display_name: "Stale Admin", - role: "admin", - admin_access: false, - super_admin: false, + user_id: authUser.user_id ?? "stale-admin", + email: authUser.email ?? "stale-admin@twentyoz.kr", + display_name: authUser.display_name ?? "Stale Admin", + role: authUser.role ?? "admin", + admin_access: authUser.admin_access ?? false, + super_admin: authUser.super_admin ?? false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: null, - onboarding_completed_at: null, + onboarding_completed_at: authUser.onboarding_completed_at ?? null, nickname: "", self_introduction: "", avatar_url: "", @@ -275,6 +286,10 @@ async function mockApprovedAdminWithoutOnboarding(page: Page) { return seenAdminEndpoints; } +async function mockApprovedAdminWithoutOnboarding(page: Page) { + return mockAdminSession(page); +} + async function signInAsAdmin(page: Page) { let res: APIResponse | null = null; for (let attempt = 0; attempt < 3; attempt += 1) { @@ -593,6 +608,27 @@ test.describe("admin route guards", () => { .poll(() => Array.from(seenAdminEndpoints).sort()) .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", () => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 446ceb6..cb4a766 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -17,7 +17,7 @@ import type { ReactNode } from "react"; 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 Onboarding from "./pages/Onboarding"; @@ -79,10 +79,7 @@ function isAdminWorkspacePath(path: string) { } function approvedHomePath(user: AuthUser) { - if (user.onboardingCompletedAt == null) { - return canAccessRole(user, "admin") ? "/admin" : "/onboarding"; - } - return roleHomePath(user.role); + return initialPathForUser(user); } function OnboardingGate({ children }: { children: ReactNode }) { @@ -99,7 +96,7 @@ function OnboardingGate({ children }: { children: ReactNode }) { return ; } if (user && user.onboardingCompletedAt != null && (path === "/login" || path === "/onboarding")) { - return ; + return ; } return <>{children}; } @@ -129,7 +126,7 @@ function RootRedirect() { if (user && user.onboardingCompletedAt == null) { return ; } - return ; + return ; } function AppRoutes() { diff --git a/apps/web/src/lib/auth.tsx b/apps/web/src/lib/auth.tsx index 36cd43a..bae077a 100644 --- a/apps/web/src/lib/auth.tsx +++ b/apps/web/src/lib/auth.tsx @@ -74,6 +74,14 @@ export function canAccessRole(user: AuthUser, role: Role): boolean { 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[] { if (user.superAdmin || user.role === "admin") return ["learner", "teacher", "admin"]; if (user.adminAccess) return ["admin"]; diff --git a/apps/web/src/pages/Login.tsx b/apps/web/src/pages/Login.tsx index ea1fc16..ffc7972 100644 --- a/apps/web/src/pages/Login.tsx +++ b/apps/web/src/pages/Login.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; 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 { LoginBrand } from "./login/LoginBrand"; import { LoginPanel, type LoginRoleOption } from "./login/LoginPanel"; @@ -172,14 +172,7 @@ export default function Login() { setLoginErrorReason(null); try { const signedIn = await login(role); - navigate( - signedIn.accountStatus !== "approved" - ? "/pending" - : signedIn.onboardingCompletedAt == null - ? "/onboarding" - : roleHomePath(signedIn.role), - { replace: true }, - ); + navigate(initialPathForUser(signedIn), { replace: true }); } catch (err) { setLoginError(err instanceof Error ? err.message : "로그인에 실패했습니다."); } finally { diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 1f68834..c01813a 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -960,7 +960,7 @@ Engine session reuseengine_gateway.test_gateway_model27 tests OK; shared engine contract, JSON Schema + golden fixture validation, Node.js artifact conformance runner, GenerateResponse response validation, structured payload fallback parser, current-turn prompt split, direct evaluator/live-coach parser ownership, SSE frames/decoder, live session_id reuse, gateway-default 기본 라우팅 sentinel 정규화, missing-user 400 guard, ephemeral close fixed P1/H4 masking gateapp/test_pii_masking_eval.py app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py / PLAYWRIGHT_PORT=5244 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "Korean PII"47 passed; 최신 보정 회귀 59 passed + DB-backed browser PII E2E 1 passed. phone/email/RRN 및 한국어 NAME/ORG raw 값이 generate/stream/evaluator payload와 client text_masked에 남지 않음. Optional ko recognizer fake span은 [NAME]/[ORG]로 마스킹되고 같은 문장의 phone은 후단 regex가 처리하며, adapter 실패 시에도 regex fallback이 유지된다. Synthetic ko fixture 16/16 pass, 자연 발화형 이름 라벨·자기소개와 negative control 포함, input/report schema validation, summary-only report(evidence_text_included=false), entity recall 1.0, forbidden substring removal 1.0, unexpected entity violations 0. 최신 브라우저 stream E2E는 한국어 이름·기관·전화번호 발화가 DB-backed GET /sessions/{id} 상세와 /review 모두에서 raw 값 없이 [NAME]/[ORG]/[PHONE]으로 남는지 확인한다. P2a RBAC/audit/visibilityapp.test_rbac_idor11 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 - Admin access delegationC:\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 / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --grep "approved admin without onboarding" / npm run typecheck13 backend focused passed + admin onboarding guard E2E 2 passed + web typecheck passed. 실제 admin role은 admin_access=false 세션이어도 can_access_role(Role.ADMIN)으로 관리자 API를 열고, 승인된 관리자 또는 admin_access 계정은 /admin*에서 학습자 온보딩으로 우회하지 않는다. 관리자 sidebar와 기존 workspace 전환 계약은 유지된다. + Admin access delegationC:\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 / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --grep "approved admin without onboarding" / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards" / npm run typecheck13 backend focused passed + admin onboarding guard E2E 2 passed + latest admin route guards 2 passed + web typecheck/build passed. 실제 admin role은 admin_access=false 세션이어도 can_access_role(Role.ADMIN)으로 관리자 API를 열고, 승인된 관리자 또는 admin_access 계정은 /admin*에서 학습자 온보딩으로 우회하지 않는다. 최초 진입 경로도 initialPathForUser가 소유해 primary role이 learner/teacher인 관리자 권한 계정이 / 또는 로그인 완료 후 /admin으로 들어간다. 관리자 sidebar와 기존 workspace 전환 계약은 유지된다. Session read-model DB readinesspython scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets / https://api-vignette.chanpaca.net/health2026-06-29 prod 503 원인은 운영 DB의 app.turns.provider_events 컬럼 누락이었다. 운영 DB hotfix 후 /teacher/dashboard code path는 source=database로 복구됐다. 현재 db.healthcheck(), runtime table readiness, deploy preflight DB mode는 app.turns 음성 메타 컬럼 5개(audio_ref, silence_ms, speech_rate, barge_in, provider_events), app.session_review_status worksheet 컬럼, app.safety_events 필수 컬럼을 함께 검증한다. 2026-06-30에는 Docker Desktop/DB 중단으로 public API 530/error code 1033이 재발했지만, Docker Desktop/DB 재기동 뒤 public health가 environment=prod, db=true, engine=true로 복구됐다. Admin usage persistencepython -m pytest app/test_admin_ops.py app/test_runtime_policy.py -q / authenticated local public-API smoke26 passed; /admin/usage returns 200 with source=database, durable=true. Cloudflare blocked raw Python public smoke with 1010, so app-level HTTP was verified against 127.0.0.1:8001 using the same prod process. Synthetic health samplerrecord_admin_health_sample(), record-admin-health-sample.py, install-health-sampler-task.ps1Backend focused 31 passed. Python compile/help passed; PowerShell parser + -PrintOnly passed. Local one-shot appended 5 service rows to app.admin_health_event: status ok, engine_mode claude_cli. This remains sample history, not an SLA claim. diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index 6a6f65a..9beb53a 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -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을 합쳐 `cohort_ids`로 세션에 저장한다. 관리 사용자 `app_user.external_id`는 provider subject 기반 (`google:`/`saml:`/`dev:`)으로 저장해 email 변경 리스크를 줄인다. +- 프론트의 최초 진입 경로(`initialPathForUser`)는 pending이면 `/pending`, 온보딩 미완료 일반 사용자는 + `/onboarding`, 관리자 콘솔 접근권이 있는 사용자는 기본 역할이 learner/teacher여도 `/admin`을 우선한다. + 역할 전환용 `roleHomePath`는 그대로 역할별 홈(`/learn`, `/teach`, `/admin`)만 소유한다. - `AUTH_ALLOWED_EMAIL_DOMAINS`는 기본 도메인 게이트다. 단, 슈퍼 관리자/관리자가 `/admin/users`에 미리 만든 정확한 이메일은 도메인 밖이어도 Google/SAML/dev-login의 이메일 검증을 통과한다. 이 예외는 도메인 전체를 열지 않고, provider 로그인 시 기존 `email:<주소>` 관리 row를