관리자 워크스페이스 접근 확장과 소유자 결정 7건 확정 반영
- 관리자(role=admin)가 학습자·교수자·관리자 워크스페이스를 모두 접근하도록 can_access_role/require_role와 프론트 auth 헬퍼·Sidebar 내비를 정리하고 admin 워크스페이스 내비 E2E를 추가. - 소유자 결정 7건 전건 확정(2026-06-30)을 SSOT 대시보드·백로그에 반영하고 결정 필요 7→0으로 동기화. SSOT drift 게이트 기대 카운트도 갱신. - 확정된 H1 평가설계(κ≥0.70·ICC≥0.75·환각률≤0.03·t-검정 α=0.05·무작위 배정)를 approved-export κ 게이트(checker·dataset_export·recursive export)와 KPI 측정계획·export manifest 문서에 반영. 검증: npm run typecheck, npm run check:api-types, 백엔드 pytest 290 passed, admin 내비 E2E 1 passed, 레이아웃 시각게이트 9/9, session-layout 4 passed, SSOT drift 게이트 PASS.
This commit is contained in:
parent
1274ba9ccc
commit
4e6b0045e3
17 changed files with 110 additions and 53 deletions
|
|
@ -58,6 +58,8 @@ class Principal:
|
|||
return True
|
||||
if self.super_admin:
|
||||
return True
|
||||
if self.role == Role.ADMIN:
|
||||
return True
|
||||
if role == Role.ADMIN:
|
||||
return self.admin_access
|
||||
return False
|
||||
|
|
@ -131,9 +133,11 @@ def require_role(*allowed: Role):
|
|||
) -> Principal:
|
||||
if principal.role in allowed:
|
||||
return principal
|
||||
if principal.super_admin:
|
||||
effective = Role.ADMIN if Role.ADMIN in allowed else allowed[0]
|
||||
return principal.with_role(effective)
|
||||
if principal.super_admin and Role.ADMIN in allowed:
|
||||
return principal.with_role(Role.ADMIN)
|
||||
for role in allowed:
|
||||
if principal.can_access_role(role):
|
||||
return principal.with_role(role)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"role {principal.role.value} not permitted",
|
||||
|
|
|
|||
|
|
@ -1065,7 +1065,7 @@ async def accept_consent(
|
|||
principal: CurrentPrincipal,
|
||||
) -> ConsentResponse:
|
||||
"""Record the current learner's practice-session consent receipt."""
|
||||
if principal.role != Role.LEARNER and principal.super_admin:
|
||||
if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER):
|
||||
principal = principal.with_role(Role.LEARNER)
|
||||
if principal.role != Role.LEARNER:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
|
||||
|
|
@ -1081,7 +1081,7 @@ async def accept_consent(
|
|||
@router.delete("/consent", response_model=ConsentResponse)
|
||||
async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse:
|
||||
"""Withdraw practice-session consent until the learner accepts again."""
|
||||
if principal.role != Role.LEARNER and principal.super_admin:
|
||||
if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER):
|
||||
principal = principal.with_role(Role.LEARNER)
|
||||
if principal.role != Role.LEARNER:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"})
|
||||
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
|
||||
return
|
||||
if principal.role != Role.LEARNER and principal.super_admin:
|
||||
if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER):
|
||||
principal = principal.with_role(Role.LEARNER)
|
||||
if principal.role != Role.LEARNER:
|
||||
await _safe_send_json(websocket, {"type": "error", "detail": "only learners can use voice"})
|
||||
|
|
|
|||
|
|
@ -389,8 +389,8 @@ def validate_manifest_gate(manifest: Mapping[str, Any]) -> None:
|
|||
approvals = manifest.get("approvals") or {}
|
||||
if pii_scan.get("status") != "pass":
|
||||
errors.append("PII scan must pass")
|
||||
if (agreement.get("kappa") or 0) < 0.60:
|
||||
errors.append("kappa must be >= 0.60")
|
||||
if (agreement.get("kappa") or 0) < 0.70:
|
||||
errors.append("kappa must be >= 0.70")
|
||||
if (agreement.get("icc") or 0) < 0.75:
|
||||
errors.append("ICC must be >= 0.75")
|
||||
for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"):
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ class Phase3ArtifactCheckerTests(unittest.TestCase):
|
|||
"direct_identifier_policy": "blocked",
|
||||
},
|
||||
"pii_scan": {"status": "pass"},
|
||||
"agreement": {"kappa": 0.60, "icc": 0.75},
|
||||
"agreement": {"kappa": 0.70, "icc": 0.75},
|
||||
"files": [
|
||||
{
|
||||
"path": "03-export/anonymized_dataset.jsonl",
|
||||
|
|
@ -167,7 +167,7 @@ class Phase3ArtifactCheckerTests(unittest.TestCase):
|
|||
manifest_path = root / "03-export" / "export_manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["pii_scan"]["status"] = "pending"
|
||||
manifest["agreement"]["kappa"] = 0.59
|
||||
manifest["agreement"]["kappa"] = 0.69
|
||||
manifest["agreement"]["icc"] = 0.74
|
||||
manifest["selection_criteria"]["include_withdrawn"] = True
|
||||
manifest["consent_scope"]["allowed_uses"] = ["education_quality_review"]
|
||||
|
|
@ -177,7 +177,7 @@ class Phase3ArtifactCheckerTests(unittest.TestCase):
|
|||
|
||||
errors = "\n".join(report.errors)
|
||||
self.assertIn("pii_scan.status='pass'", errors)
|
||||
self.assertIn("agreement.kappa >= 0.60", errors)
|
||||
self.assertIn("agreement.kappa >= 0.70", errors)
|
||||
self.assertIn("agreement.icc >= 0.75", errors)
|
||||
self.assertIn("include_withdrawn=false", errors)
|
||||
self.assertIn("recursive_learning_seed consent scope", errors)
|
||||
|
|
|
|||
|
|
@ -146,6 +146,8 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
|
|||
"has_engine_config": False,
|
||||
"has_session_columns": False,
|
||||
"has_state_columns": False,
|
||||
"has_turn_provider_events": False,
|
||||
"has_session_review_worksheet_columns": False,
|
||||
"has_stage_defs": False,
|
||||
"has_admin_health_event": False,
|
||||
"has_admin_health_daily_rollup": False,
|
||||
|
|
|
|||
|
|
@ -554,6 +554,33 @@ test.describe("admin route", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("shows every workspace entry in the admin navigation", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await signInAsAdmin(page);
|
||||
|
||||
await page.goto("/admin");
|
||||
const nav = page.locator(".vg-nav");
|
||||
await expect(nav.getByRole("link", { name: "운영 홈" })).toBeVisible();
|
||||
await expect(nav.getByRole("link", { name: "사용자" })).toBeVisible();
|
||||
await expect(nav.getByRole("link", { name: "권한" })).toBeVisible();
|
||||
await expect(nav.getByRole("link", { name: "티켓" })).toBeVisible();
|
||||
await expect(nav.getByRole("link", { name: "교수 콘솔" })).toBeVisible();
|
||||
await expect(nav.getByRole("link", { name: "페르소나" })).toBeVisible();
|
||||
await expect(nav.getByRole("link", { name: "학습자 홈" })).toBeVisible();
|
||||
await expect(nav.getByRole("link", { name: "학습", exact: true })).toBeVisible();
|
||||
await expect(nav.getByRole("link", { name: "기록" })).toBeVisible();
|
||||
|
||||
await nav.getByRole("link", { name: "교수 콘솔" }).click();
|
||||
await expect(page).toHaveURL(/\/teach$/);
|
||||
await expect(page.locator(".pf-root")).toBeVisible();
|
||||
|
||||
await page.goto("/admin");
|
||||
await page.locator(".vg-nav").getByRole("link", { name: "학습자 홈" }).click();
|
||||
await expect(page).toHaveURL(/\/learn$/);
|
||||
await expect(page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("allows an admin to manage real server-known users", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
await signInAsAdmin(page);
|
||||
|
|
|
|||
|
|
@ -27,11 +27,15 @@ const NAV_BY_ROLE: Record<Role, NavItem[]> = {
|
|||
{ to: "/settings", label: "설정", icon: "settings" },
|
||||
],
|
||||
admin: [
|
||||
{ to: "/admin", label: "운영", icon: "shield", end: true },
|
||||
{ to: "/admin", label: "운영 홈", icon: "shield", end: true },
|
||||
{ to: "/admin/users", label: "사용자", icon: "users" },
|
||||
{ to: "/admin/access", label: "권한", icon: "settings" },
|
||||
{ to: "/admin/tickets", label: "티켓", icon: "review" },
|
||||
{ to: "/teach", label: "교수 콘솔", icon: "users", end: true },
|
||||
{ to: "/teach/personas", label: "페르소나", icon: "review" },
|
||||
{ to: "/learn", label: "학습자 홈", icon: "home", end: true },
|
||||
{ to: "/learn/practice", label: "학습", icon: "session" },
|
||||
{ to: "/learn/history", label: "기록", icon: "review" },
|
||||
{ to: "/settings", label: "설정", icon: "settings" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
|
||||
import { accessibleRolesFor, canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
|
||||
import { applyTheme, readInitialTheme, type AppTheme } from "../../lib/theme";
|
||||
|
||||
/** Vignette 워드마크 — 비네트(조리개) inline SVG. dev_dashboard 마크 계승. */
|
||||
|
|
@ -49,11 +49,7 @@ export function Topbar({ contextLabel }: TopbarProps) {
|
|||
const [dark, toggleTheme] = useTheme();
|
||||
|
||||
const label = contextLabel ?? (user ? roleLabel(user.role) : null);
|
||||
const switchRoles: Role[] = user?.superAdmin
|
||||
? ["learner", "teacher", "admin"]
|
||||
: user?.adminAccess
|
||||
? ["admin"]
|
||||
: [];
|
||||
const switchRoles: Role[] = user ? accessibleRolesFor(user) : [];
|
||||
|
||||
const onLogout = async () => {
|
||||
await logout();
|
||||
|
|
|
|||
|
|
@ -70,9 +70,16 @@ export function roleHomePath(role: Role): string {
|
|||
export function canAccessRole(user: AuthUser, role: Role): boolean {
|
||||
if (user.role === role) return true;
|
||||
if (user.superAdmin) return true;
|
||||
if (user.role === "admin") return true;
|
||||
return role === "admin" && user.adminAccess;
|
||||
}
|
||||
|
||||
export function accessibleRolesFor(user: AuthUser): Role[] {
|
||||
if (user.superAdmin || user.role === "admin") return ["learner", "teacher", "admin"];
|
||||
if (user.adminAccess) return ["admin"];
|
||||
return [];
|
||||
}
|
||||
|
||||
const DEV_EMAIL_BY_ROLE: Record<Role, string> = {
|
||||
learner: "learner@hs.ac.kr",
|
||||
teacher: "teacher@hs.ac.kr",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue