1
0
forked from baron/baron-sso

feat: 테넌트/RP 관리자 할당 UI 및 ReBAC 권한 검증 도구 구현 #244

This commit is contained in:
2026-02-11 13:26:26 +09:00
parent 8856485265
commit 68df43f3a8
24 changed files with 1547 additions and 48 deletions

View File

@@ -0,0 +1,198 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import {
fetchTenantAdmins,
addTenantAdmin,
removeTenantAdmin,
fetchUsers
} from "../../../lib/adminApi";
function TenantAdminsTab() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
if (!tenantId) return null;
// 현재 관리자 목록
const adminsQuery = useQuery({
queryKey: ["tenant-admins", tenantId],
queryFn: () => fetchTenantAdmins(tenantId),
enabled: !!tenantId,
});
// 전체 사용자 목록 (관리자 추가용)
const usersQuery = useQuery({
queryKey: ["users", { limit: 100, search: searchTerm }],
queryFn: () => fetchUsers(100, 0, searchTerm),
enabled: searchTerm.length > 1,
});
const addMutation = useMutation({
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
onSuccess: () => {
adminsQuery.refetch();
setSearchTerm("");
},
});
const removeMutation = useMutation({
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
onSuccess: () => {
adminsQuery.refetch();
},
});
const handleAddAdmin = (userId: string) => {
addMutation.mutate(userId);
};
const handleRemoveAdmin = (userId: string, userName: string) => {
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
removeMutation.mutate(userId);
}
};
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" />
</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)}
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchTerm.length < 2 && (
<TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
.
</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}</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>
</CardContent>
</Card>
</div>
);
}
export default TenantAdminsTab;

View File

@@ -16,6 +16,7 @@ function TenantDetailPage() {
});
const isFederationTab = location.pathname.includes("/federation");
const isAdminTab = location.pathname.includes("/admins");
return (
<div className="space-y-8">
@@ -44,7 +45,7 @@ function TenantDetailPage() {
<Link
to={`/tenants/${tenantId}`}
className={`px-4 py-2 text-sm font-medium ${
!isFederationTab
!isFederationTab && !isAdminTab && !location.pathname.includes("/schema")
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
@@ -61,6 +62,16 @@ function TenantDetailPage() {
>
Federation
</Link>
<Link
to={`/tenants/${tenantId}/admins`}
className={`px-4 py-2 text-sm font-medium ${
isAdminTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Admins
</Link>
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-4 py-2 text-sm font-medium ${