import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Plus, Search, ShieldCheck, UserPlus, } from "lucide-react"; import { useState, useEffect } from "react"; import { useParams } from "react-router-dom"; import { useTenantPermission } from "../hooks/useTenantPermission"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../../../components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../../../components/ui/dialog"; import { Input } from "../../../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../../components/ui/table"; import { toast } from "../../../components/ui/use-toast"; import { fetchUsers, fetchTenantRelations, addTenantRelation, removeTenantRelation, type TenantRelation, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { Trash2 } from "lucide-react"; interface TenantFineGrainedPermissionsTabProps { tenantIdProp?: string; } export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrainedPermissionsTabProps = {}) { const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>(); const tenantId = tenantIdProp || tenantIdParam || ""; const { hasPermission } = useTenantPermission(tenantId); const isWritable = hasPermission("manage_admins"); const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); const [isDialogOpen, setIsDialogOpen] = useState(false); // 🌟 ν…Œλ„ŒνŠΈ 탭별 λ“œλ‘­λ‹€μš΄ 즉각 변경을 μœ„ν•œ μž„μ‹œ 둜컬 λ§΅ μ„ μ–Έ const [localTenantPermissions, setLocalTenantPermissions] = useState>>({}); const relationsQuery = useQuery({ queryKey: ["tenant-relations", tenantId], queryFn: () => fetchTenantRelations(tenantId), enabled: !!tenantId, }); const relationsData = relationsQuery.data ?? []; // 🌟 μ„œλ²„ 데이터λ₯Ό μˆ˜μ‹ ν•˜λ©΄ 둜컬 λ³€κ²½ μƒνƒœ 맡을 μ‹€μ‹œκ°„ 동기화 useEffect(() => { if (relationsQuery.data) { const initialMap: Record> = {}; for (const user of relationsQuery.data) { initialMap[user.userId] = {}; const tabs = ["profile", "permissions", "organization", "schema"]; for (const tab of tabs) { const isWrite = user.relations.includes(`${tab}_managers`); const isRead = user.relations.includes(`${tab}_viewers`); initialMap[user.userId][tab] = isWrite ? "write" : isRead ? "read" : "none"; } } setLocalTenantPermissions(initialMap); } }, [relationsQuery.data]); const relations = relationsQuery.data ?? []; const invalidateAllQueries = () => { queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] }); queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); queryClient.invalidateQueries({ queryKey: ["me"] }); setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] }); queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); queryClient.invalidateQueries({ queryKey: ["me"] }); }, 500); }; const addRelationMutation = useMutation({ mutationFn: (payload: { userId: string; relation: string }) => addTenantRelation(tenantId, payload.userId, payload.relation), onMutate: async (newRelation) => { await queryClient.cancelQueries({ queryKey: ["tenant-relations", tenantId] }); const previousRelations = queryClient.getQueryData(["tenant-relations", tenantId]); queryClient.setQueryData(["tenant-relations", tenantId], (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(["tenant-relations", tenantId], context.previousRelations); } toast.error(err.response?.data?.error || t("msg.common.error", "였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")); }, onSuccess: () => { // Quiet mutate }, }); const removeRelationMutation = useMutation({ mutationFn: (payload: { userId: string; relation: string }) => removeTenantRelation(tenantId, payload.userId, payload.relation), onMutate: async (targetRelation) => { await queryClient.cancelQueries({ queryKey: ["tenant-relations", tenantId] }); const previousRelations = queryClient.getQueryData(["tenant-relations", tenantId]); queryClient.setQueryData(["tenant-relations", tenantId], (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(["tenant-relations", tenantId], context.previousRelations); } toast.error(err.response?.data?.error || t("msg.common.error", "였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")); }, onSuccess: () => { // Quiet mutate }, }); const handleRelationChange = async ( userId: string, tab: "profile" | "permissions" | "organization" | "schema", currentVal: "none" | "read" | "write", newVal: "none" | "read" | "write", ) => { const readRel = `${tab}_viewers`; const writeRel = `${tab}_managers`; if (currentVal === newVal) return; try { if (currentVal === "read") { await removeRelationMutation.mutateAsync({ userId, relation: readRel }); } else if (currentVal === "write") { await removeRelationMutation.mutateAsync({ userId, relation: writeRel }); } if (newVal === "read") { await addRelationMutation.mutateAsync({ userId, relation: readRel }); } else if (newVal === "write") { await addRelationMutation.mutateAsync({ userId, relation: writeRel }); } invalidateAllQueries(); // 🌟 Trigger a single consolidated success toast at the very end toast.success(t("msg.admin.tenants.relations.update_success", "μ„ΈλΆ€ κΆŒν•œμ΄ μ„±κ³΅μ μœΌλ‘œ λ³€κ²½λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")); } catch { // Individual mutations handle error toast via onError } }; const handleRemoveAllRelations = async (userId: string, userRelations: string[]) => { if (!window.confirm(t("msg.admin.tenants.relations.remove_all_confirm", "이 μ‚¬μš©μžμ˜ λͺ¨λ“  μ„ΈλΆ€ κΆŒν•œμ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?"))) { return; } for (const rel of userRelations) { await removeRelationMutation.mutateAsync({ userId, relation: rel }); } invalidateAllQueries(); }; const usersQuery = useQuery({ queryKey: ["admin-users-search", searchTerm], queryFn: () => fetchUsers(20, 0, searchTerm), enabled: isDialogOpen && searchTerm.length >= 2, }); const handleAddUser = (userId: string) => { addRelationMutation.mutate({ userId, relation: "profile_viewers" }, { onSettled: () => { invalidateAllQueries(); } }); setIsDialogOpen(false); setSearchTerm(""); }; if (!tenantId) return null; const searchResults = usersQuery.data?.items || []; return (
{t("ui.admin.tenants.relations.title", "μ„ΈλΆ€ κΆŒν•œ μ„€μ • (Fine-grained Permissions)")} {t( "msg.admin.tenants.relations.subtitle", "μ‚¬μš©μžλ³„λ‘œ 각 νƒ­μ˜ μ„ΈλΆ€ 쑰회 및 μˆ˜μ • κΆŒν•œμ„ κ²©λ¦¬ν•˜μ—¬ ν• λ‹Ήν•©λ‹ˆλ‹€. μƒμœ„ 상속 κΆŒν•œμ€ μžλ™μœΌλ‘œ λ³΄μ‘΄λ©λ‹ˆλ‹€.", )}
{t("ui.common.name", "이름")} {t("ui.admin.tenants.detail.tab_profile", "ν…Œλ„ŒνŠΈ ν”„λ‘œν•„")} {t("ui.admin.tenants.detail.tab_permissions", "κΆŒν•œ 관리")} {t("ui.admin.tenants.detail.tab_organization", "쑰직 관리")} {t("ui.admin.tenants.detail.tab_schema", "μ‚¬μš©μž μŠ€ν‚€λ§ˆ")} {t("ui.common.action", "μž‘μ—…")} {relations.length === 0 ? ( {t("msg.admin.tenants.relations.empty", "μ„ΈλΆ€ κΆŒν•œμ΄ μ§€μ •λœ μ‚¬μš©μžκ°€ μ—†μŠ΅λ‹ˆλ‹€. μ‚¬μš©μžλ₯Ό μΆ”κ°€ν•΄ μ„€μ •ν•˜μ„Έμš”.")} ) : ( relations.map((user) => { const profileVal = user.relations.includes("profile_managers") ? "write" : user.relations.includes("profile_viewers") ? "read" : "none"; const permissionsVal = user.relations.includes("permissions_managers") ? "write" : user.relations.includes("permissions_viewers") ? "read" : "none"; const organizationVal = user.relations.includes("organization_managers") ? "write" : user.relations.includes("organization_viewers") ? "read" : "none"; const schemaVal = user.relations.includes("schema_managers") ? "write" : user.relations.includes("schema_viewers") ? "read" : "none"; const curProfileVal = localTenantPermissions[user.userId]?.profile ?? profileVal; const curPermissionsVal = localTenantPermissions[user.userId]?.permissions ?? permissionsVal; const curOrganizationVal = localTenantPermissions[user.userId]?.organization ?? organizationVal; const curSchemaVal = localTenantPermissions[user.userId]?.schema ?? schemaVal; return (
{user.name} {user.email}
); }) )}
{/* Common Dialog for adding users */} { if (!open) { setIsDialogOpen(false); setSearchTerm(""); } }} > {t("ui.admin.tenants.relations.dialog_title", "μ„ΈλΆ€ κΆŒν•œ 관리 μœ μ € μΆ”κ°€")} {t( "ui.admin.tenants.admins.dialog_description", "이름 λ˜λŠ” μ΄λ©”μΌλ‘œ μ‚¬μš©μžλ₯Ό κ²€μƒ‰ν•˜μ„Έμš”.", )}
setSearchTerm(e.target.value)} />
{searchTerm.length < 2 ? (

{t( "ui.admin.tenants.admins.dialog_search_hint", "검색어λ₯Ό μž…λ ₯ν•΄ μ£Όμ„Έμš”.", )}

) : usersQuery.isLoading ? (
) : searchResults.length === 0 ? (
{t( "ui.admin.tenants.admins.dialog_no_results", "검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€.", )}
) : (
{searchResults.map((user) => { const isAlreadyInMatrix = relations.some( (r) => r.userId === user.id, ); return (
{user.name.charAt(0)}
{user.name} {user.email}
); })}
)}
); }