forked from baron/baron-sso
feat: 구현: 유저 그룹 중심 권한 통합 및 미들웨어 정책 고도화
This commit is contained in:
@@ -1,215 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
type TenantGroupSummary,
|
||||
addGroupAdmin,
|
||||
fetchGroupAdmins,
|
||||
fetchUsers,
|
||||
removeGroupAdmin,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
function TenantGroupAdminsTab() {
|
||||
const { group } = useOutletContext<{
|
||||
group: TenantGroupSummary;
|
||||
}>();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 현재 관리자 목록
|
||||
const adminsQuery = useQuery({
|
||||
queryKey: ["tenant-group-admins", group.id],
|
||||
queryFn: () => fetchGroupAdmins(group.id),
|
||||
enabled: !!group.id,
|
||||
});
|
||||
|
||||
// 전체 사용자 목록 (관리자 추가용)
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["users", { limit: 100, search: searchTerm }],
|
||||
queryFn: () => fetchUsers(100, 0, searchTerm),
|
||||
enabled: searchTerm.length > 1, // 2글자 이상 입력 시 검색
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (userId: string) => addGroupAdmin(group.id, userId),
|
||||
onSuccess: () => {
|
||||
adminsQuery.refetch();
|
||||
setSearchTerm("");
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) => removeGroupAdmin(group.id, 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">
|
||||
{/* 현재 그룹 관리자 */}
|
||||
<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 TenantGroupAdminsTab;
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { LayoutGrid, Sparkles } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { createTenantGroup } from "../../../lib/adminApi";
|
||||
|
||||
function TenantGroupCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createTenantGroup({
|
||||
name,
|
||||
slug: slug || name.toLowerCase().replace(/ /g, "-"),
|
||||
description: description || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
navigate("/tenant-groups");
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>Tenants</span>
|
||||
<span>/</span>
|
||||
<span>Groups</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Create</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold">테넌트 그룹 추가</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
여러 테넌트를 논리적으로 묶어 관리하기 위한 그룹을 생성합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="muted">Super Admin only</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LayoutGrid size={18} className="text-primary" />
|
||||
Group Profile
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
그룹 이름과 식별자(Slug)를 입력합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
Group Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="예: 바론소프트웨어 통합그룹"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Slug</Label>
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="baron-group"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL이나 API에서 사용될 고유 식별자입니다. 비워두면 이름 기반으로
|
||||
자동 생성됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Description</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="그룹에 대한 설명을 입력하세요."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles size={18} />
|
||||
권한 상속 안내
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
테넌트 그룹의 관리자는 소속된 모든 테넌트에 대한 관리 권한을
|
||||
자동으로 가집니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-[var(--color-muted)]">
|
||||
생성 후 상세 페이지에서 테넌트를 이 그룹에 할당할 수 있습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => navigate("/tenant-groups")}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || name.trim() === ""}
|
||||
>
|
||||
생성
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupCreatePage;
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, LayoutGrid } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { fetchTenantGroup } from "../../../lib/adminApi";
|
||||
|
||||
function TenantGroupDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
|
||||
const groupQuery = useQuery({
|
||||
queryKey: ["tenant-group", id],
|
||||
queryFn: () => fetchTenantGroup(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const isTenantsTab = location.pathname.endsWith("/tenants");
|
||||
const isAdminTab = location.pathname.endsWith("/admins");
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<Link
|
||||
to="/tenant-groups"
|
||||
className="inline-flex items-center gap-2 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Groups
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Detail</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<LayoutGrid size={24} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{groupQuery.data?.name ?? "Loading Group..."}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{groupQuery.data?.description ||
|
||||
"그룹 정보를 관리하고 소속 테넌트를 구성합니다."}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="muted">Super Admin only</Badge>
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border">
|
||||
<Link
|
||||
to={`/tenant-groups/${id}`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
!isTenantsTab && !isAdminTab
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
기본 정보
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenant-groups/${id}/tenants`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
isTenantsTab
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
소속 테넌트 ({groupQuery.data?.tenants?.length ?? 0})
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenant-groups/${id}/admins`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
isAdminTab
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
관리자
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Outlet
|
||||
context={{ group: groupQuery.data, refetch: groupQuery.refetch }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupDetailPage;
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { LayoutGrid, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
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 { deleteTenantGroup, fetchTenantGroups } from "../../../lib/adminApi";
|
||||
|
||||
function TenantGroupListPage() {
|
||||
const navigate = useNavigate();
|
||||
const query = useQuery({
|
||||
queryKey: ["tenant-groups", { limit: 50, offset: 0 }],
|
||||
queryFn: () => fetchTenantGroups(50, 0),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (groupId: string) => deleteTenantGroup(groupId),
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
!errorMsg && query.isError ? "테넌트 그룹 목록 조회에 실패했습니다." : null;
|
||||
|
||||
const items = query.data?.items ?? [];
|
||||
|
||||
const handleDelete = (groupId: string, groupName: string) => {
|
||||
if (!window.confirm(`테넌트 그룹 "${groupName}"를 삭제할까요?`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(groupId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>Tenants</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Groups</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">테넌트 그룹 목록</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
여러 테넌트를 하나의 그룹으로 묶어 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/tenant-groups/new">
|
||||
<Plus size={16} />
|
||||
그룹 추가
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LayoutGrid size={20} className="text-primary" />
|
||||
Tenant Group Registry
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
총 {query.data?.total ?? 0}개 그룹
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">Super Admin only</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMsg ?? fallbackError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>NAME</TableHead>
|
||||
<TableHead>SLUG</TableHead>
|
||||
<TableHead>TENANTS</TableHead>
|
||||
<TableHead>CREATED</TableHead>
|
||||
<TableHead className="text-right">ACTIONS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>로딩 중...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
아직 등록된 테넌트 그룹이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-semibold">{group.name}</TableCell>
|
||||
<TableCell>{group.slug}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{group.tenants?.length ?? 0}개
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{group.createdAt
|
||||
? new Date(group.createdAt).toLocaleDateString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenant-groups/${group.id}`)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
관리
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(group.id, group.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupListPage;
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import {
|
||||
type TenantGroupSummary,
|
||||
updateTenantGroup,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
function TenantGroupProfileTab() {
|
||||
const { group, refetch } = useOutletContext<{
|
||||
group: TenantGroupSummary;
|
||||
refetch: () => void;
|
||||
}>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [name, setName] = useState(group?.name ?? "");
|
||||
const [description, setDescription] = useState(group?.description ?? "");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateTenantGroup(group.id, { name, description }),
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-groups"] });
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
|
||||
if (!group) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle>그룹 정보 수정</CardTitle>
|
||||
<CardDescription>
|
||||
그룹의 기본 이름과 설명을 변경할 수 있습니다. 식별자(Slug)는 변경할
|
||||
수 없습니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>그룹 ID (고유 식별자)</Label>
|
||||
<Input value={group.id} disabled className="bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug</Label>
|
||||
<Input value={group.slug} disabled className="bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="groupName">Group Name</Label>
|
||||
<Input
|
||||
id="groupName"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="groupDesc">Description</Label>
|
||||
<Textarea
|
||||
id="groupDesc"
|
||||
rows={4}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={
|
||||
mutation.isPending ||
|
||||
(name === group.name && description === group.description)
|
||||
}
|
||||
>
|
||||
{mutation.isPending ? "저장 중..." : "변경사항 저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupProfileTab;
|
||||
@@ -1,210 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
type TenantGroupSummary,
|
||||
addTenantToGroup,
|
||||
fetchTenants,
|
||||
removeTenantFromGroup,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
function TenantGroupTenantsTab() {
|
||||
const { group, refetch } = useOutletContext<{
|
||||
group: TenantGroupSummary;
|
||||
refetch: () => void;
|
||||
}>();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 전체 테넌트 목록 (할당용)
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (tenantId: string) => addTenantToGroup(group.id, tenantId),
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-group", group.id] });
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (tenantId: string) => removeTenantFromGroup(group.id, tenantId),
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-group", group.id] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddTenant = (tenantId: string) => {
|
||||
addMutation.mutate(tenantId);
|
||||
};
|
||||
|
||||
const handleRemoveTenant = (tenantId: string) => {
|
||||
if (window.confirm("이 테넌트를 그룹에서 제외할까요?")) {
|
||||
removeMutation.mutate(tenantId);
|
||||
}
|
||||
};
|
||||
|
||||
const availableTenants =
|
||||
tenantsQuery.data?.items.filter(
|
||||
(t) => !group.tenants?.some((gt) => gt.id === t.id),
|
||||
) || [];
|
||||
|
||||
const filteredAvailable = availableTenants.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
t.slug.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* 현재 소속 테넌트 */}
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 size={18} className="text-primary" />
|
||||
소속 테넌트
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
현재 이 그룹에 포함된 테넌트 목록입니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead className="text-right">제외</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.tenants?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
소속된 테넌트가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{group.tenants?.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell className="text-xs">{t.slug}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveTenant(t.id)}
|
||||
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">
|
||||
<Plus size={18} className="text-primary" />
|
||||
테넌트 추가
|
||||
</CardTitle>
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
className="pl-8 h-9"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
다른 그룹에 속하지 않았거나 이동이 필요한 테넌트를 선택하세요.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">추가</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAvailable.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
추가할 수 있는 테넌트가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filteredAvailable.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{t.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t.slug}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAddTenant(t.id)}
|
||||
disabled={addMutation.isPending}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupTenantsTab;
|
||||
@@ -17,6 +17,7 @@ function TenantDetailPage() {
|
||||
|
||||
const isFederationTab = location.pathname.includes("/federation");
|
||||
const isAdminTab = location.pathname.includes("/admins");
|
||||
const isUserGroupsTab = location.pathname.includes("/user-groups");
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -74,6 +75,16 @@ function TenantDetailPage() {
|
||||
>
|
||||
Admins
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/user-groups`}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
isUserGroupsTab
|
||||
? "border-b-2 border-blue-500 text-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
User Groups
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/schema`}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Building2, Plus, Users } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
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 { fetchTenants, fetchGroups } from "../../../lib/adminApi";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function GlobalUserGroupListPage() {
|
||||
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
|
||||
queryKey: ["admin-tenants"],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
});
|
||||
|
||||
if (isTenantsLoading) return <div className="p-8">Loading tenants and groups...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
|
||||
<p className="text-muted-foreground">
|
||||
모든 테넌트의 유저 그룹을 관리합니다. 권한 상속의 주체가 되는 그룹을 설정하세요.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{tenantList?.items.map((tenant) => (
|
||||
<TenantGroupCard key={tenant.id} tenant={tenant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantGroupCard({ tenant }: { tenant: any }) {
|
||||
const { data: groups, isLoading } = useQuery({
|
||||
queryKey: ["tenant-user-groups", tenant.id],
|
||||
queryFn: () => fetchGroups(tenant.id),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Building2 size={20} className="text-muted-foreground" />
|
||||
{tenant.name}
|
||||
<Badge variant="outline" className="ml-2">{tenant.slug}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
이 테넌트에 정의된 유저 그룹 목록입니다.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to={`/tenants/${tenant.id}/user-groups`}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
그룹 관리
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">그룹명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="w-[100px]">멤버 수</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center">Loading...</TableCell>
|
||||
</TableRow>
|
||||
) : groups?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground py-4">
|
||||
등록된 유저 그룹이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groups?.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={14} className="text-primary" />
|
||||
<Link to={`/tenants/${tenant.id}/user-groups/${group.id}`} className="hover:underline">
|
||||
{group.name}
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{group.description || "-"}</TableCell>
|
||||
<TableCell>{group.members?.length || 0} 명</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/tenants/${tenant.id}/user-groups/${group.id}`}>상세보기</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Trash2, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { createGroup, deleteGroup, fetchGroups } from "../../../lib/adminApi";
|
||||
|
||||
export function TenantUserGroupsTab() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupDesc, setNewGroupDesc] = useState("");
|
||||
|
||||
const { data: groups, isLoading } = useQuery({
|
||||
queryKey: ["tenant-user-groups", tenantId],
|
||||
queryFn: () => fetchGroups(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createGroup(tenantId!, { name: newGroupName, description: newGroupDesc }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tenant-user-groups", tenantId],
|
||||
});
|
||||
setIsCreateOpen(false);
|
||||
setNewGroupName("");
|
||||
setNewGroupDesc("");
|
||||
alert("User group created successfully");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(error.message || "Failed to create user group");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (groupId: string) => deleteGroup(tenantId!, groupId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tenant-user-groups", tenantId],
|
||||
});
|
||||
alert("User group deleted successfully");
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading user groups...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>User Groups</CardTitle>
|
||||
<CardDescription>
|
||||
Manage user groups within this tenant for collective permission
|
||||
assignment.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Group
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create User Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new group to manage users collectively.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Group Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g. Developers, Project A Managers"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
placeholder="Brief description of the group"
|
||||
value={newGroupDesc}
|
||||
onChange={(e) => setNewGroupDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!newGroupName || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? "Creating..." : "Create Group"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
No user groups found for this tenant.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groups?.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={16} className="text-muted-foreground" />
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/user-groups/${group.id}`}
|
||||
className="hover:underline text-primary"
|
||||
>
|
||||
{group.name}
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{group.description || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{group.createdAt
|
||||
? new Date(group.createdAt).toLocaleDateString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to delete this group?",
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(group.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Plus, Shield, Trash2, UserPlus, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
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,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../../components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
addGroupMember,
|
||||
assignGroupRole,
|
||||
fetchGroup,
|
||||
fetchGroupRoles,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
removeGroupMember,
|
||||
removeGroupRole,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
export function UserGroupDetailPage() {
|
||||
const { tenantId, id } = useParams<{ tenantId: string; id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState("");
|
||||
const [searchUser, setSearchUser] = useState("");
|
||||
|
||||
const [isAddRoleOpen, setIsAddRoleOpen] = useState(false);
|
||||
const [selectedTargetTenantId, setSelectedTargetTenantId] = useState("");
|
||||
const [selectedRelation, setSelectedRelation] = useState("view");
|
||||
|
||||
// Fetch specific group details
|
||||
const { data: currentGroup, isLoading: isGroupLoading, error } = useQuery({
|
||||
queryKey: ["user-group-detail", id],
|
||||
queryFn: () => fetchGroup(tenantId!, id!),
|
||||
enabled: !!id && !!tenantId,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Fetch assigned roles
|
||||
const { data: groupRoles, isLoading: isRolesLoading } = useQuery({
|
||||
queryKey: ["user-group-roles", id],
|
||||
queryFn: () => fetchGroupRoles(tenantId!, id!),
|
||||
enabled: !!id && !!tenantId,
|
||||
});
|
||||
|
||||
// Fetch all users for selection
|
||||
const { data: userList } = useQuery({
|
||||
queryKey: ["admin-users", searchUser],
|
||||
queryFn: () => fetchUsers(20, 0, searchUser),
|
||||
enabled: isAddMemberOpen,
|
||||
});
|
||||
|
||||
// Fetch all tenants for role assignment
|
||||
const { data: tenantList } = useQuery({
|
||||
queryKey: ["admin-tenants"],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
enabled: isAddRoleOpen,
|
||||
});
|
||||
|
||||
const addMemberMutation = useMutation({
|
||||
mutationFn: (userId: string) => addGroupMember(tenantId!, id!, userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
|
||||
setIsAddMemberOpen(false);
|
||||
setSelectedUserId("");
|
||||
alert("Member added successfully");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(error.message || "Failed to add member");
|
||||
},
|
||||
});
|
||||
|
||||
const removeMemberMutation = useMutation({
|
||||
mutationFn: (userId: string) => removeGroupMember(tenantId!, id!, userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
|
||||
alert("Member removed successfully");
|
||||
},
|
||||
});
|
||||
|
||||
const assignRoleMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
assignGroupRole(
|
||||
tenantId!,
|
||||
id!,
|
||||
selectedTargetTenantId,
|
||||
selectedRelation,
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
|
||||
setIsAddRoleOpen(false);
|
||||
alert(`Role '${selectedRelation}' assigned successfully`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(error.message || "Failed to assign role");
|
||||
},
|
||||
});
|
||||
|
||||
const removeRoleMutation = useMutation({
|
||||
mutationFn: (role: { targetTenantId: string; relation: string }) =>
|
||||
removeGroupRole(tenantId!, id!, role.targetTenantId, role.relation),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
|
||||
alert("Role removed successfully");
|
||||
},
|
||||
});
|
||||
|
||||
if (isGroupLoading) return (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<span className="ml-3 text-muted-foreground">Loading group details...</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error || !currentGroup) return (
|
||||
<div className="p-8 text-center space-y-4">
|
||||
<h3 className="text-xl font-semibold text-destructive">Could not load group</h3>
|
||||
<div className="p-4 bg-red-50 text-red-700 rounded-md text-left text-sm font-mono overflow-auto max-w-xl mx-auto border border-red-100">
|
||||
<p>Error: {(error as any)?.response?.data?.error || (error as any)?.message || "Not found"}</p>
|
||||
<p className="mt-2 text-red-500 opacity-70">Path: /admin/tenants/{tenantId}/user-groups/{id}</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground pt-2">The group ID might be invalid or you don't have sufficient permissions.</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>Retry</Button>
|
||||
<div className="pt-4 border-t">
|
||||
<Link to={`/tenants/${tenantId}/user-groups`} className="text-primary hover:underline text-sm">
|
||||
Return to Group List
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<Link
|
||||
to={`/tenants/${tenantId}`}
|
||||
className="inline-flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Tenant Detail
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">User Group</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Users size={24} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">{currentGroup.name}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{currentGroup.description || "No description provided."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline">User Group</Badge>
|
||||
<Badge variant="muted">Tenant: {tenantId?.split('-')[0]}...</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Members Management */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Members</CardTitle>
|
||||
<CardDescription>Manage users in this group.</CardDescription>
|
||||
</div>
|
||||
<Dialog open={isAddMemberOpen} onOpenChange={setIsAddMemberOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<UserPlus size={16} className="mr-2" />
|
||||
Add Member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a user to add to this group.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Search User</Label>
|
||||
<Input
|
||||
placeholder="Search by email or name..."
|
||||
value={searchUser}
|
||||
onChange={(e) => setSearchUser(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Select User</Label>
|
||||
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userList?.items.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddMemberOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => addMemberMutation.mutate(selectedUserId)}
|
||||
disabled={!selectedUserId || addMemberMutation.isPending}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!currentGroup.members || currentGroup.members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 text-muted-foreground">
|
||||
No members in this group.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentGroup.members.map((member) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{member.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{member.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
onClick={() => removeMemberMutation.mutate(member.id)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Roles/Permissions Management (Keto Based) */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Permissions</CardTitle>
|
||||
<CardDescription>Tenant roles assigned to this group.</CardDescription>
|
||||
</div>
|
||||
<Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Shield size={16} className="mr-2" />
|
||||
Assign Role
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Tenant Role</DialogTitle>
|
||||
<DialogDescription>
|
||||
Members of this group will inherit this role on the target tenant.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Target Tenant</Label>
|
||||
<Select value={selectedTargetTenantId} onValueChange={setSelectedTargetTenantId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select target tenant" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenantList?.items.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name} ({t.slug})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Role (Relation)</Label>
|
||||
<Select value={selectedRelation} onValueChange={setSelectedRelation}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="view">View (Read-only)</SelectItem>
|
||||
<SelectItem value="manage">Manage (Read/Write)</SelectItem>
|
||||
<SelectItem value="admins">Admin (Full Control)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddRoleOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => assignRoleMutation.mutate()}
|
||||
disabled={!selectedTargetTenantId || assignRoleMutation.isPending}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Target Tenant</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isRolesLoading ? (
|
||||
<TableRow><TableCell colSpan={3} className="text-center">Loading...</TableCell></TableRow>
|
||||
) : !groupRoles || groupRoles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-4 text-muted-foreground">
|
||||
No roles assigned.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupRoles.map((role, idx) => (
|
||||
<TableRow key={`${role.tenantId}-${role.relation}-${idx}`}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{role.tenantName || role.tenantId}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">{role.relation}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
onClick={() => removeRoleMutation.mutate({ targetTenantId: role.tenantId, relation: role.relation })}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user