forked from baron/baron-sso
674 lines
25 KiB
TypeScript
674 lines
25 KiB
TypeScript
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<DialogMode | null>(null);
|
|
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
|
|
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
|
|
|
|
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<TenantAdmin[]>([
|
|
"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<TenantAdmin[]>(
|
|
["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<TenantAdmin[]>([
|
|
"tenant-owners",
|
|
tenantId,
|
|
]);
|
|
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
|
|
queryClient.setQueryData<TenantAdmin[]>(
|
|
["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<TenantAdmin[]>([
|
|
"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<TenantAdmin[]>(
|
|
["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<TenantAdmin[]>([
|
|
"tenant-admins",
|
|
tenantId,
|
|
]);
|
|
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
|
|
queryClient.setQueryData<TenantAdmin[]>(
|
|
["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 (
|
|
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
|
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
|
|
{/* Owners Card */}
|
|
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
|
|
<div className="space-y-1">
|
|
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
|
<Crown className="h-6 w-6 text-yellow-500" />
|
|
{t("ui.admin.tenants.owners.title", "테넌트 소유자")}
|
|
</CardTitle>
|
|
<CardDescription className="text-muted-foreground">
|
|
{t(
|
|
"msg.admin.tenants.owners.subtitle",
|
|
"이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.",
|
|
)}
|
|
</CardDescription>
|
|
</div>
|
|
<Button
|
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
|
onClick={() => setDialogMode("owner")}
|
|
>
|
|
<UserPlus className="mr-2 h-4 w-4" />
|
|
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
|
<Table>
|
|
<TableHeader className={commonStickyTableHeaderClass}>
|
|
<TableRow>
|
|
<TableHead className="w-[250px] font-bold">
|
|
{t("ui.admin.tenants.owners.table_name", "이름")}
|
|
</TableHead>
|
|
<TableHead className="font-bold">
|
|
{t("ui.admin.tenants.owners.table_email", "이메일")}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{ownersQuery.isLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={2} className="h-32 text-center">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : currentOwners.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={2}
|
|
className="h-32 text-center text-muted-foreground"
|
|
>
|
|
<div className="flex flex-col items-center gap-2">
|
|
<Users className="h-8 w-8 opacity-20" />
|
|
<p>
|
|
{t(
|
|
"msg.admin.tenants.owners.empty",
|
|
"등록된 소유자가 없습니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
currentOwners.map((owner) => (
|
|
<TableRow
|
|
key={owner.id}
|
|
className="hover:bg-muted/30 transition-colors group cursor-pointer"
|
|
onClick={() => navigate(`/users/${owner.id}`)}
|
|
>
|
|
<TableCell className="font-medium">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
|
|
{owner.name.charAt(0)}
|
|
</div>
|
|
<span>{owner.name}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground italic">
|
|
{owner.email}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Admins Card */}
|
|
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
|
|
<div className="space-y-1">
|
|
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
|
<ShieldCheck className="h-6 w-6 text-primary" />
|
|
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
|
|
</CardTitle>
|
|
<CardDescription className="text-muted-foreground">
|
|
{t(
|
|
"msg.admin.tenants.admins.subtitle",
|
|
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
|
|
)}
|
|
</CardDescription>
|
|
</div>
|
|
<Button
|
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
|
onClick={() => setDialogMode("admin")}
|
|
>
|
|
<UserPlus className="mr-2 h-4 w-4" />
|
|
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
|
<Table>
|
|
<TableHeader className={commonStickyTableHeaderClass}>
|
|
<TableRow>
|
|
<TableHead className="w-[250px] font-bold">
|
|
{t("ui.admin.tenants.admins.table_name", "이름")}
|
|
</TableHead>
|
|
<TableHead className="font-bold">
|
|
{t("ui.admin.tenants.admins.table_email", "이메일")}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{adminsQuery.isLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={2} className="h-32 text-center">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : currentAdmins.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={2}
|
|
className="h-32 text-center text-muted-foreground"
|
|
>
|
|
<div className="flex flex-col items-center gap-2">
|
|
<Users className="h-8 w-8 opacity-20" />
|
|
<p>
|
|
{t(
|
|
"msg.admin.tenants.admins.empty",
|
|
"등록된 관리자가 없습니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
currentAdmins.map((admin) => (
|
|
<TableRow
|
|
key={admin.id}
|
|
className="hover:bg-muted/30 transition-colors group cursor-pointer"
|
|
onClick={() => navigate(`/users/${admin.id}`)}
|
|
>
|
|
<TableCell className="font-medium">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
|
|
{admin.name.charAt(0)}
|
|
</div>
|
|
<span>{admin.name}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground italic">
|
|
{admin.email}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Common Dialog for adding users */}
|
|
<Dialog
|
|
open={isDialogOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDialogMode(null);
|
|
setSearchTerm("");
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-xl font-bold">
|
|
{dialogTitle}
|
|
</DialogTitle>
|
|
<DialogDescription>{dialogDescription}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="py-4 space-y-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={t(
|
|
"ui.admin.tenants.admins.dialog_search_placeholder",
|
|
"사용자 검색 (최소 2자)...",
|
|
)}
|
|
className="pl-10 h-11"
|
|
autoFocus
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
|
|
{searchTerm.length < 2 ? (
|
|
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
|
|
<Search className="h-8 w-8 opacity-20" />
|
|
<p className="text-sm">
|
|
{t(
|
|
"ui.admin.tenants.admins.dialog_search_hint",
|
|
"검색어를 입력해 주세요.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
) : usersQuery.isLoading ? (
|
|
<div className="p-10 text-center">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
|
</div>
|
|
) : searchResults.length === 0 ? (
|
|
<div className="p-10 text-center text-muted-foreground">
|
|
{t(
|
|
"ui.admin.tenants.admins.dialog_no_results",
|
|
"검색 결과가 없습니다.",
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{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 (
|
|
<div
|
|
key={user.id}
|
|
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
|
{user.name.charAt(0)}
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-medium">
|
|
{user.name}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{user.email}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant={isAlreadyMember ? "ghost" : "outline"}
|
|
disabled={
|
|
isAlreadyMember ||
|
|
addOwnerMutation.isPending ||
|
|
addAdminMutation.isPending
|
|
}
|
|
onClick={() => handleAddUser(user.id)}
|
|
>
|
|
{isAlreadyMember ? (
|
|
<Badge variant="secondary" className="font-normal">
|
|
{dialogMode === "owner"
|
|
? t(
|
|
"ui.admin.tenants.owners.already_owner",
|
|
"이미 소유자",
|
|
)
|
|
: t(
|
|
"ui.admin.tenants.admins.already_admin",
|
|
"이미 관리자",
|
|
)}
|
|
</Badge>
|
|
) : (
|
|
<>
|
|
<Plus className="h-3 w-3 mr-1" />{" "}
|
|
{t("ui.common.add", "추가")}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default TenantAdminsAndOwnersTab;
|