diff --git a/.gitignore b/.gitignore index 191012ce..5b269838 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ config/.generated/ .npm-cache/ reports reports/* +/backups/ +/tmp/rp-restore-*/ config/*.pem common/node_modules common/.baron-deps-install.lock diff --git a/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx b/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx index 9f9d20f3..5026242d 100644 --- a/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx +++ b/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx @@ -2,7 +2,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, renderHook, screen, waitFor } from "@testing-library/react"; import type React from "react"; import { describe, expect, it, vi } from "vitest"; -import { fetchMe, fetchTenant } from "../../../lib/adminApi"; +import { + fetchMe, + fetchTenant, + type TenantSummary, + type UserProfileResponse, +} from "../../../lib/adminApi"; import { TenantPermissionGuard } from "../components/TenantPermissionGuard"; import { useTenantPermission } from "./useTenantPermission"; @@ -22,18 +27,52 @@ function createWrapper() { ); } +function mockProfile( + overrides: Partial, +): UserProfileResponse { + return { + id: "user-id", + email: "user@example.com", + name: "Test User", + phone: "", + role: "user", + department: "", + affiliationType: "general", + ...overrides, + }; +} + +function mockTenant(overrides: Partial): TenantSummary { + return { + id: "tenant-id", + type: "COMPANY", + name: "Test Tenant", + slug: "test-tenant", + description: "", + status: "active", + memberCount: 0, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + describe("useTenantPermission", () => { it("returns true for all permissions if user is super_admin", async () => { - vi.mocked(fetchMe).mockResolvedValue({ - id: "user-super", - role: "super_admin", - } as any); + vi.mocked(fetchMe).mockResolvedValue( + mockProfile({ + id: "user-super", + role: "super_admin", + }), + ); - vi.mocked(fetchTenant).mockResolvedValue({ - id: "tenant-1", - name: "Super Tenant", - userPermissions: { view: false, manage: false, manage_admins: false }, - } as any); + vi.mocked(fetchTenant).mockResolvedValue( + mockTenant({ + id: "tenant-1", + name: "Super Tenant", + userPermissions: { view: false, manage: false, manage_admins: false }, + }), + ); const { result } = renderHook(() => useTenantPermission("tenant-1"), { wrapper: createWrapper(), @@ -49,16 +88,20 @@ describe("useTenantPermission", () => { }); it("returns permissions mapped from userPermissions for normal admins/users", async () => { - vi.mocked(fetchMe).mockResolvedValue({ - id: "user-admin", - role: "tenant_admin", - } as any); + vi.mocked(fetchMe).mockResolvedValue( + mockProfile({ + id: "user-admin", + role: "tenant_admin", + }), + ); - vi.mocked(fetchTenant).mockResolvedValue({ - id: "tenant-2", - name: "Tenant Admin Corp", - userPermissions: { view: true, manage: true, manage_admins: false }, - } as any); + vi.mocked(fetchTenant).mockResolvedValue( + mockTenant({ + id: "tenant-2", + name: "Tenant Admin Corp", + userPermissions: { view: true, manage: true, manage_admins: false }, + }), + ); const { result } = renderHook(() => useTenantPermission("tenant-2"), { wrapper: createWrapper(), @@ -76,15 +119,19 @@ describe("useTenantPermission", () => { describe("TenantPermissionGuard", () => { it("renders children when user has permission", async () => { - vi.mocked(fetchMe).mockResolvedValue({ - id: "user-admin", - role: "tenant_admin", - } as any); + vi.mocked(fetchMe).mockResolvedValue( + mockProfile({ + id: "user-admin", + role: "tenant_admin", + }), + ); - vi.mocked(fetchTenant).mockResolvedValue({ - id: "tenant-3", - userPermissions: { view: true, manage: true, manage_admins: false }, - } as any); + vi.mocked(fetchTenant).mockResolvedValue( + mockTenant({ + id: "tenant-3", + userPermissions: { view: true, manage: true, manage_admins: false }, + }), + ); render( { }); it("renders fallback when user lacks permission", async () => { - vi.mocked(fetchMe).mockResolvedValue({ - id: "user-admin", - role: "tenant_admin", - } as any); + vi.mocked(fetchMe).mockResolvedValue( + mockProfile({ + id: "user-admin", + role: "tenant_admin", + }), + ); - vi.mocked(fetchTenant).mockResolvedValue({ - id: "tenant-4", - userPermissions: { view: true, manage: false, manage_admins: false }, - } as any); + vi.mocked(fetchTenant).mockResolvedValue( + mockTenant({ + id: "tenant-4", + userPermissions: { view: true, manage: false, manage_admins: false }, + }), + ); render( vi.fn()); +const bulkUpdateUsersMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../../lib/i18n", () => createI18nMock()); + +vi.mock("../../../lib/adminApi", () => ({ + addSystemRelation: vi.fn(async () => undefined), + addTenantRelation: vi.fn(async () => undefined), + bulkUpdateUsers: bulkUpdateUsersMock, + fetchAllTenants: vi.fn(async () => ({ items: [], total: 0 })), + fetchMe: vi.fn(async () => ({ + id: "current-admin", + name: "Current Admin", + email: "current@example.com", + role: "super_admin", + })), + fetchSystemRelations: vi.fn(async () => []), + fetchTenantRelations: vi.fn(async () => []), + fetchUsers: fetchUsersMock, + removeSystemRelation: vi.fn(async () => undefined), + removeTenantRelation: vi.fn(async () => undefined), +})); + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + {ui} + , + ); +} + +describe("TenantFineGrainedPermissionsPage Super Admin role tab", () => { + beforeEach(() => { + vi.clearAllMocks(); + bulkUpdateUsersMock.mockResolvedValue({ results: [] }); + fetchUsersMock.mockResolvedValue({ + items: [ + { + id: "current-admin", + name: "Current Admin", + email: "current@example.com", + role: "super_admin", + status: "active", + createdAt: "2026-06-17T00:00:00Z", + updatedAt: "2026-06-17T00:00:00Z", + }, + { + id: "bootstrap-admin", + name: "Bootstrap Admin", + email: "env-admin@example.com", + role: "super_admin", + status: "active", + metadata: { bootstrapSuperAdmin: true }, + createdAt: "2026-06-17T00:00:00Z", + updatedAt: "2026-06-17T00:00:00Z", + }, + { + id: "delegated-admin", + name: "Delegated Admin", + email: "delegated@example.com", + role: "super_admin", + status: "active", + createdAt: "2026-06-17T00:00:00Z", + updatedAt: "2026-06-17T00:00:00Z", + }, + { + id: "regular-user", + name: "Regular User", + email: "regular@example.com", + role: "user", + status: "active", + createdAt: "2026-06-17T00:00:00Z", + updatedAt: "2026-06-17T00:00:00Z", + }, + ], + total: 4, + limit: 1000, + offset: 0, + }); + }); + + it("shows revocable super admin users even when they have no direct system relations", async () => { + renderWithProviders( + + } + /> + , + ); + + fireEvent.click( + await screen.findByRole("tab", { name: "Super Admin μ—­ν• " }), + ); + + expect(await screen.findByText("Delegated Admin")).toBeInTheDocument(); + expect(screen.getByText("delegated@example.com")).toBeInTheDocument(); + expect(screen.queryByText("Current Admin")).not.toBeInTheDocument(); + expect(screen.queryByText("Bootstrap Admin")).not.toBeInTheDocument(); + expect(screen.queryByText("Regular User")).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByTestId("super-admin-role-user-delegated-admin"), + ); + fireEvent.click(screen.getByRole("button", { name: "Super Admin 회수" })); + + await waitFor(() => + expect(bulkUpdateUsersMock).toHaveBeenCalledWith({ + userIds: ["delegated-admin"], + role: "user", + }), + ); + }); +}); diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx index dc0dbb2c..95e5f74a 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx @@ -8,7 +8,6 @@ import { LayoutDashboard, Network, NotebookTabs, - Plus, Search, Share2, Shield, @@ -19,10 +18,8 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Avatar, AvatarFallback } from "../../../components/ui/avatar"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; -import { Card, CardContent } from "../../../components/ui/card"; import { Dialog, DialogContent, @@ -31,7 +28,6 @@ import { DialogTitle, } from "../../../components/ui/dialog"; import { Input } from "../../../components/ui/input"; -import { ScrollArea } from "../../../components/ui/scroll-area"; import { Table, TableBody, @@ -49,6 +45,7 @@ import { fetchMe, fetchSystemRelations, fetchTenantRelations, + fetchUsers, removeSystemRelation, removeTenantRelation, type TenantRelation, @@ -66,6 +63,10 @@ const protectedSystemMenuRelations = new Set([ "permissions_direct", ]); +function isBootstrapSuperAdminUser(user: UserSummary) { + return user.metadata?.bootstrapSuperAdmin === true; +} + export function TenantFineGrainedPermissionsPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -79,8 +80,7 @@ export function TenantFineGrainedPermissionsPage() { const [bulkRelationMode, setBulkRelationMode] = useState< "page" | "target-action" >("page"); - const [bulkPageRelation, setBulkPageRelation] = - useState("overview_viewers"); + const [bulkPageRelation, setBulkPageRelation] = useState("overview_viewers"); const [bulkTenantPage, setBulkTenantPage] = useState("profile"); const [bulkAction, setBulkAction] = useState<"read" | "manage">("read"); const [tenantPickerOpen, setTenantPickerOpen] = useState(false); @@ -94,15 +94,12 @@ export function TenantFineGrainedPermissionsPage() { >("user"); const orgChartMemberPickerUrl = useMemo( () => - buildAuthenticatedOrgChartUserMultiPickerUrl(import.meta.env.ORGFRONT_URL), + buildAuthenticatedOrgChartUserMultiPickerUrl( + import.meta.env.ORGFRONT_URL, + ), [], ); - // 🌟 κΈ€λ‘œλ²Œ μ‹œμŠ€ν…œ λ“œλ‘­λ‹€μš΄ 즉각 변경을 μœ„ν•œ μž„μ‹œ 둜컬 λ§΅ μ„ μ–Έ - const [localSystemPermissions, setLocalSystemPermissions] = useState< - Record> - >({}); - const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, @@ -128,6 +125,30 @@ export function TenantFineGrainedPermissionsPage() { }); const systemRelations = systemRelationsQuery.data ?? []; + const superAdminUsersQuery = useQuery({ + queryKey: ["admin-users", "super-admin-role-candidates"], + queryFn: () => fetchUsers(10000, 0), + enabled: isSuperAdmin && activePermissionTab === "super-admin", + }); + + const revocableSuperAdminUsers = useMemo(() => { + const currentAdminId = profile?.id ?? ""; + const currentAdminEmail = (profile?.email ?? "").trim().toLowerCase(); + + return (superAdminUsersQuery.data?.items ?? []).filter((user) => { + if (user.role !== "super_admin") { + return false; + } + if (user.id === currentAdminId) { + return false; + } + if (user.email.trim().toLowerCase() === currentAdminEmail) { + return false; + } + return !isBootstrapSuperAdminUser(user); + }); + }, [profile?.email, profile?.id, superAdminUsersQuery.data?.items]); + const tenantRelationsQuery = useQuery({ queryKey: ["tenant-relations", targetTenantId], queryFn: () => fetchTenantRelations(targetTenantId), @@ -139,42 +160,6 @@ export function TenantFineGrainedPermissionsPage() { }); const tenantRelations = tenantRelationsQuery.data ?? []; - // 🌟 μ„œλ²„ 데이터λ₯Ό μˆ˜μ‹ ν•˜λ©΄ 둜컬 λ³€κ²½ μƒνƒœ 맡을 μ‹€μ‹œκ°„ 동기화 - useEffect(() => { - if (systemRelationsQuery.data) { - const initialMap: Record< - string, - Record - > = {}; - for (const user of systemRelationsQuery.data) { - initialMap[user.userId] = {}; - const menus = [ - "overview", - "audit_logs", - "tenants", - "org_chart", - "users", - "worksmobile", - "api_keys", - "ory_ssot", - "data_integrity", - "auth_guard", - "permissions_direct", - ]; - for (const m of menus) { - const isWrite = user.relations.includes(`${m}_managers`); - const isRead = user.relations.includes(`${m}_viewers`); - initialMap[user.userId][m] = isWrite - ? "write" - : isRead - ? "read" - : "none"; - } - } - setLocalSystemPermissions(initialMap); - } - }, [systemRelationsQuery.data]); - const addSystemRelationMutation = useMutation({ mutationFn: (payload: { userId: string; relation: string }) => addSystemRelation(payload.userId, payload.relation), @@ -325,14 +310,19 @@ export function TenantFineGrainedPermissionsPage() { const updateUserRoleMutation = useMutation({ mutationFn: (payload: { userIds: string[]; role: string }) => bulkUpdateUsers(payload), - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ["admin-users"] }); queryClient.invalidateQueries({ queryKey: ["me"] }); toast.success( - t( - "msg.admin.permissions_direct.super_admin_grant_success", - "Super Admin 역할이 λΆ€μ—¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", - ), + variables.role === "super_admin" + ? t( + "msg.admin.permissions_direct.super_admin_grant_success", + "Super Admin 역할이 λΆ€μ—¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + ) + : t( + "msg.admin.permissions_direct.super_admin_revoke_success", + "Super Admin 역할을 νšŒμˆ˜ν–ˆμŠ΅λ‹ˆλ‹€.", + ), ); setSelectedSuperAdminUserIds([]); }, @@ -344,70 +334,6 @@ export function TenantFineGrainedPermissionsPage() { }, }); - const handleSystemRelationChange = async ( - userId: string, - menuKey: string, - currentVal: "none" | "read" | "write", - newVal: "none" | "read" | "write", - ) => { - if (currentVal === newVal) return; - - try { - if (currentVal === "read") { - await removeSystemRelationMutation.mutateAsync({ - userId, - relation: `${menuKey}_viewers`, - }); - } else if (currentVal === "write") { - await removeSystemRelationMutation.mutateAsync({ - userId, - relation: `${menuKey}_managers`, - }); - } - - if (newVal === "read") { - await addSystemRelationMutation.mutateAsync({ - userId, - relation: `${menuKey}_viewers`, - }); - } else if (newVal === "write") { - await addSystemRelationMutation.mutateAsync({ - userId, - relation: `${menuKey}_managers`, - }); - } - - // 🌟 Trigger a single consolidated success toast at the very end - toast.success( - t( - "msg.admin.system.relations.update_success", - "μ‹œμŠ€ν…œ 메뉴 κΆŒν•œμ΄ μ„±κ³΅μ μœΌλ‘œ λ³€κ²½λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", - ), - ); - } catch { - // Individual mutations handle error toast via onError - } - }; - - const handleRemoveAllSystemRelations = async ( - userId: string, - userRelations: string[], - ) => { - if ( - !window.confirm( - t( - "msg.admin.system.relations.remove_all_confirm", - "이 μ‚¬μš©μžμ˜ λͺ¨λ“  μ‹œμŠ€ν…œ 메뉴 κΆŒν•œμ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?", - ), - ) - ) { - return; - } - for (const rel of userRelations) { - await removeSystemRelationMutation.mutateAsync({ userId, relation: rel }); - } - }; - const toggleSuperAdminUser = (userId: string, checked: boolean) => { setSelectedSuperAdminUserIds((current) => checked @@ -435,7 +361,10 @@ export function TenantFineGrainedPermissionsPage() { } const relation = resolveBulkRelation(); - if (bulkRelationMode === "page" && relation.startsWith("permissions_direct_")) { + if ( + bulkRelationMode === "page" && + relation.startsWith("permissions_direct_") + ) { toast.error( t( "msg.admin.permissions_direct.protected_relation", @@ -489,19 +418,19 @@ export function TenantFineGrainedPermissionsPage() { setQueuedTargetUsers([]); }; - const handleGrantSuperAdminRole = () => { + const handleRevokeSuperAdminRole = () => { if (selectedSuperAdminUserIds.length === 0) { toast.error( t( "msg.admin.permissions_direct.super_admin_users_required", - "Super Admin을 λΆ€μ—¬ν•  μ‚¬μš©μžλ₯Ό ν•˜λ‚˜ 이상 μ„ νƒν•˜μ„Έμš”.", + "νšŒμˆ˜ν•  Super Admin μ‚¬μš©μžλ₯Ό ν•˜λ‚˜ 이상 μ„ νƒν•˜μ„Έμš”.", ), ); return; } updateUserRoleMutation.mutate({ userIds: selectedSuperAdminUserIds, - role: "super_admin", + role: "user", }); }; @@ -537,18 +466,18 @@ export function TenantFineGrainedPermissionsPage() { name: selection.name, email: selection.email, tenantSlug: selection.leafTenantName, - tenant: selection.leafTenantName - ? { - id: "", - type: "ORGANIZATION", - slug: "", - name: selection.leafTenantName, - description: "", - status: "active", - memberCount: 0, - createdAt: "", - updatedAt: "", - } + tenant: selection.leafTenantName + ? { + id: "", + type: "ORGANIZATION", + slug: "", + name: selection.leafTenantName, + description: "", + status: "active", + memberCount: 0, + createdAt: "", + updatedAt: "", + } : undefined, metadata: { rootTenantName: selection.rootTenantName, @@ -698,8 +627,6 @@ export function TenantFineGrainedPermissionsPage() { }, ]; - const filteredRelations = systemRelations; - const selectedUser = undefined; const grantableSystemMenus = systemMenuCategories.flatMap((category) => category.menus.filter( (menu) => !protectedSystemMenuRelations.has(menu.relation), @@ -975,7 +902,19 @@ export function TenantFineGrainedPermissionsPage() { {t("ui.admin.permissions_direct.bulk_selected", "λͺ… 선택")} -
+
+
+