관리자 워크스페이스 접근 확장과 소유자 결정 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:
Yun Chan 2026-06-30 10:57:46 +09:00
parent 1274ba9ccc
commit 4e6b0045e3
17 changed files with 110 additions and 53 deletions

View file

@ -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);

View file

@ -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" },
],
};

View file

@ -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();

View file

@ -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",