From a5a2e2ea78a50f00088cee4a98013352e219367c Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 3 Jul 2026 20:27:54 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/e2e/admin.spec.ts | 30 ++++++++++++++++++++++++++++++ apps/web/src/App.tsx | 15 ++++++++++++++- docs/dev_dashboard.html | 1 + docs/guides/architecture.md | 2 ++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index 7242de5..85b891a 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -629,6 +629,36 @@ test.describe("admin route guards", () => { .poll(() => Array.from(seenAdminEndpoints).sort()) .toEqual(["health", "tickets", "uptime", "usage", "users"]); }); + + test("resets scroll when moving from long admin pages to access policy", async ({ page }) => { + await mockAdminSession(page, { + user_id: "scroll-admin", + email: "scroll-admin@twentyoz.kr", + display_name: "Scroll Admin", + role: "admin", + admin_access: true, + super_admin: true, + onboarding_completed_at: 1_782_900_000, + }); + + await page.goto("/admin/users"); + await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible(); + const scrolled = await page.evaluate(() => { + document.documentElement.style.minHeight = "2200px"; + document.body.style.minHeight = "2200px"; + window.scrollTo(0, 900); + return window.scrollY; + }); + expect(scrolled).toBeGreaterThan(0); + + await page.locator(".vg-nav").getByRole("link", { name: "권한" }).click(); + + await expect(page).toHaveURL(/\/admin\/access$/); + await expect + .poll(() => page.evaluate(() => window.scrollY)) + .toBe(0); + await expect(page.getByRole("heading", { name: "역할, 그룹, 접근 범위" })).toBeInViewport(); + }); }); test.describe("admin route", () => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cb4a766..03d0b2f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -15,7 +15,7 @@ 보호 라우트는 AuthContext 기반 간단 가드. 과설계 금지. ===================================================================== */ -import type { ReactNode } from "react"; +import { useEffect, 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"; @@ -129,6 +129,18 @@ function RootRedirect() { return ; } +function ScrollToTopOnPathChange() { + const { pathname } = useLocation(); + + useEffect(() => { + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); + document.documentElement.scrollTop = 0; + document.body.scrollTop = 0; + }, [pathname]); + + return null; +} + function AppRoutes() { return ( @@ -298,6 +310,7 @@ export default function App() { return ( + diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index d29e53a..4b257d5 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -962,6 +962,7 @@ 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" / 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 전환 계약은 유지된다. + SPA route scroll resetnpm run typecheck / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "resets scroll"Typecheck passed and focused E2E 1 passed. App.tsx resets document scroll to top on pathname changes, so moving from a long admin page to short /admin/access no longer preserves stale scrollY and shows only the sticky topbar/sidebar over an empty main area. Probe proof: previous scrollY=1000, after clicking 권한 scrollY=0, heading 역할, 그룹, 접근 범위 in viewport. OAuth/SAML admin landingC:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_auth_providers.py -q / public runtime restart36 passed; OAuth/SAML callback now normalizes generic saved next paths such as /learn and /teach to /admin for admin-entitled users while preserving deep links such as /learn/session/.... Live proof after commits 998acb44/6b6241f4: local/public /health returned prod, db=true, engine=true; custom domain now serves assets/index-COxjbe7E.js/assets/index-liGoZae_.css; unauthenticated /personas remains 401. 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. diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index d9e6d74..f7be947 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -724,6 +724,8 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`): - 프론트의 최초 진입 경로(`initialPathForUser`)는 pending이면 `/pending`, 온보딩 미완료 일반 사용자는 `/onboarding`, 관리자 콘솔 접근권이 있는 사용자는 기본 역할이 learner/teacher여도 `/admin`을 우선한다. 역할 전환용 `roleHomePath`는 그대로 역할별 홈(`/learn`, `/teach`, `/admin`)만 소유한다. +- SPA 라우트 전환은 `App.tsx`가 `pathname` 변경마다 문서 스크롤을 맨 위로 복원한다. 긴 관리자 목록에서 + 짧은 `/admin/access` 정책 화면으로 이동할 때 이전 `scrollY`가 유지되어 sticky 셸만 보이는 빈 화면을 막기 위한 계약이다. - OAuth/SAML callback은 저장된 `next`가 일반 진입 경로(`/`, `/learn`, `/teach`, `/login`, `/onboarding`)이고 로그인 사용자가 관리자 콘솔 접근권을 가지면 `/admin`으로 정규화한다. 단, `/learn/session/...` 같은 깊은 링크는 사용자가 의도적으로 연 URL일 수 있으므로 보존한다.