From 9ca73e877438672ca1b8410e5cb94ad6739b9fce Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 14 May 2026 08:45:48 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B6=8C=ED=95=9C=EB=B6=80=EC=97=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.test.tsx | 6 + adminfront/src/app/routes.tsx | 2 + adminfront/src/components/auth/RoleGuard.tsx | 7 +- .../src/components/layout/AppLayout.tsx | 9 +- .../integrity/DataIntegrityPage.test.tsx | 81 +++++++ .../features/integrity/DataIntegrityPage.tsx | 209 ++++++++++++++++ .../overview/GlobalOverviewPage.test.tsx | 62 ++++- .../features/overview/GlobalOverviewPage.tsx | 112 ++++++++- .../tenants/routes/TenantDetailPage.tsx | 4 +- .../tenants/routes/TenantListPage.tsx | 23 +- .../tenants/routes/TenantSchemaPage.tsx | 4 +- .../src/features/users/UserCreatePage.tsx | 3 +- .../src/features/users/UserDetailPage.tsx | 6 +- .../src/features/users/UserListPage.tsx | 169 ++++++++----- adminfront/src/lib/adminApi.ts | 38 +++ adminfront/src/lib/roles.test.ts | 37 +++ adminfront/src/lib/roles.ts | 40 ++++ adminfront/src/locales/en.toml | 2 + adminfront/src/locales/ko.toml | 2 + adminfront/src/locales/template.toml | 2 + adminfront/tests/bulk_actions.spec.ts | 111 ++++++++- adminfront/tests/data_integrity.spec.ts | 151 ++++++++++++ backend/cmd/server/main.go | 2 + backend/internal/domain/data_integrity.go | 41 ++++ backend/internal/domain/user_test.go | 2 + backend/internal/handler/admin_handler.go | 42 +++- .../internal/handler/admin_handler_test.go | 14 +- .../internal/handler/admin_integrity_test.go | 92 +++++++ .../repository/data_integrity_repository.go | 226 ++++++++++++++++++ .../data_integrity_repository_test.go | 106 ++++++++ .../internal/service/worksmobile_client.go | 43 ++++ .../service/worksmobile_client_test.go | 101 ++++++++ .../service/worksmobile_relay_worker.go | 24 +- .../service/worksmobile_sync_service.go | 14 +- .../service/worksmobile_sync_service_test.go | 45 ++++ docs/data-integrity-management.md | 45 ++++ 36 files changed, 1772 insertions(+), 105 deletions(-) create mode 100644 adminfront/src/features/integrity/DataIntegrityPage.test.tsx create mode 100644 adminfront/src/features/integrity/DataIntegrityPage.tsx create mode 100644 adminfront/src/lib/roles.test.ts create mode 100644 adminfront/src/lib/roles.ts create mode 100644 adminfront/tests/data_integrity.spec.ts create mode 100644 backend/internal/domain/data_integrity.go create mode 100644 backend/internal/handler/admin_integrity_test.go create mode 100644 backend/internal/repository/data_integrity_repository.go create mode 100644 backend/internal/repository/data_integrity_repository_test.go create mode 100644 docs/data-integrity-management.md diff --git a/adminfront/src/app/routes.test.tsx b/adminfront/src/app/routes.test.tsx index 08d10367..ec98de66 100644 --- a/adminfront/src/app/routes.test.tsx +++ b/adminfront/src/app/routes.test.tsx @@ -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]; diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index df3df402..7c17cf9e 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -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: }, { path: "api-keys/new", element: }, { path: "system/projections/users", element: }, + { path: "system/data-integrity", element: }, ], }, ], diff --git a/adminfront/src/components/auth/RoleGuard.tsx b/adminfront/src/components/auth/RoleGuard.tsx index 6fd8e51c..6872747b 100644 --- a/adminfront/src/components/auth/RoleGuard.tsx +++ b/adminfront/src/components/auth/RoleGuard.tsx @@ -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}; diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 7f8d894a..5950ddf4 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -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, { diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx new file mode 100644 index 00000000..0b9fd597 --- /dev/null +++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx @@ -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( + + + , + ); +} + +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(); + }); +}); diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx new file mode 100644 index 00000000..502338a2 --- /dev/null +++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx @@ -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 ; + } + if (check.status === "warning") { + return ; + } + return ; +} + +function DataIntegrityContent() { + const { data, isLoading, isError, error, refetch, isFetching } = useQuery({ + queryKey: ["data-integrity-report"], + queryFn: fetchDataIntegrityReport, + }); + + return ( +
+
+
+

System

+

+ 데이터 정합성 검증 +

+
+ +
+ + {isError ? ( +
+ {(error as Error)?.message || "정합성 리포트를 불러오지 못했습니다."} +
+ ) : null} + +
+
+
+
+ +
+
+

Read model integrity

+

+ Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 + 확인합니다. +

+
+
+ {data ? ( + + {statusLabel(data.status)} + + ) : null} +
+ + {isLoading ? ( +
불러오는 중
+ ) : ( +
+
+
검사 항목
+
+ {data?.summary.totalChecks ?? 0} +
+
+
+
정상
+
+ {data?.summary.passed ?? 0} +
+
+
+
실패 건수
+
+ {data?.summary.failures ?? 0} +
+
+
+
검사 시각
+
+ {formatDateTime(data?.checkedAt)} +
+
+
+ )} +
+ +
+ {(data?.sections ?? []).map((section) => ( +
+
+

{section.label}

+ + {statusLabel(section.status)} + +
+
+ {section.checks.map((check) => ( +
+
+ +
+
{check.label}
+

+ {check.description} +

+
+
+
+ + {statusLabel(check.status)} + + + {check.count} + +
+
+ ))} +
+
+ ))} +
+
+ ); +} + +export default function DataIntegrityPage() { + return ( + +
+

접근 권한이 없습니다

+

+ 이 화면은 super_admin 권한으로만 접근할 수 있습니다. +

+
+ + } + > + +
+ ); +} diff --git a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx index 80e33caf..15a2bd36 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx @@ -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(); 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(); + + 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(); + + 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(); diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index d3c4dad7..28431105 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -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 ( +
+
+ + 정합성 최종 검증 결과를 불러오지 못했습니다. +
+
+ ); + } + + if (!data) { + return null; + } + + return ( +
+
+
+ {data.status === "pass" ? ( + + ) : ( + + )} +

정합성 최종 검증

+
+
+ + {integrityStatusText(data.status)} + + 실패 {data.summary.failures}건 + + {formatOverviewDateTime(data.checkedAt)} + +
+
+
+ {data.sections.map((section) => ( +
+ {section.label} + + {integrityStatusText(section.status)} + +
+ ))} +
+
+ ); +} + 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)} /> } @@ -454,6 +555,11 @@ function GlobalOverviewPage() { )} value={metric(stats?.oidcClients)} /> + } + label={t("ui.admin.overview.summary.total_users", "전체 사용자 수")} + value={metric(stats?.totalUsers)} + /> } @@ -491,6 +597,10 @@ function GlobalOverviewPage() { period={period} /> )} + + + + ); } diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index 87901297..01ce0e0c 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -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"); diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 23b6f609..8739e474 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -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 (
@@ -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< diff --git a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx index dca46f15..5c3d6822 100644 --- a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx @@ -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], diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index d9e6bb48..70c65dc5 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -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)} >