import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Building2, Database, Key, KeyRound, LayoutDashboard, Network, NotebookTabs, Plus, Search, Share2, Shield, ShieldCheck, Trash2, Users, X, } 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, DialogDescription, DialogHeader, DialogTitle, } from "../../../components/ui/dialog"; import { Input } from "../../../components/ui/input"; import { ScrollArea } from "../../../components/ui/scroll-area"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../../components/ui/table"; import { toast } from "../../../components/ui/use-toast"; import { addSystemRelation, addTenantRelation, bulkUpdateUsers, fetchAllTenants, fetchMe, fetchSystemRelations, fetchTenantRelations, removeSystemRelation, removeTenantRelation, type TenantRelation, type UserSummary, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { buildAuthenticatedOrgChartUserMultiPickerUrl, parseOrgChartUserSelections, } from "../../users/orgChartPicker"; const protectedSystemMenuRelations = new Set([ "ory_ssot", "data_integrity", "permissions_direct", ]); export function TenantFineGrainedPermissionsPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); const [activeTab, _setActiveTab] = useState<"tenant" | "system">("system"); const [activePermissionTab, setActivePermissionTab] = useState< "direct" | "super-admin" >("direct"); const [_selectedTenantId, _setSelectedTenantId] = useState(""); const [targetTenantId, setTargetTenantId] = useState(""); const [queuedTargetUsers, setQueuedTargetUsers] = useState([]); const [bulkRelationMode, setBulkRelationMode] = useState< "page" | "target-action" >("page"); const [bulkPageRelation, setBulkPageRelation] = useState("overview_viewers"); const [bulkTenantPage, setBulkTenantPage] = useState("profile"); const [bulkAction, setBulkAction] = useState<"read" | "manage">("read"); const [tenantPickerOpen, setTenantPickerOpen] = useState(false); const [tenantPickerSearch, setTenantPickerSearch] = useState(""); const [selectedSuperAdminUserIds, setSelectedSuperAdminUserIds] = useState< string[] >([]); const [assignmentSearchTerm, setAssignmentSearchTerm] = useState(""); const [assignmentSort, setAssignmentSort] = useState< "user" | "relation" | "level" >("user"); const orgChartMemberPickerUrl = useMemo( () => buildAuthenticatedOrgChartUserMultiPickerUrl(import.meta.env.ORGFRONT_URL), [], ); // ๐ŸŒŸ ๊ธ€๋กœ๋ฒŒ ์‹œ์Šคํ…œ ๋“œ๋กญ๋‹ค์šด ์ฆ‰๊ฐ ๋ณ€๊ฒฝ์„ ์œ„ํ•œ ์ž„์‹œ ๋กœ์ปฌ ๋งต ์„ ์–ธ const [localSystemPermissions, setLocalSystemPermissions] = useState< Record> >({}); const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, }); const isSuperAdmin = profile?.role === "super_admin"; const tenantsQuery = useQuery({ queryKey: ["tenants", "list-all"], queryFn: () => fetchAllTenants(), enabled: isSuperAdmin, }); const tenants = isSuperAdmin ? (tenantsQuery.data?.items ?? []) : (profile?.manageableTenants ?? []); // System Relations (Admin Control) Queries & Mutations const systemRelationsQuery = useQuery({ queryKey: ["system-relations"], queryFn: fetchSystemRelations, enabled: isSuperAdmin && activeTab === "system", }); const systemRelations = systemRelationsQuery.data ?? []; const tenantRelationsQuery = useQuery({ queryKey: ["tenant-relations", targetTenantId], queryFn: () => fetchTenantRelations(targetTenantId), enabled: isSuperAdmin && activePermissionTab === "direct" && bulkRelationMode === "target-action" && targetTenantId.length > 0, }); 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), onMutate: async (newRelation) => { await queryClient.cancelQueries({ queryKey: ["system-relations"] }); const previousRelations = queryClient.getQueryData([ "system-relations", ]); queryClient.setQueryData( ["system-relations"], (old) => { if (!old) return []; return old.map((user) => { if (user.userId === newRelation.userId) { return { ...user, relations: user.relations.includes(newRelation.relation) ? user.relations : [...user.relations, newRelation.relation], }; } return user; }); }, ); return { previousRelations }; }, onError: (err: AxiosError<{ error?: string }>, _, context) => { if (context?.previousRelations) { queryClient.setQueryData( ["system-relations"], context.previousRelations, ); } toast.error( err.response?.data?.error || t("msg.common.error", "์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), ); }, onSuccess: () => { // Quiet mutate }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["system-relations"] }); queryClient.invalidateQueries({ queryKey: ["me"] }); setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["system-relations"] }); queryClient.invalidateQueries({ queryKey: ["me"] }); }, 500); }, }); const removeSystemRelationMutation = useMutation({ mutationFn: (payload: { userId: string; relation: string }) => removeSystemRelation(payload.userId, payload.relation), onMutate: async (targetRelation) => { await queryClient.cancelQueries({ queryKey: ["system-relations"] }); const previousRelations = queryClient.getQueryData([ "system-relations", ]); queryClient.setQueryData( ["system-relations"], (old) => { if (!old) return []; return old.map((user) => { if (user.userId === targetRelation.userId) { return { ...user, relations: user.relations.filter( (r) => r !== targetRelation.relation, ), }; } return user; }); }, ); return { previousRelations }; }, onError: (err: AxiosError<{ error?: string }>, _, context) => { if (context?.previousRelations) { queryClient.setQueryData( ["system-relations"], context.previousRelations, ); } toast.error( err.response?.data?.error || t("msg.common.error", "์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), ); }, onSuccess: () => { // Quiet mutate }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["system-relations"] }); queryClient.invalidateQueries({ queryKey: ["me"] }); setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["system-relations"] }); queryClient.invalidateQueries({ queryKey: ["me"] }); }, 500); }, }); const addTenantRelationMutation = useMutation({ mutationFn: (payload: { tenantId: string; userId: string; relation: string; }) => addTenantRelation(payload.tenantId, payload.userId, payload.relation), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ["tenant-relations", variables.tenantId], }); }, onError: (err: AxiosError<{ error?: string }>) => { toast.error( err.response?.data?.error || t("msg.common.error", "์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), ); }, }); const removeTenantRelationMutation = useMutation({ mutationFn: (payload: { tenantId: string; userId: string; relation: string; }) => removeTenantRelation(payload.tenantId, payload.userId, payload.relation), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ["tenant-relations", variables.tenantId], }); }, onError: (err: AxiosError<{ error?: string }>) => { toast.error( err.response?.data?.error || t("msg.common.error", "์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), ); }, }); const updateUserRoleMutation = useMutation({ mutationFn: (payload: { userIds: string[]; role: string }) => bulkUpdateUsers(payload), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin-users"] }); queryClient.invalidateQueries({ queryKey: ["me"] }); toast.success( t( "msg.admin.permissions_direct.super_admin_grant_success", "Super Admin ์—ญํ• ์ด ๋ถ€์—ฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", ), ); setSelectedSuperAdminUserIds([]); }, onError: (err: AxiosError<{ error?: string }>) => { toast.error( err.response?.data?.error || t("msg.common.error", "์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), ); }, }); 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 ? [...new Set([...current, userId])] : current.filter((id) => id !== userId), ); }; const resolveBulkRelation = () => { if (bulkRelationMode === "page") { return bulkPageRelation; } return `${bulkTenantPage}_${bulkAction === "manage" ? "managers" : "viewers"}`; }; const handleBulkRelationSubmit = async () => { if (queuedTargetUsers.length === 0) { toast.error( t( "msg.admin.permissions_direct.bulk_users_required", "๊ถŒํ•œ์„ ์ ์šฉํ•  ์‚ฌ์šฉ์ž๋ฅผ ํ•˜๋‚˜ ์ด์ƒ ์„ ํƒํ•˜์„ธ์š”.", ), ); return; } const relation = resolveBulkRelation(); if (bulkRelationMode === "page" && relation.startsWith("permissions_direct_")) { toast.error( t( "msg.admin.permissions_direct.protected_relation", "๊ถŒํ•œ ๋ถ€์—ฌ ํ™”๋ฉด ์ ‘๊ทผ ๊ถŒํ•œ์€ Super Admin ์ „์šฉ์ž…๋‹ˆ๋‹ค.", ), ); return; } if (bulkRelationMode === "target-action" && !targetTenantId) { toast.error( t( "msg.admin.permissions_direct.target_tenant_required", "๋Œ€์ƒ ํ…Œ๋„ŒํŠธ๋ฅผ ์„ ํƒํ•˜์„ธ์š”.", ), ); return; } for (const user of queuedTargetUsers) { if (bulkRelationMode === "page") { await addSystemRelationMutation.mutateAsync({ userId: user.id, relation, }); } else { const currentSystemRelations = systemRelations.find((item) => item.userId === user.id)?.relations ?? []; const requiredPageAccess = bulkAction === "manage" ? "tenants_managers" : "tenants_viewers"; if (!currentSystemRelations.includes(requiredPageAccess)) { await addSystemRelationMutation.mutateAsync({ userId: user.id, relation: requiredPageAccess, }); } await addTenantRelationMutation.mutateAsync({ tenantId: targetTenantId, userId: user.id, relation, }); } } toast.success( t( "msg.admin.permissions_direct.bulk_grant_success", "์„ ํƒ ์‚ฌ์šฉ์ž์—๊ฒŒ ๊ถŒํ•œ์„ ๋ถ€์—ฌํ–ˆ์Šต๋‹ˆ๋‹ค.", ), ); setQueuedTargetUsers([]); }; const handleGrantSuperAdminRole = () => { if (selectedSuperAdminUserIds.length === 0) { toast.error( t( "msg.admin.permissions_direct.super_admin_users_required", "Super Admin์„ ๋ถ€์—ฌํ•  ์‚ฌ์šฉ์ž๋ฅผ ํ•˜๋‚˜ ์ด์ƒ ์„ ํƒํ•˜์„ธ์š”.", ), ); return; } updateUserRoleMutation.mutate({ userIds: selectedSuperAdminUserIds, role: "super_admin", }); }; const queueTargetUsers = useCallback((users: UserSummary[]) => { setQueuedTargetUsers((current) => { const next = [...current]; const ids = new Set(current.map((user) => user.id)); for (const user of users) { if (ids.has(user.id)) continue; ids.add(user.id); next.push(user); } return next; }); }, []); const removeQueuedTargetUser = (userId: string) => { setQueuedTargetUsers((current) => current.filter((user) => user.id !== userId), ); }; useEffect(() => { if (activePermissionTab !== "direct") return; const onMessage = (event: MessageEvent) => { const selections = parseOrgChartUserSelections(event.data); if (selections.length === 0) return; queueTargetUsers( selections.map((selection) => ({ id: selection.id, name: selection.name, email: selection.email, tenantSlug: selection.leafTenantName, tenant: selection.leafTenantName ? { id: "", slug: "", name: selection.leafTenantName, createdAt: "", updatedAt: "", } : undefined, metadata: { rootTenantName: selection.rootTenantName, leafTenantName: selection.leafTenantName, }, role: "user", status: "active", createdAt: "", updatedAt: "", })), ); }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [activePermissionTab, queueTargetUsers]); // Categorized system menus with descriptions and icons const systemMenuCategories = [ { title: t( "ui.admin.permissions_direct.cat_dashboard", "ํ•ต์‹ฌ ๋Œ€์‹œ๋ณด๋“œ ๋ฐ ๋ถ„์„", ), menus: [ { label: t("ui.admin.nav.overview", "๊ฐœ์š”"), relation: "overview", desc: t( "msg.admin.permissions_direct.desc_overview", "๋ฐ”๋ก  ์ „์ฒด ์‚ฌ์–‘ ๋ฐ ์‹œ์Šคํ…œ ์ƒํƒœ ๊ฐœ์š” ์ •๋ณด", ), icon: LayoutDashboard, }, { label: t("ui.admin.nav.audit_logs", "๊ฐ์‚ฌ ๋กœ๊ทธ"), relation: "audit_logs", desc: t( "msg.admin.permissions_direct.desc_audit_logs", "์‹œ์Šคํ…œ ์ „์—ญ ๋ณด์•ˆ ๊ฐ์‚ฌ ๋ฐ ์ ‘์† ์ด๋ ฅ ๋กœ๊ทธ", ), icon: NotebookTabs, }, ], }, { title: t("ui.admin.permissions_direct.cat_resources", "ํ•ต์‹ฌ ๋ฆฌ์†Œ์Šค ๊ด€๋ฆฌ"), menus: [ { label: t("ui.admin.nav.tenants", "ํ…Œ๋„ŒํŠธ"), relation: "tenants", desc: t( "msg.admin.permissions_direct.desc_tenants", "๊ณ ๊ฐ ํ…Œ๋„ŒํŠธ ๋ชฉ๋ก, ์‹ ๊ทœ ๋ถ€๋ชจ-์ž์‹ ํ…Œ๋„ŒํŠธ ๊ด€๋ฆฌ", ), icon: Building2, }, { label: t("ui.admin.nav.org_chart", "์กฐ์ง๋„"), relation: "org_chart", desc: t( "msg.admin.permissions_direct.desc_org_chart", "์กฐ์ง๋„ ๊ฐ€์‹œํ™” ๋ฐ ํŠธ๋ฆฌ ๋ฐฐ์น˜ ํ™•์ธ", ), icon: Network, }, { label: t("ui.admin.nav.users", "์‚ฌ์šฉ์ž"), relation: "users", desc: t( "msg.admin.permissions_direct.desc_users", "๊ฐ€์ž… ์‚ฌ์šฉ์ž ๋ชฉ๋ก, ์Šน์ธ ๋ฐ ์ปค์Šคํ…€ ํด๋ ˆ์ž„ ์ˆ˜๋™ ์ฃผ์ž…", ), icon: Users, }, ], }, { title: t( "ui.admin.permissions_direct.cat_integrations", "์ธํ”„๋ผ ์—ฐ๋™ ๋ฐ ๋ณด์•ˆ", ), menus: [ { label: t("ui.admin.nav.worksmobile", "Worksmobile"), relation: "worksmobile", desc: t( "msg.admin.permissions_direct.desc_worksmobile", "๋ผ์ธ์›์Šค ์—ฐ๋™ ๋ฐ ์‚ฌ๋‚ด ์ž„์ง์› ํŒจ์Šค์›Œ๋“œ ๊ฐ•์ œ ๋™๊ธฐํ™”", ), icon: Share2, }, { label: t("ui.admin.nav.api_keys", "API ํ‚ค"), relation: "api_keys", desc: t( "msg.admin.permissions_direct.desc_api_keys", "์กฐ์ง๋„ ์—ฐ๋™์„ ์œ„ํ•œ ์ „์—ญ ์„œ๋“œํŒŒํ‹ฐ ํ† ํฐ ๊ด€๋ฆฌ", ), icon: Key, }, ], }, { title: t( "ui.admin.permissions_direct.cat_system", "์•„์ด๋ดํ‹ฐํ‹ฐ ๋ฐ ๊ฒŒ์ดํŠธ ๊ด€๋ฆฌ", ), menus: [ { label: t("ui.admin.nav.ory_ssot", "Ory SSOT ์‹œ์Šคํ…œ"), relation: "ory_ssot", desc: t( "msg.admin.permissions_direct.desc_ory_ssot", "Redis ์•„์ด๋ดํ‹ฐํ‹ฐ ๋ฏธ๋Ÿฌ ์บ์‹œ ๋ฐ PostgreSQL read model ์ •ํ•ฉ์„ฑ ๊ฐฑ์‹ ", ), icon: Database, }, { label: t("ui.admin.nav.data_integrity", "๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ"), relation: "data_integrity", desc: t( "msg.admin.permissions_direct.desc_data_integrity", "๊ณ ์•„ ๋ ˆ์ฝ”๋“œ ๊ฒ€์ถœ ๋ฐ DB ์ •ํ•ฉ์„ฑ ์ตœ์ข… ๊ฒ€์ฆ๊ธฐ", ), icon: ShieldCheck, }, { label: t("ui.admin.nav.auth_guard", "์ธ์ฆ ๊ฐ€๋“œ"), relation: "auth_guard", desc: t( "msg.admin.permissions_direct.desc_auth_guard", "์ •์ฑ…์—”์ง„ ๊ธฐ์ค€์œผ๋กœ Keto ReBAC ๊ด€๊ณ„ ๊ฒ€์ฆ ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ", ), icon: KeyRound, }, { label: t("ui.admin.nav.permissions_direct", "๊ถŒํ•œ ๋ถ€์—ฌ"), relation: "permissions_direct", desc: t( "msg.admin.permissions_direct.desc_permissions_direct", "๋ณธ ์‚ฌ์ด๋“œ๋ฐ” ๋ฉ”๋‰ด ์„ธ๋ถ€ ๊ถŒํ•œ ๊ฒฉ์ž ๋ฐ ํ…Œ๋„ŒํŠธ ์ธ๊ฐ€ ์„ค์ • ํŒจ๋„", ), icon: Shield, }, ], }, ]; const filteredRelations = systemRelations; const selectedUser = undefined; const grantableSystemMenus = systemMenuCategories.flatMap((category) => category.menus.filter( (menu) => !protectedSystemMenuRelations.has(menu.relation), ), ); const menuByRelation = new Map( systemMenuCategories .flatMap((category) => category.menus) .map((menu) => [menu.relation, menu]), ); const pageRelationOptions = grantableSystemMenus.flatMap((menu) => [ { label: `${menu.label} - ${t("ui.common.read", "์กฐํšŒ")}`, value: `${menu.relation}_viewers`, }, { label: `${menu.label} - ${t("ui.common.write", "์ˆ˜์ •")}`, value: `${menu.relation}_managers`, }, ]); const tenantPermissionPages = [ { value: "profile", label: t("ui.admin.tenants.detail.tab_profile", "ํ…Œ๋„ŒํŠธ ํ”„๋กœํ•„"), }, { value: "permissions", label: t("ui.admin.tenants.detail.tab_permissions", "๊ถŒํ•œ ๊ด€๋ฆฌ"), }, { value: "organization", label: t("ui.admin.tenants.detail.tab_organization", "์กฐ์ง ๊ด€๋ฆฌ"), }, { value: "schema", label: t("ui.admin.tenants.detail.tab_schema", "์‚ฌ์šฉ์ž ์Šคํ‚ค๋งˆ"), }, ]; const selectedTargetTenant = tenants.find( (tenant) => tenant.id === targetTenantId, ); const tenantPickerCandidates = tenants.filter((tenant) => { const query = tenantPickerSearch.trim().toLowerCase(); if (!query) return true; return ( tenant.name.toLowerCase().includes(query) || tenant.slug.toLowerCase().includes(query) ); }); const permissionAssignmentRows = systemRelations.flatMap((user) => user.relations.map((relation) => { const level = relation.endsWith("_managers") ? "write" : "read"; const target = relation.replace(/_(viewers|managers)$/, ""); const menu = menuByRelation.get(target); return { scope: "system" as const, user, relation, target, level, label: menu?.label ?? target, tenantId: "", tenantName: t("ui.admin.permissions_direct.scope_system", "์ „์—ญ"), protected: protectedSystemMenuRelations.has(target), }; }), ); const tenantPermissionPageByValue = new Map( tenantPermissionPages.map((page) => [page.value, page.label]), ); const tenantPermissionAssignmentRows = tenantRelations.flatMap((user) => user.relations.map((relation) => { const level = relation.endsWith("_managers") ? "write" : "read"; const target = relation.replace(/_(viewers|managers)$/, ""); return { scope: "tenant" as const, user, relation, target, level, label: tenantPermissionPageByValue.get(target) ?? target, tenantId: targetTenantId, tenantName: selectedTargetTenant?.name ?? t("ui.admin.permissions_direct.scope_tenant", "ํ…Œ๋„ŒํŠธ"), protected: false, }; }), ); const allPermissionAssignmentRows = bulkRelationMode === "target-action" ? tenantPermissionAssignmentRows : permissionAssignmentRows; const filteredPermissionAssignmentRows = allPermissionAssignmentRows .filter((row) => { const query = assignmentSearchTerm.trim().toLowerCase(); if (!query) return true; return ( row.user.name.toLowerCase().includes(query) || row.user.email.toLowerCase().includes(query) || row.relation.toLowerCase().includes(query) || row.label.toLowerCase().includes(query) ); }) .sort((a, b) => { if (assignmentSort === "relation") { const relationCompare = a.label.localeCompare(b.label); if (relationCompare !== 0) return relationCompare; } if (assignmentSort === "level") { const levelCompare = a.level.localeCompare(b.level); if (levelCompare !== 0) return levelCompare; } return a.user.name.localeCompare(b.user.name); }); const handleAssignmentLevelChange = async ( scope: "system" | "tenant", tenantId: string, userId: string, relation: string, nextLevel: "none" | "read" | "write", ) => { const target = relation.replace(/_(viewers|managers)$/, ""); if (scope === "system" && protectedSystemMenuRelations.has(target)) return; if (scope === "system") { await removeSystemRelationMutation.mutateAsync({ userId, relation }); } else { await removeTenantRelationMutation.mutateAsync({ tenantId, userId, relation, }); } if (nextLevel === "read") { if (scope === "system") { await addSystemRelationMutation.mutateAsync({ userId, relation: `${target}_viewers`, }); } else { await addTenantRelationMutation.mutateAsync({ tenantId, userId, relation: `${target}_viewers`, }); } } else if (nextLevel === "write") { if (scope === "system") { await addSystemRelationMutation.mutateAsync({ userId, relation: `${target}_managers`, }); } else { await addTenantRelationMutation.mutateAsync({ tenantId, userId, relation: `${target}_managers`, }); } } }; const handleAssignmentRemove = async ( scope: "system" | "tenant", tenantId: string, userId: string, relation: string, ) => { if (scope === "system") { await removeSystemRelationMutation.mutateAsync({ userId, relation }); } else { await removeTenantRelationMutation.mutateAsync({ tenantId, userId, relation, }); } }; if (profile && !isSuperAdmin) { return (

{t("msg.admin.common.forbidden", "์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.")}

); } return (

{t("ui.admin.nav.permissions_direct", "๊ถŒํ•œ ๋ถ€์—ฌ")}

{t( "msg.admin.permissions_direct.description", "ํ…Œ๋„ŒํŠธ์˜ ์„ธ๋ถ€ ๊ธฐ๋Šฅ ๊ถŒํ•œ ๋ฐ ๊ธ€๋กœ๋ฒŒ ์‚ฌ์ด๋“œ๋ฐ” ๋ฉ”๋‰ด ํƒญ ์ ‘๊ทผ ๊ถŒํ•œ์„ ์ง€์ •ํ•˜๊ณ  ๋ถ€์—ฌํ•ฉ๋‹ˆ๋‹ค.", )}

{isSuperAdmin && ( )}
{activePermissionTab === "direct" && ( <>

{t( "ui.admin.permissions_direct.bulk_title", "๋‹ค์ค‘ ์‚ฌ์šฉ์ž ๊ถŒํ•œ ๋ถ€์—ฌ ๋ฐ ํšŒ์ˆ˜", )}

{t( "msg.admin.permissions_direct.bulk_description", "ํŽ˜์ด์ง€๋ณ„ ์ ‘๊ทผ ๊ถŒํ•œ ๋˜๋Š” ๋Œ€์ƒ+์•ก์…˜ ๊ถŒํ•œ์„ ์„ ํƒํ•œ ์‚ฌ์šฉ์ž๋“ค์—๊ฒŒ ๋™์‹œ์— ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.", )}

{t("ui.admin.permissions_direct.bulk_users", "์ ์šฉ ๋Œ€์ƒ")}

{queuedTargetUsers.length} {t("ui.admin.permissions_direct.bulk_selected", "๋ช… ์„ ํƒ")}
{queuedTargetUsers.length === 0 ? (
{t( "ui.admin.permissions_direct.target_queue_empty", "์ ์šฉํ•  ์‚ฌ์šฉ์ž๋ฅผ ์„ ํƒํ•˜์„ธ์š”.", )}
) : (
{queuedTargetUsers.map((user) => ( {user.name} {(user.metadata?.rootTenantName || user.metadata?.leafTenantName) && ( {[user.metadata?.rootTenantName, user.metadata?.leafTenantName] .filter(Boolean) .join(" / ")} )} ))}
)}