forked from baron/baron-sso
권한부여 및 정합성 검사 추가
This commit is contained in:
@@ -22,6 +22,12 @@ describe("admin routes", () => {
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
|
||||
});
|
||||
|
||||
it("registers the super-admin data integrity management route", () => {
|
||||
const matches = matchRoutes(adminRoutes, "/system/data-integrity");
|
||||
|
||||
expect(matches?.at(-1)?.route.path).toBe("system/data-integrity");
|
||||
});
|
||||
|
||||
it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
|
||||
const rootRoute = adminRoutes.find((route) => route.path === "/");
|
||||
const protectedShellRoute = rootRoute?.children?.[0];
|
||||
|
||||
@@ -8,6 +8,7 @@ import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||
import AuthGuard from "../features/auth/AuthGuard";
|
||||
import AuthPage from "../features/auth/AuthPage";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
|
||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import UserProjectionPage from "../features/projections/UserProjectionPage";
|
||||
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||
@@ -66,6 +67,7 @@ export const adminRoutes: RouteObject[] = [
|
||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||
{ path: "system/projections/users", element: <UserProjectionPage /> },
|
||||
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type * as React from "react";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
import { normalizeAdminRole } from "../../lib/roles";
|
||||
|
||||
interface RoleGuardProps {
|
||||
children: React.ReactNode;
|
||||
@@ -29,8 +30,10 @@ export function RoleGuard({
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
const userRole = profile?.role || "user";
|
||||
const hasAccess = roles.includes(userRole);
|
||||
const userRole = normalizeAdminRole(profile?.role);
|
||||
const hasAccess = roles
|
||||
.map((role) => normalizeAdminRole(role))
|
||||
.includes(userRole);
|
||||
|
||||
if (!hasAccess) {
|
||||
return <>{fallback}</>;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Moon,
|
||||
Network,
|
||||
NotebookTabs,
|
||||
ShieldCheck,
|
||||
ShieldHalf,
|
||||
Sun,
|
||||
User as UserIcon,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
shouldAttemptSlidingSessionRenew,
|
||||
shouldAttemptUnlimitedSessionRenew,
|
||||
@@ -150,7 +152,7 @@ function AppLayout() {
|
||||
._IS_TEST_MODE === true;
|
||||
const effectiveRole = mockRoleOverride || profile?.role;
|
||||
|
||||
const isSuperAdmin = isTest || effectiveRole === "super_admin";
|
||||
const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
|
||||
const isTenantAdmin = effectiveRole === "tenant_admin";
|
||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||
@@ -180,6 +182,11 @@ function AppLayout() {
|
||||
to: "/system/projections/users",
|
||||
icon: Database,
|
||||
});
|
||||
filteredItems.splice(5, 0, {
|
||||
label: "ui.admin.nav.data_integrity",
|
||||
to: "/system/data-integrity",
|
||||
icon: ShieldCheck,
|
||||
});
|
||||
} else if (isTenantAdmin || manageableCount > 0) {
|
||||
if (manageableCount <= 1 && profile?.tenantId) {
|
||||
filteredItems.splice(1, 0, {
|
||||
|
||||
81
adminfront/src/features/integrity/DataIntegrityPage.test.tsx
Normal file
81
adminfront/src/features/integrity/DataIntegrityPage.test.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchDataIntegrityReport, fetchMe } from "../../lib/adminApi";
|
||||
import DataIntegrityPage from "./DataIntegrityPage";
|
||||
|
||||
let currentRole = "super_admin";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: currentRole })),
|
||||
fetchDataIntegrityReport: vi.fn(async () => ({
|
||||
status: "fail",
|
||||
checkedAt: "2026-05-14T00:00:00Z",
|
||||
summary: {
|
||||
totalChecks: 2,
|
||||
passed: 1,
|
||||
warnings: 0,
|
||||
failures: 1,
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
key: "tenant_integrity",
|
||||
label: "테넌트 정합성",
|
||||
status: "fail",
|
||||
checks: [
|
||||
{
|
||||
key: "duplicate_tenant_slugs",
|
||||
label: "중복 테넌트 slug",
|
||||
description:
|
||||
"active tenant slug의 대소문자 무시 중복을 검사합니다.",
|
||||
status: "fail",
|
||||
severity: "error",
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DataIntegrityPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("DataIntegrityPage", () => {
|
||||
beforeEach(() => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders integrity report for super_admin", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
|
||||
expect(await screen.findByText("테넌트 정합성")).toBeInTheDocument();
|
||||
expect(screen.getByText("중복 테넌트 slug")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("1").length).toBeGreaterThan(0);
|
||||
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks non-super admins", async () => {
|
||||
currentRole = "tenant_admin";
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
|
||||
expect(fetchMe).toHaveBeenCalled();
|
||||
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
209
adminfront/src/features/integrity/DataIntegrityPage.tsx
Normal file
209
adminfront/src/features/integrity/DataIntegrityPage.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
type DataIntegrityCheck,
|
||||
type DataIntegrityStatus,
|
||||
fetchDataIntegrityReport,
|
||||
} from "../../lib/adminApi";
|
||||
|
||||
function statusLabel(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return "정상";
|
||||
case "warning":
|
||||
return "주의";
|
||||
case "fail":
|
||||
return "실패";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return "success";
|
||||
case "warning":
|
||||
return "warning";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function CheckIcon({ check }: { check: DataIntegrityCheck }) {
|
||||
if (check.status === "pass") {
|
||||
return <CheckCircle2 className="text-emerald-600" size={18} />;
|
||||
}
|
||||
if (check.status === "warning") {
|
||||
return <AlertTriangle className="text-amber-600" size={18} />;
|
||||
}
|
||||
return <ShieldAlert className="text-destructive" size={18} />;
|
||||
}
|
||||
|
||||
function DataIntegrityContent() {
|
||||
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
|
||||
queryKey: ["data-integrity-report"],
|
||||
queryFn: fetchDataIntegrityReport,
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="space-y-6 p-6 md:p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">System</p>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
데이터 정합성 검증
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<Database size={16} />
|
||||
다시 검사
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(error as Error)?.message || "정합성 리포트를 불러오지 못했습니다."}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-primary/10 text-primary">
|
||||
<ShieldAlert size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Read model integrity</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만
|
||||
확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{data ? (
|
||||
<Badge variant={statusBadgeVariant(data.status)}>
|
||||
{statusLabel(data.status)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">불러오는 중</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">검사 항목</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.totalChecks ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">정상</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.passed ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">실패 건수</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.summary.failures ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">검사 시각</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.checkedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(data?.sections ?? []).map((section) => (
|
||||
<section
|
||||
key={section.key}
|
||||
className="rounded-lg border border-border bg-card p-5"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold">{section.label}</h3>
|
||||
<Badge variant={statusBadgeVariant(section.status)}>
|
||||
{statusLabel(section.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{section.checks.map((check) => (
|
||||
<div
|
||||
key={check.key}
|
||||
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<CheckIcon check={check} />
|
||||
<div>
|
||||
<div className="font-medium">{check.label}</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{check.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 md:justify-end">
|
||||
<Badge variant={statusBadgeVariant(check.status)}>
|
||||
{statusLabel(check.status)}
|
||||
</Badge>
|
||||
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
|
||||
{check.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DataIntegrityPage() {
|
||||
return (
|
||||
<RoleGuard
|
||||
roles={["super_admin"]}
|
||||
fallback={
|
||||
<main className="p-6 md:p-8">
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<h2 className="text-lg font-semibold">접근 권한이 없습니다</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
이 화면은 super_admin 권한으로만 접근할 수 있습니다.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<DataIntegrityContent />
|
||||
</RoleGuard>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,20 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchAdminRPUsageDaily } from "../../lib/adminApi";
|
||||
import {
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchDataIntegrityReport,
|
||||
} from "../../lib/adminApi";
|
||||
import AuthPage from "../auth/AuthPage";
|
||||
import GlobalOverviewPage from "./GlobalOverviewPage";
|
||||
|
||||
let currentRole = "super_admin";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
|
||||
fetchMe: vi.fn(async () => ({ role: currentRole })),
|
||||
fetchAdminOverviewStats: vi.fn(async () => ({
|
||||
totalTenants: 10,
|
||||
totalUsers: 152,
|
||||
oidcClients: 3,
|
||||
auditEvents24h: 18,
|
||||
})),
|
||||
@@ -93,6 +99,30 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchDataIntegrityReport: vi.fn(async () => ({
|
||||
status: "fail",
|
||||
checkedAt: "2026-05-14T00:00:00Z",
|
||||
summary: {
|
||||
totalChecks: 5,
|
||||
passed: 4,
|
||||
warnings: 0,
|
||||
failures: 1,
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
key: "tenant_integrity",
|
||||
label: "테넌트 정합성",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
},
|
||||
{
|
||||
key: "user_integrity",
|
||||
label: "사용자 정합성",
|
||||
status: "fail",
|
||||
checks: [],
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
@@ -112,6 +142,7 @@ function renderWithProviders(ui: React.ReactElement) {
|
||||
|
||||
describe("admin overview and auth guard pages", () => {
|
||||
beforeEach(() => {
|
||||
currentRole = "super_admin";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -132,15 +163,18 @@ describe("admin overview and auth guard pages", () => {
|
||||
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders overview summary metrics from the admin stats API", async () => {
|
||||
it("renders overview tenant count from the fully fetched tenant list", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
(await screen.findByText("전체 테넌트 수")).parentElement,
|
||||
).toHaveTextContent("10");
|
||||
).toHaveTextContent("3");
|
||||
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
|
||||
"3",
|
||||
);
|
||||
expect(screen.getByText("전체 사용자 수").parentElement).toHaveTextContent(
|
||||
"152",
|
||||
);
|
||||
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
|
||||
"18",
|
||||
);
|
||||
@@ -172,6 +206,26 @@ describe("admin overview and auth guard pages", () => {
|
||||
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows the latest integrity summary at the bottom for super admins only", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(await screen.findByText("정합성 최종 검증")).toBeInTheDocument();
|
||||
expect(screen.getByText("실패 1건")).toBeInTheDocument();
|
||||
expect(screen.getByText("테넌트 정합성")).toBeInTheDocument();
|
||||
expect(screen.getByText("사용자 정합성")).toBeInTheDocument();
|
||||
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fetch or show the integrity summary for non-super admins", async () => {
|
||||
currentRole = "tenant_admin";
|
||||
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
|
||||
expect(screen.queryByText("정합성 최종 검증")).not.toBeInTheDocument();
|
||||
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
|
||||
renderWithProviders(<AuthPage />);
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
@@ -9,12 +11,14 @@ import {
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import {
|
||||
type DataIntegrityStatus,
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
type TenantSummary,
|
||||
fetchAdminOverviewStats,
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchAllTenants,
|
||||
fetchDataIntegrityReport,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
@@ -151,6 +155,102 @@ function OverviewMetric({
|
||||
);
|
||||
}
|
||||
|
||||
function formatOverviewDateTime(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function integrityStatusText(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return "정상";
|
||||
case "warning":
|
||||
return "주의";
|
||||
default:
|
||||
return "실패";
|
||||
}
|
||||
}
|
||||
|
||||
function integrityStatusClass(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return "text-emerald-700 dark:text-emerald-300";
|
||||
case "warning":
|
||||
return "text-amber-700 dark:text-amber-300";
|
||||
default:
|
||||
return "text-destructive";
|
||||
}
|
||||
}
|
||||
|
||||
function IntegrityOverviewSummary() {
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ["admin-overview-integrity"],
|
||||
queryFn: fetchDataIntegrityReport,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<section className="border-t border-border/60 pt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle size={16} />
|
||||
<span>정합성 최종 검증 결과를 불러오지 못했습니다.</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="border-t border-border/60 pt-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{data.status === "pass" ? (
|
||||
<CheckCircle2 size={18} className="text-emerald-600" />
|
||||
) : (
|
||||
<AlertTriangle size={18} className="text-amber-600" />
|
||||
)}
|
||||
<h3 className="text-base font-semibold">정합성 최종 검증</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span
|
||||
className={`font-semibold ${integrityStatusClass(data.status)}`}
|
||||
>
|
||||
{integrityStatusText(data.status)}
|
||||
</span>
|
||||
<span className="tabular-nums">실패 {data.summary.failures}건</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatOverviewDateTime(data.checkedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||||
{data.sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-2"
|
||||
>
|
||||
<span>{section.label}</span>
|
||||
<span
|
||||
className={`font-medium ${integrityStatusClass(section.status)}`}
|
||||
>
|
||||
{integrityStatusText(section.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RPUsageMixedChart({
|
||||
rows,
|
||||
filters,
|
||||
@@ -371,6 +471,7 @@ function GlobalOverviewPage() {
|
||||
retry: false,
|
||||
});
|
||||
const stats = statsQuery.data;
|
||||
const visibleTenantCount = tenantsQuery.data?.items.length;
|
||||
const usageRows = usageQuery.data?.items ?? [];
|
||||
const metric = (value: number | undefined) =>
|
||||
value === undefined ? "-" : value.toLocaleString();
|
||||
@@ -444,7 +545,7 @@ function GlobalOverviewPage() {
|
||||
"ui.admin.overview.summary.total_tenants",
|
||||
"전체 테넌트 수",
|
||||
)}
|
||||
value={metric(stats?.totalTenants)}
|
||||
value={metric(visibleTenantCount ?? stats?.totalTenants)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<ShieldCheck size={14} />}
|
||||
@@ -454,6 +555,11 @@ function GlobalOverviewPage() {
|
||||
)}
|
||||
value={metric(stats?.oidcClients)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Users size={14} />}
|
||||
label={t("ui.admin.overview.summary.total_users", "전체 사용자 수")}
|
||||
value={metric(stats?.totalUsers)}
|
||||
/>
|
||||
</RoleGuard>
|
||||
<OverviewMetric
|
||||
icon={<Activity size={14} />}
|
||||
@@ -491,6 +597,10 @@ function GlobalOverviewPage() {
|
||||
period={period}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<IntegrityOverviewSummary />
|
||||
</RoleGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
|
||||
export function canShowWorksmobileEntry(tenant?: {
|
||||
id?: string;
|
||||
@@ -30,8 +31,9 @@ function TenantDetailPage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canAccessSchema =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
profileRole === "super_admin" || profileRole === "tenant_admin";
|
||||
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
|
||||
|
||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
importTenantsCSV,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
import {
|
||||
filterNonHanmacFamilyTenants,
|
||||
@@ -262,10 +263,11 @@ function TenantListPage() {
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
|
||||
// Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin") {
|
||||
if (profile && profileRole === "tenant_admin") {
|
||||
const manageableCount = profile.manageableTenants?.length ?? 0;
|
||||
if (
|
||||
(manageableCount === 1 || manageableCount === 0) &&
|
||||
@@ -274,7 +276,7 @@ function TenantListPage() {
|
||||
navigate(`/tenants/${profile.tenantId}`, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [profile, navigate]);
|
||||
}, [profile, profileRole, navigate]);
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["tenants", "lazy"],
|
||||
@@ -289,9 +291,9 @@ function TenantListPage() {
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.nextCursor || lastPage.next_cursor || undefined,
|
||||
enabled:
|
||||
profile?.role === "super_admin" ||
|
||||
(profile?.role === "tenant_admin" &&
|
||||
(profile.manageableTenants?.length ?? 0) > 1),
|
||||
profileRole === "super_admin" ||
|
||||
(profileRole === "tenant_admin" &&
|
||||
(profile?.manageableTenants?.length ?? 0) > 1),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -356,8 +358,8 @@ function TenantListPage() {
|
||||
|
||||
if (
|
||||
profile &&
|
||||
profile.role !== "super_admin" &&
|
||||
profile.role !== "tenant_admin"
|
||||
profileRole !== "super_admin" &&
|
||||
profileRole !== "tenant_admin"
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
@@ -372,7 +374,8 @@ function TenantListPage() {
|
||||
}
|
||||
|
||||
if (
|
||||
profile?.role === "tenant_admin" &&
|
||||
profile &&
|
||||
profileRole === "tenant_admin" &&
|
||||
(profile.manageableTenants?.length ?? 0) <= 1
|
||||
) {
|
||||
return null;
|
||||
@@ -396,7 +399,7 @@ function TenantListPage() {
|
||||
return rawTenants.find((tenant) => tenant.slug === "hanmac-family")?.id;
|
||||
}, [rawTenants]);
|
||||
const allTenants = React.useMemo(() => {
|
||||
if (profile?.role === "super_admin") {
|
||||
if (profileRole === "super_admin") {
|
||||
return rawTenants;
|
||||
}
|
||||
if (
|
||||
@@ -406,7 +409,7 @@ function TenantListPage() {
|
||||
return rawTenants;
|
||||
}
|
||||
return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId);
|
||||
}, [hanmacFamilyTenantId, profile, rawTenants]);
|
||||
}, [hanmacFamilyTenantId, profile, profileRole, rawTenants]);
|
||||
const importParentOptionGroups =
|
||||
buildTenantImportParentOptionGroups(allTenants);
|
||||
const tenantSortResolvers = React.useMemo<
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Label } from "../../../components/ui/label";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
|
||||
export type SchemaFieldType =
|
||||
| "text"
|
||||
@@ -101,8 +102,9 @@ export function TenantSchemaPage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canAccess =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
profileRole === "super_admin" || profileRole === "tenant_admin";
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
fetchTenant,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
type OrgChartTenantSelection,
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
@@ -642,7 +643,7 @@ function UserCreatePage() {
|
||||
id="role"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("role")}
|
||||
disabled={profile?.role !== "super_admin"}
|
||||
disabled={!isSuperAdminRole(profile?.role)}
|
||||
>
|
||||
<option value="super_admin">
|
||||
{t("ui.admin.role.super_admin", "시스템 관리자")}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
} from "../../lib/adminApi";
|
||||
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
|
||||
import { generateSecurePassword } from "../../lib/utils";
|
||||
import {
|
||||
type OrgChartTenantSelection,
|
||||
@@ -387,8 +388,9 @@ function UserDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const isAdmin =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
profileRole === "super_admin" || profileRole === "tenant_admin";
|
||||
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
||||
const watchedStatus = watch("status");
|
||||
|
||||
@@ -1070,7 +1072,7 @@ function UserDetailPage() {
|
||||
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
|
||||
{...register("role")}
|
||||
disabled={
|
||||
profile?.role !== "super_admin" ||
|
||||
!isSuperAdminRole(profile?.role) ||
|
||||
profile?.id === user?.id
|
||||
}
|
||||
>
|
||||
|
||||
@@ -70,7 +70,13 @@ import {
|
||||
updateUser,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole } from "../../lib/roles";
|
||||
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
||||
import {
|
||||
type UserStatusValue,
|
||||
userStatusLabel,
|
||||
userStatusValues,
|
||||
} from "./userStatus";
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
@@ -80,6 +86,29 @@ type UserSchemaField = {
|
||||
|
||||
type UserSortKey = string;
|
||||
|
||||
const bulkPermissionOptions = [
|
||||
{
|
||||
value: "super_admin",
|
||||
labelKey: "ui.admin.role.super_admin",
|
||||
fallback: "시스템 관리자",
|
||||
},
|
||||
{
|
||||
value: "tenant_admin",
|
||||
labelKey: "ui.admin.role.tenant_admin",
|
||||
fallback: "테넌트 관리자",
|
||||
},
|
||||
{
|
||||
value: "rp_admin",
|
||||
labelKey: "ui.admin.role.rp_admin",
|
||||
fallback: "서비스 관리자",
|
||||
},
|
||||
{
|
||||
value: "user",
|
||||
labelKey: "ui.admin.role.user",
|
||||
fallback: "일반 사용자",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function UserListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = React.useState(1);
|
||||
@@ -90,6 +119,11 @@ function UserListPage() {
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState<
|
||||
UserStatusValue | ""
|
||||
>("");
|
||||
const [selectedBulkPermission, setSelectedBulkPermission] =
|
||||
React.useState("");
|
||||
const [sortConfig, setSortConfig] =
|
||||
React.useState<SortConfig<UserSortKey> | null>(null);
|
||||
|
||||
@@ -270,7 +304,7 @@ function UserListPage() {
|
||||
|
||||
const total = query.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const canPromoteSuperAdmin = profile?.role === "super_admin";
|
||||
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedUserIds.length === items.length) {
|
||||
@@ -306,6 +340,8 @@ function UserListPage() {
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
setSelectedBulkStatus("");
|
||||
setSelectedBulkPermission("");
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.bulk.update_success",
|
||||
@@ -315,22 +351,20 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleBulkStatusChange = (status: string) => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
|
||||
};
|
||||
|
||||
const handlePromoteSuperAdmin = () => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
const handleApplyBulkStatus = () => {
|
||||
if (selectedUserIds.length === 0 || !selectedBulkStatus) return;
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: selectedUserIds,
|
||||
role: "super_admin",
|
||||
status: selectedBulkStatus,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkRoleChange = (role: string) => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
bulkUpdateMutation.mutate({ userIds: selectedUserIds, role });
|
||||
const handleApplyBulkPermission = () => {
|
||||
if (selectedUserIds.length === 0 || !selectedBulkPermission) return;
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: selectedUserIds,
|
||||
role: selectedBulkPermission,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
@@ -746,7 +780,7 @@ function UserListPage() {
|
||||
}
|
||||
disabled={
|
||||
bulkUpdateMutation.isPending ||
|
||||
profile?.role !== "super_admin" ||
|
||||
!isSuperAdminRole(profile?.role) ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
>
|
||||
@@ -754,7 +788,7 @@ function UserListPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profile?.role === "super_admin" && (
|
||||
{isSuperAdminRole(profile?.role) && (
|
||||
<SelectItem value="super_admin">
|
||||
{t(
|
||||
"ui.admin.role.super_admin",
|
||||
@@ -819,63 +853,80 @@ function UserListPage() {
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={() => handleBulkStatusChange("active")}
|
||||
data-testid="bulk-active-btn"
|
||||
<Select
|
||||
value={selectedBulkStatus}
|
||||
onValueChange={(value) =>
|
||||
setSelectedBulkStatus(value as UserStatusValue)
|
||||
}
|
||||
>
|
||||
{t("ui.common.status.active", "활성화")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={() => handleBulkStatusChange("inactive")}
|
||||
data-testid="bulk-inactive-btn"
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성화")}
|
||||
</Button>
|
||||
{canPromoteSuperAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8 gap-1.5"
|
||||
onClick={handlePromoteSuperAdmin}
|
||||
data-testid="bulk-promote-super-admin-btn"
|
||||
<SelectTrigger
|
||||
className="h-8 w-[150px] bg-transparent border-background/20 text-background text-xs"
|
||||
data-testid="bulk-status-select"
|
||||
>
|
||||
<ShieldCheck size={14} />
|
||||
{t("ui.admin.users.bulk.promote_admin", "Admin으로 만들기")}
|
||||
</Button>
|
||||
)}
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"ui.admin.users.bulk.status_placeholder",
|
||||
"상태 선택",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={handleApplyBulkStatus}
|
||||
disabled={!selectedBulkStatus || bulkUpdateMutation.isPending}
|
||||
data-testid="bulk-apply-status-btn"
|
||||
>
|
||||
{t("ui.common.apply", "적용")}
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
{profile?.role === "super_admin" && (
|
||||
{canPromoteSuperAdmin && (
|
||||
<>
|
||||
<Select onValueChange={handleBulkRoleChange}>
|
||||
<SelectTrigger className="h-8 w-[140px] bg-transparent border-background/20 text-background text-xs">
|
||||
<Select
|
||||
value={selectedBulkPermission}
|
||||
onValueChange={setSelectedBulkPermission}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-[150px] bg-transparent border-background/20 text-background text-xs"
|
||||
data-testid="bulk-permission-select"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.table.role",
|
||||
"ROLE",
|
||||
"ui.admin.users.bulk.permission_placeholder",
|
||||
"권한 선택",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="super_admin">
|
||||
{t("ui.admin.role.super_admin", "시스템 관리자")}
|
||||
</SelectItem>
|
||||
<SelectItem value="tenant_admin">
|
||||
{t("ui.admin.role.tenant_admin", "테넌트 관리자")}
|
||||
</SelectItem>
|
||||
<SelectItem value="rp_admin">
|
||||
{t("ui.admin.role.rp_admin", "서비스 관리자")}
|
||||
</SelectItem>
|
||||
<SelectItem value="user">
|
||||
{t("ui.admin.role.user", "일반 사용자")}
|
||||
</SelectItem>
|
||||
{bulkPermissionOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8 gap-1.5"
|
||||
onClick={handleApplyBulkPermission}
|
||||
disabled={
|
||||
!selectedBulkPermission || bulkUpdateMutation.isPending
|
||||
}
|
||||
data-testid="bulk-apply-permission-btn"
|
||||
>
|
||||
<ShieldCheck size={14} />
|
||||
{t("ui.common.apply", "적용")}
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -129,6 +129,7 @@ export type RPUsageDailyResponse = {
|
||||
|
||||
export type AdminOverviewStats = {
|
||||
totalTenants: number;
|
||||
totalUsers: number;
|
||||
oidcClients: number;
|
||||
auditEvents24h: number;
|
||||
};
|
||||
@@ -149,6 +150,36 @@ export type UserProjectionActionResult = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DataIntegrityStatus = "pass" | "warning" | "fail";
|
||||
|
||||
export type DataIntegrityCheck = {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
status: DataIntegrityStatus;
|
||||
severity: "info" | "warning" | "error" | string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type DataIntegritySection = {
|
||||
key: string;
|
||||
label: string;
|
||||
status: DataIntegrityStatus;
|
||||
checks: DataIntegrityCheck[];
|
||||
};
|
||||
|
||||
export type DataIntegrityReport = {
|
||||
status: DataIntegrityStatus;
|
||||
checkedAt: string;
|
||||
summary: {
|
||||
totalChecks: number;
|
||||
passed: number;
|
||||
warnings: number;
|
||||
failures: number;
|
||||
};
|
||||
sections: DataIntegritySection[];
|
||||
};
|
||||
|
||||
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||
params: { limit, cursor },
|
||||
@@ -161,6 +192,13 @@ export async function fetchAdminOverviewStats() {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchDataIntegrityReport() {
|
||||
const { data } = await apiClient.get<DataIntegrityReport>(
|
||||
"/v1/admin/integrity",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchUserProjectionStatus() {
|
||||
const { data } = await apiClient.get<UserProjectionStatus>(
|
||||
"/v1/admin/projections/users",
|
||||
|
||||
37
adminfront/src/lib/roles.test.ts
Normal file
37
adminfront/src/lib/roles.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ROLE_RP_ADMIN,
|
||||
ROLE_SUPER_ADMIN,
|
||||
ROLE_TENANT_ADMIN,
|
||||
ROLE_USER,
|
||||
isSuperAdminRole,
|
||||
normalizeAdminRole,
|
||||
} from "./roles";
|
||||
|
||||
describe("admin role helpers", () => {
|
||||
it.each([
|
||||
["super_admin", ROLE_SUPER_ADMIN],
|
||||
["superadmin", ROLE_SUPER_ADMIN],
|
||||
["super-admin", ROLE_SUPER_ADMIN],
|
||||
[" SUPER-ADMIN ", ROLE_SUPER_ADMIN],
|
||||
["tenant_admin", ROLE_TENANT_ADMIN],
|
||||
["tenantadmin", ROLE_TENANT_ADMIN],
|
||||
["tenant-admin", ROLE_TENANT_ADMIN],
|
||||
["admin", ROLE_TENANT_ADMIN],
|
||||
["rp_admin", ROLE_RP_ADMIN],
|
||||
["rpadmin", ROLE_RP_ADMIN],
|
||||
["rp-admin", ROLE_RP_ADMIN],
|
||||
["tenant_member", ROLE_USER],
|
||||
["member", ROLE_USER],
|
||||
["custom", ROLE_USER],
|
||||
["", ROLE_USER],
|
||||
])("normalizes %s to %s", (input, expected) => {
|
||||
expect(normalizeAdminRole(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("detects super admin aliases", () => {
|
||||
expect(isSuperAdminRole("super-admin")).toBe(true);
|
||||
expect(isSuperAdminRole("admin")).toBe(false);
|
||||
expect(isSuperAdminRole(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
40
adminfront/src/lib/roles.ts
Normal file
40
adminfront/src/lib/roles.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export const ROLE_SUPER_ADMIN = "super_admin";
|
||||
export const ROLE_TENANT_ADMIN = "tenant_admin";
|
||||
export const ROLE_RP_ADMIN = "rp_admin";
|
||||
export const ROLE_USER = "user";
|
||||
|
||||
export type AdminRole =
|
||||
| typeof ROLE_SUPER_ADMIN
|
||||
| typeof ROLE_TENANT_ADMIN
|
||||
| typeof ROLE_RP_ADMIN
|
||||
| typeof ROLE_USER;
|
||||
|
||||
export function normalizeAdminRole(role?: string | null): AdminRole {
|
||||
const normalized = role?.trim().toLowerCase() ?? "";
|
||||
|
||||
switch (normalized) {
|
||||
case ROLE_SUPER_ADMIN:
|
||||
case "superadmin":
|
||||
case "super-admin":
|
||||
return ROLE_SUPER_ADMIN;
|
||||
case ROLE_TENANT_ADMIN:
|
||||
case "tenantadmin":
|
||||
case "tenant-admin":
|
||||
case "admin":
|
||||
return ROLE_TENANT_ADMIN;
|
||||
case ROLE_RP_ADMIN:
|
||||
case "rpadmin":
|
||||
case "rp-admin":
|
||||
return ROLE_RP_ADMIN;
|
||||
case ROLE_USER:
|
||||
case "tenant_member":
|
||||
case "member":
|
||||
return ROLE_USER;
|
||||
default:
|
||||
return ROLE_USER;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSuperAdminRole(role?: string | null) {
|
||||
return normalizeAdminRole(role) === ROLE_SUPER_ADMIN;
|
||||
}
|
||||
@@ -842,6 +842,7 @@ org_chart = "Org Chart"
|
||||
api_keys = "API Keys"
|
||||
audit_logs = "Audit Logs"
|
||||
auth_guard = "Auth Guard"
|
||||
data_integrity = "Data Integrity"
|
||||
logout = "Logout"
|
||||
overview = "Overview"
|
||||
relying_parties = "Apps (RP)"
|
||||
@@ -876,6 +877,7 @@ audit_events_24h = "24h Events"
|
||||
oidc_clients = "OIDC Clients"
|
||||
policy_gate = "Policy Gate"
|
||||
total_tenants = "Total Tenants"
|
||||
total_users = "Total Users"
|
||||
|
||||
[ui.admin.profile]
|
||||
manageable_tenants = "Manageable Tenants"
|
||||
|
||||
@@ -844,6 +844,7 @@ org_chart = "조직도"
|
||||
api_keys = "API 키"
|
||||
audit_logs = "감사 로그"
|
||||
auth_guard = "인증 가드"
|
||||
data_integrity = "데이터 정합성"
|
||||
logout = "로그아웃"
|
||||
overview = "개요"
|
||||
relying_parties = "애플리케이션(RP)"
|
||||
@@ -878,6 +879,7 @@ audit_events_24h = "24시간 이벤트"
|
||||
oidc_clients = "OIDC 클라이언트"
|
||||
policy_gate = "정책 게이트"
|
||||
total_tenants = "전체 테넌트 수"
|
||||
total_users = "전체 사용자 수"
|
||||
|
||||
[ui.admin.profile]
|
||||
manageable_tenants = "관리 가능한 테넌트"
|
||||
|
||||
@@ -189,6 +189,7 @@ audit_events_24h = ""
|
||||
oidc_clients = ""
|
||||
policy_gate = ""
|
||||
total_tenants = ""
|
||||
total_users = ""
|
||||
|
||||
[msg.admin.tenants]
|
||||
approve_confirm = ""
|
||||
@@ -856,6 +857,7 @@ org_chart = ""
|
||||
api_keys = ""
|
||||
audit_logs = ""
|
||||
auth_guard = ""
|
||||
data_integrity = ""
|
||||
logout = ""
|
||||
overview = ""
|
||||
relying_parties = ""
|
||||
|
||||
Reference in New Issue
Block a user