forked from baron/baron-sso
349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import {
|
|
Plus,
|
|
Search,
|
|
ShieldCheck,
|
|
Trash2,
|
|
UserPlus,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
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,
|
|
DialogTrigger,
|
|
} from "../../../components/ui/dialog";
|
|
import { Input } from "../../../components/ui/input";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "../../../components/ui/table";
|
|
import {
|
|
addTenantAdmin,
|
|
fetchTenantAdmins,
|
|
fetchUsers,
|
|
removeTenantAdmin,
|
|
} from "../../../lib/adminApi";
|
|
import { t } from "../../../lib/i18n";
|
|
|
|
export function TenantAdminsTab() {
|
|
const { tenantId } = useParams<{ tenantId: string }>();
|
|
const queryClient = useQueryClient();
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [isDialogOpen, setIsAddDialogOpen] = useState(false);
|
|
|
|
if (!tenantId) return null;
|
|
|
|
// 현재 관리자 목록 조회
|
|
const adminsQuery = useQuery({
|
|
queryKey: ["tenant-admins", tenantId],
|
|
queryFn: () => fetchTenantAdmins(tenantId),
|
|
enabled: !!tenantId,
|
|
});
|
|
|
|
// 사용자 검색 조회 (2자 이상 입력 시)
|
|
const usersQuery = useQuery({
|
|
queryKey: ["admin-users-search", searchTerm],
|
|
queryFn: () => fetchUsers(20, 0, searchTerm),
|
|
enabled: isDialogOpen && searchTerm.length >= 2,
|
|
});
|
|
|
|
const addMutation = useMutation({
|
|
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
|
|
toast.success(
|
|
t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."),
|
|
);
|
|
setSearchTerm("");
|
|
},
|
|
onError: (err: AxiosError<{ error?: string }>) => {
|
|
toast.error(
|
|
err.response?.data?.error ||
|
|
t("msg.common.error", "오류가 발생했습니다."),
|
|
);
|
|
},
|
|
});
|
|
|
|
const removeMutation = useMutation({
|
|
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
|
|
toast.success(
|
|
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
|
|
);
|
|
},
|
|
onError: (err: AxiosError<{ error?: string }>) => {
|
|
toast.error(
|
|
err.response?.data?.error ||
|
|
t("msg.common.error", "오류가 발생했습니다."),
|
|
);
|
|
},
|
|
});
|
|
|
|
const handleAddAdmin = (userId: string) => {
|
|
addMutation.mutate(userId);
|
|
};
|
|
|
|
const handleRemoveAdmin = (userId: string, userName: string) => {
|
|
if (
|
|
window.confirm(
|
|
t(
|
|
"msg.admin.tenants.admins.remove_confirm",
|
|
"관리자를 삭제하시겠습니까?",
|
|
{ name: userName },
|
|
),
|
|
)
|
|
) {
|
|
removeMutation.mutate(userId);
|
|
}
|
|
};
|
|
|
|
const currentAdmins = adminsQuery.data || [];
|
|
const searchResults = usersQuery.data?.items || [];
|
|
|
|
return (
|
|
<div className="space-y-6 mt-6">
|
|
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
|
|
<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>
|
|
|
|
<Dialog
|
|
open={isDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setIsAddDialogOpen(open);
|
|
if (!open) setSearchTerm("");
|
|
}}
|
|
>
|
|
<DialogTrigger asChild>
|
|
<Button className="bg-primary text-primary-foreground hover:bg-primary/90">
|
|
<UserPlus className="mr-2 h-4 w-4" />
|
|
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-xl font-bold">
|
|
{t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t(
|
|
"ui.admin.tenants.admins.dialog_description",
|
|
"이름 또는 이메일로 사용자를 검색하세요.",
|
|
)}
|
|
</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 isAlreadyAdmin = currentAdmins.some(
|
|
(a) => a.id === user.id,
|
|
);
|
|
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={isAlreadyAdmin ? "ghost" : "outline"}
|
|
disabled={isAlreadyAdmin || addMutation.isPending}
|
|
onClick={() => handleAddAdmin(user.id)}
|
|
>
|
|
{isAlreadyAdmin ? (
|
|
<Badge
|
|
variant="secondary"
|
|
className="font-normal"
|
|
>
|
|
{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>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
<div className="rounded-xl border border-border overflow-hidden">
|
|
<Table>
|
|
<TableHeader className="bg-muted/30">
|
|
<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>
|
|
<TableHead className="text-right font-bold w-[100px]">
|
|
{t("ui.admin.tenants.admins.table_actions", "액션")}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{adminsQuery.isLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={3} 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={3}
|
|
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"
|
|
>
|
|
<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>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
|
|
onClick={() =>
|
|
handleRemoveAdmin(admin.id, admin.name)
|
|
}
|
|
disabled={removeMutation.isPending}
|
|
title={t(
|
|
"ui.admin.tenants.admins.remove_title",
|
|
"관리자 권한 회수",
|
|
)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default TenantAdminsTab;
|