import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Crown, Plus, Search, ShieldCheck, UserPlus, Users, } from "lucide-react"; import { useState } from "react"; import { useAuth } from "react-oidc-context"; import { useNavigate, useParams } from "react-router-dom"; import { commonStickyTableHeaderClass } from "../../../../../common/ui/table"; 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 { addTenantAdmin, addTenantOwner, fetchTenantAdmins, fetchTenantOwners, fetchUsers, removeTenantAdmin, removeTenantOwner, type TenantAdmin, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; type DialogMode = "owner" | "admin"; function mergePendingMembers( members: TenantAdmin[], pendingMembers: TenantAdmin[], ) { const existingIds = new Set(members.map((member) => member.id)); return [ ...members, ...pendingMembers.filter((member) => !existingIds.has(member.id)), ]; } export function TenantAdminsAndOwnersTab() { const auth = useAuth(); const navigate = useNavigate(); const _currentUserId = auth.user?.profile.sub; const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>(); const tenantId = tenantIdParam ?? ""; const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); const [dialogMode, setDialogMode] = useState(null); const [pendingOwners, setPendingOwners] = useState([]); const [pendingAdmins, setPendingAdmins] = useState([]); const ownersQuery = useQuery({ queryKey: ["tenant-owners", tenantId], queryFn: () => fetchTenantOwners(tenantId), enabled: !!tenantId, }); const adminsQuery = useQuery({ queryKey: ["tenant-admins", tenantId], queryFn: () => fetchTenantAdmins(tenantId), enabled: !!tenantId, }); const usersQuery = useQuery({ queryKey: ["admin-users-search", searchTerm], queryFn: () => fetchUsers(20, 0, searchTerm), enabled: dialogMode !== null && searchTerm.length >= 2, }); const addOwnerMutation = useMutation({ mutationFn: (userId: string) => addTenantOwner(tenantId, userId), onMutate: async (userId) => { await queryClient.cancelQueries({ queryKey: ["tenant-owners", tenantId], }); const previousOwners = queryClient.getQueryData([ "tenant-owners", tenantId, ]); // Optimistically add to the list to prevent immediate double clicks const addedUser = searchResults.find((u) => u.id === userId); if (addedUser) { const optimisticOwner = { id: userId, name: addedUser.name, email: addedUser.email, }; setPendingOwners((old) => old.some((owner) => owner.id === userId) ? old : [...old, optimisticOwner], ); queryClient.setQueryData( ["tenant-owners", tenantId], (old) => { if (!old) return [optimisticOwner]; if (old.some((o) => o.id === userId)) return old; return [...old, optimisticOwner]; }, ); } return { previousOwners }; }, onSuccess: () => { // Delay invalidation slightly to give the backend outbox time to process setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId], }); }, 1000); toast.success( t("msg.admin.tenants.owners.add_success", "소유자가 추가되었습니다."), ); setSearchTerm(""); }, onError: (err: AxiosError<{ error?: string }>, userId, context) => { setPendingOwners((old) => old.filter((owner) => owner.id !== userId)); if (context?.previousOwners) { queryClient.setQueryData( ["tenant-owners", tenantId], context.previousOwners, ); } toast.error( err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."), ); }, }); const removeOwnerMutation = useMutation({ mutationFn: (userId: string) => removeTenantOwner(tenantId, userId), onMutate: async (userId) => { await queryClient.cancelQueries({ queryKey: ["tenant-owners", tenantId], }); const previousOwners = queryClient.getQueryData([ "tenant-owners", tenantId, ]); setPendingOwners((old) => old.filter((owner) => owner.id !== userId)); queryClient.setQueryData( ["tenant-owners", tenantId], (old) => (old ? old.filter((o) => o.id !== userId) : []), ); return { previousOwners }; }, onSuccess: () => { setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId], }); }, 1000); toast.success( t( "msg.admin.tenants.owners.remove_success", "소유자 권한이 회수되었습니다.", ), ); }, onError: (err: AxiosError<{ error?: string }>, _userId, context) => { if (context?.previousOwners) { queryClient.setQueryData( ["tenant-owners", tenantId], context.previousOwners, ); } toast.error( err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."), ); }, }); const addAdminMutation = useMutation({ mutationFn: (userId: string) => addTenantAdmin(tenantId, userId), onMutate: async (userId) => { await queryClient.cancelQueries({ queryKey: ["tenant-admins", tenantId], }); const previousAdmins = queryClient.getQueryData([ "tenant-admins", tenantId, ]); const addedUser = searchResults.find((u) => u.id === userId); if (addedUser) { const optimisticAdmin = { id: userId, name: addedUser.name, email: addedUser.email, }; setPendingAdmins((old) => old.some((admin) => admin.id === userId) ? old : [...old, optimisticAdmin], ); queryClient.setQueryData( ["tenant-admins", tenantId], (old) => { if (!old) return [optimisticAdmin]; if (old.some((a) => a.id === userId)) return old; return [...old, optimisticAdmin]; }, ); } return { previousAdmins }; }, onSuccess: () => { setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId], }); }, 1000); toast.success( t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."), ); setSearchTerm(""); }, onError: (err: AxiosError<{ error?: string }>, userId, context) => { setPendingAdmins((old) => old.filter((admin) => admin.id !== userId)); if (context?.previousAdmins) { queryClient.setQueryData( ["tenant-admins", tenantId], context.previousAdmins, ); } toast.error( err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."), ); }, }); const removeAdminMutation = useMutation({ mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId), onMutate: async (userId) => { await queryClient.cancelQueries({ queryKey: ["tenant-admins", tenantId], }); const previousAdmins = queryClient.getQueryData([ "tenant-admins", tenantId, ]); setPendingAdmins((old) => old.filter((admin) => admin.id !== userId)); queryClient.setQueryData( ["tenant-admins", tenantId], (old) => (old ? old.filter((a) => a.id !== userId) : []), ); return { previousAdmins }; }, onSuccess: () => { setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId], }); }, 1000); toast.success( t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."), ); }, onError: (err: AxiosError<{ error?: string }>, _userId, context) => { if (context?.previousAdmins) { queryClient.setQueryData( ["tenant-admins", tenantId], context.previousAdmins, ); } toast.error( err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."), ); }, }); const handleAddUser = (userId: string) => { if (dialogMode === "owner") { addOwnerMutation.mutate(userId); } else if (dialogMode === "admin") { addAdminMutation.mutate(userId); } }; const _handleRemoveOwner = (userId: string, userName: string) => { if ( window.confirm( t( "msg.admin.tenants.owners.remove_confirm", "소유자를 삭제하시겠습니까?", { name: userName }, ), ) ) { removeOwnerMutation.mutate(userId); } }; const _handleRemoveAdmin = (userId: string, userName: string) => { if ( window.confirm( t( "msg.admin.tenants.admins.remove_confirm", "관리자를 삭제하시겠습니까?", { name: userName }, ), ) ) { removeAdminMutation.mutate(userId); } }; if (!tenantId) return null; const serverOwners = ownersQuery.data || []; const serverAdmins = adminsQuery.data || []; const currentOwners = mergePendingMembers(serverOwners, pendingOwners); const currentAdmins = mergePendingMembers(serverAdmins, pendingAdmins); const searchResults = usersQuery.data?.items || []; const isDialogOpen = dialogMode !== null; const dialogTitle = dialogMode === "owner" ? t("ui.admin.tenants.owners.dialog_title", "새 소유자 추가") : t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가"); const dialogDescription = dialogMode === "owner" ? t( "ui.admin.tenants.owners.dialog_description", "이름 또는 이메일로 사용자를 검색하세요.", ) : t( "ui.admin.tenants.admins.dialog_description", "이름 또는 이메일로 사용자를 검색하세요.", ); return (
{/* Owners Card */}
{t("ui.admin.tenants.owners.title", "테넌트 소유자")} {t( "msg.admin.tenants.owners.subtitle", "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.", )}
{t("ui.admin.tenants.owners.table_name", "이름")} {t("ui.admin.tenants.owners.table_email", "이메일")} {ownersQuery.isLoading ? (
) : currentOwners.length === 0 ? (

{t( "msg.admin.tenants.owners.empty", "등록된 소유자가 없습니다.", )}

) : ( currentOwners.map((owner) => ( navigate(`/users/${owner.id}`)} >
{owner.name.charAt(0)}
{owner.name}
{owner.email}
)) )}
{/* Admins Card */}
{t("ui.admin.tenants.admins.title", "테넌트 관리자")} {t( "msg.admin.tenants.admins.subtitle", "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.", )}
{t("ui.admin.tenants.admins.table_name", "이름")} {t("ui.admin.tenants.admins.table_email", "이메일")} {adminsQuery.isLoading ? (
) : currentAdmins.length === 0 ? (

{t( "msg.admin.tenants.admins.empty", "등록된 관리자가 없습니다.", )}

) : ( currentAdmins.map((admin) => ( navigate(`/users/${admin.id}`)} >
{admin.name.charAt(0)}
{admin.name}
{admin.email}
)) )}
{/* Common Dialog for adding users */} { if (!open) { setDialogMode(null); setSearchTerm(""); } }} > {dialogTitle} {dialogDescription}
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 isAlreadyOwner = currentOwners.some( (o) => o.id === user.id, ); const isAlreadyAdmin = currentAdmins.some( (a) => a.id === user.id, ); const isAlreadyMember = dialogMode === "owner" ? isAlreadyOwner : isAlreadyAdmin; return (
{user.name.charAt(0)}
{user.name} {user.email}
); })}
)}
); } export default TenantAdminsAndOwnersTab;