1
0
forked from baron/baron-sso

feat: 테넌트 그룹(Tenant Group) 기능 구현 #239

This commit is contained in:
2026-02-11 10:19:47 +09:00
parent 655a32fd97
commit 1548e60361
14 changed files with 659 additions and 21 deletions

View File

@@ -6,6 +6,7 @@ import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthPage from "../features/auth/AuthPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
@@ -30,6 +31,7 @@ export const router = createBrowserRouter(
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "tenant-groups", element: <TenantGroupListPage /> },
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,

View File

@@ -1,4 +1,5 @@
import {
<<<<<<< HEAD
BadgeCheck,
Building2,
Key,
@@ -9,6 +10,19 @@ import {
ShieldHalf,
Sun,
Users,
=======
BadgeCheck,
Building2,
Key,
KeyRound,
LayoutDashboard,
LayoutGrid,
Moon,
NotebookTabs,
ShieldHalf,
Sun,
Users,
>>>>>>> d7d2e16 (feat: 테넌트 (Tenant Group) #239)
} from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
@@ -16,6 +30,7 @@ import { t } from "../../lib/i18n";
import RoleSwitcher from "./RoleSwitcher";
const navItems = [
<<<<<<< HEAD
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
{
label: "ui.admin.nav.tenant_dashboard",
@@ -27,6 +42,16 @@ const navItems = [
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
=======
{ label: "Overview", to: "/", icon: LayoutDashboard },
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
{ label: "Tenant Groups", to: "/tenant-groups", icon: LayoutGrid },
{ label: "Tenants", to: "/tenants", icon: Building2 },
{ label: "Users", to: "/users", icon: Users },
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
>>>>>>> d7d2e16 (feat: 테넌트 (Tenant Group) #239)
];
function AppLayout() {

View File

@@ -0,0 +1,172 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Pencil, Plus, RefreshCw, Trash2, LayoutGrid } 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;

View File

@@ -139,6 +139,7 @@ export async function approveTenant(tenantId: string) {
return data;
}
<<<<<<< HEAD
// Group Management
export type GroupMember = {
id: string;
@@ -164,21 +165,66 @@ export type GroupCreateRequest = {
export async function fetchGroups(tenantId: string) {
const { data } = await apiClient.get<GroupSummary[]>(
`/v1/admin/tenants/${tenantId}/groups`,
=======
// Tenant Group Management
export type TenantGroupSummary = {
id: string;
name: string;
slug: string;
description: string;
tenants?: TenantSummary[];
config?: Record<string, any>;
createdAt: string;
updatedAt: string;
};
export type TenantGroupListResponse = {
items: TenantGroupSummary[];
total: number;
limit: number;
offset: number;
};
export async function fetchTenantGroups(limit = 50, offset = 0) {
const { data } = await apiClient.get<TenantGroupListResponse>(
"/v1/admin/tenant-groups",
{
params: { limit, offset },
},
>>>>>>> d7d2e16 (feat: 테넌트 (Tenant Group) #239)
);
return data;
}
<<<<<<< HEAD
export async function createGroup(
tenantId: string,
payload: GroupCreateRequest,
) {
const { data } = await apiClient.post<GroupSummary>(
`/v1/admin/tenants/${tenantId}/groups`,
=======
export async function fetchTenantGroup(id: string) {
const { data } = await apiClient.get<TenantGroupSummary>(
`/v1/admin/tenant-groups/${id}`,
);
return data;
}
export async function createTenantGroup(payload: {
name: string;
slug: string;
description?: string;
}) {
const { data } = await apiClient.post<TenantGroupSummary>(
"/v1/admin/tenant-groups",
>>>>>>> d7d2e16 (feat: 테넌트 (Tenant Group) #239)
payload,
);
return data;
}
<<<<<<< HEAD
export async function deleteGroup(groupId: string) {
await apiClient.delete(`/v1/admin/groups/${groupId}`);
}
@@ -189,6 +235,31 @@ export async function addGroupMember(groupId: string, userId: string) {
export async function removeGroupMember(groupId: string, userId: string) {
await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`);
=======
export async function updateTenantGroup(
id: string,
payload: { name: string; description?: string },
) {
const { data } = await apiClient.put<TenantGroupSummary>(
`/v1/admin/tenant-groups/${id}`,
payload,
);
return data;
}
export async function deleteTenantGroup(id: string) {
await apiClient.delete(`/v1/admin/tenant-groups/${id}`);
}
export async function addTenantToGroup(groupId: string, tenantId: string) {
await apiClient.post(`/v1/admin/tenant-groups/${groupId}/tenants/${tenantId}`);
}
export async function removeTenantFromGroup(groupId: string, tenantId: string) {
await apiClient.delete(
`/v1/admin/tenant-groups/${groupId}/tenants/${tenantId}`,
);
>>>>>>> d7d2e16 (feat: 테넌트 (Tenant Group) #239)
}
// API Key Management (M2M)