1
0
forked from baron/baron-sso

권한부여 및 정합성 검사 추가

This commit is contained in:
2026-05-14 08:45:48 +09:00
parent f6f8e88342
commit 9ca73e8774
36 changed files with 1772 additions and 105 deletions

View File

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

View File

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

View File

@@ -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}</>;

View File

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

View 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();
});
});

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", "시스템 관리자")}

View File

@@ -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
}
>

View File

@@ -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" />
</>
)}

View File

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

View 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);
});
});

View 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;
}

View File

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

View File

@@ -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 = "관리 가능한 테넌트"

View File

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