forked from baron/baron-sso
프론트엔드 UI/UX를 전면 개편
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
|
||||
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,
|
||||
@@ -10,6 +13,14 @@ import {
|
||||
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,
|
||||
@@ -25,40 +36,50 @@ import {
|
||||
fetchUsers,
|
||||
removeTenantAdmin,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantAdminsTab() {
|
||||
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: ["users", { limit: 100, search: searchTerm }],
|
||||
queryFn: () => fetchUsers(100, 0, searchTerm),
|
||||
enabled: searchTerm.length > 1,
|
||||
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: () => {
|
||||
adminsQuery.refetch();
|
||||
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: () => {
|
||||
adminsQuery.refetch();
|
||||
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", "오류가 발생했습니다."));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,144 +88,176 @@ function TenantAdminsTab() {
|
||||
};
|
||||
|
||||
const handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
|
||||
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="grid gap-6 lg:grid-cols-2 mt-6">
|
||||
{/* 현재 테넌트 관리자 */}
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldCheck size={18} className="text-primary" />
|
||||
테넌트 관리자
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
이 테넌트의 자원을 관리할 수 있는 권한을 가진 사용자들입니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableHead className="text-right">회수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminsQuery.data?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
등록된 관리자가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{adminsQuery.data?.map((admin) => (
|
||||
<TableRow key={admin.id}>
|
||||
<TableCell className="font-medium">
|
||||
{admin.name || "Unknown"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{admin.email}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
|
||||
disabled={removeMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사용자 검색 및 추가 */}
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UserPlus size={18} className="text-primary" />
|
||||
관리자 추가
|
||||
<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>
|
||||
</div>
|
||||
<CardDescription>
|
||||
관리자로 추가할 사용자를 검색하세요 (이름 또는 이메일).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="사용자 검색 (최소 2자)..."
|
||||
className="pl-10"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t("msg.admin.tenants.admins.subtitle", "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>사용자</TableHead>
|
||||
<TableHead className="text-right">추가</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{searchTerm.length < 2 && (
|
||||
<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>
|
||||
</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>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
사용자 이름을 입력하여 검색하세요.
|
||||
</TableCell>
|
||||
<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>
|
||||
)}
|
||||
{searchTerm.length >= 2 &&
|
||||
usersQuery.data?.items.length === 0 && (
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminsQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
검색 결과가 없습니다.
|
||||
<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"></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{usersQuery.data?.items
|
||||
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
|
||||
.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{user.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{user.email}
|
||||
) : 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>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAddAdmin(user.id)}
|
||||
disabled={addMutation.isPending}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user