forked from baron/baron-sso
feat: 테넌트 그룹 관리 UI 및 상세 멤버십 관리 기능 구현 #239
This commit is contained in:
@@ -6,7 +6,11 @@ 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 TenantGroupCreatePage from "../features/tenant-groups/routes/TenantGroupCreatePage";
|
||||
import TenantGroupDetailPage from "../features/tenant-groups/routes/TenantGroupDetailPage";
|
||||
import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage";
|
||||
import TenantGroupProfileTab from "../features/tenant-groups/routes/TenantGroupProfileTab";
|
||||
import TenantGroupTenantsTab from "../features/tenant-groups/routes/TenantGroupTenantsTab";
|
||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||
@@ -32,6 +36,15 @@ export const router = createBrowserRouter(
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{ path: "tenant-groups", element: <TenantGroupListPage /> },
|
||||
{ path: "tenant-groups/new", element: <TenantGroupCreatePage /> },
|
||||
{
|
||||
path: "tenant-groups/:id",
|
||||
element: <TenantGroupDetailPage />,
|
||||
children: [
|
||||
{ index: true, element: <TenantGroupProfileTab /> },
|
||||
{ path: "tenants", element: <TenantGroupTenantsTab /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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;
|
||||
@@ -0,0 +1,77 @@
|
||||
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");
|
||||
|
||||
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
|
||||
? "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>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Outlet context={{ group: groupQuery.data, refetch: groupQuery.refetch }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupDetailPage;
|
||||
@@ -0,0 +1,97 @@
|
||||
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 { updateTenantGroup, type TenantGroupSummary } 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;
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Trash2, Building2, Search } 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import {
|
||||
addTenantToGroup,
|
||||
removeTenantFromGroup,
|
||||
fetchTenants,
|
||||
type TenantGroupSummary
|
||||
} 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;
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
approveTenant,
|
||||
deleteTenant,
|
||||
fetchTenant,
|
||||
fetchTenantGroups,
|
||||
updateTenant,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
@@ -35,11 +36,17 @@ export function TenantProfilePage() {
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
});
|
||||
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ["tenant-groups", { limit: 100 }],
|
||||
queryFn: () => fetchTenantGroups(100, 0),
|
||||
});
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState("");
|
||||
const [tenantGroupId, setTenantGroupId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantQuery.data) {
|
||||
@@ -48,6 +55,7 @@ export function TenantProfilePage() {
|
||||
setDescription(tenantQuery.data.description ?? "");
|
||||
setStatus(tenantQuery.data.status);
|
||||
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
||||
setTenantGroupId(tenantQuery.data.tenantGroupId ?? "");
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
@@ -58,6 +66,7 @@ export function TenantProfilePage() {
|
||||
slug,
|
||||
description: description || undefined,
|
||||
status,
|
||||
tenantGroupId: tenantGroupId || undefined,
|
||||
domains: domains
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
@@ -136,6 +145,24 @@ export function TenantProfilePage() {
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Tenant Group</Label>
|
||||
<select
|
||||
value={tenantGroupId}
|
||||
onChange={(e) => setTenantGroupId(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">그룹 없음</option>
|
||||
{groupsQuery.data?.items.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name} ({group.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
테넌트가 속할 그룹을 지정합니다. 그룹 관리자는 소속 테넌트에 대한 접근 권한을 가집니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
Allowed Domains (Comma separated)
|
||||
|
||||
@@ -27,6 +27,7 @@ export type TenantSummary = {
|
||||
status: string;
|
||||
domains?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
tenantGroupId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -38,6 +39,7 @@ export type TenantCreateRequest = {
|
||||
status?: string;
|
||||
domains?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
tenantGroupId?: string;
|
||||
};
|
||||
|
||||
export type TenantListResponse = {
|
||||
@@ -54,6 +56,7 @@ export type TenantUpdateRequest = {
|
||||
status?: string;
|
||||
domains?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
tenantGroupId?: string;
|
||||
};
|
||||
|
||||
export type ApiKeySummary = {
|
||||
@@ -139,8 +142,7 @@ export async function approveTenant(tenantId: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Group Management
|
||||
// User Group Management (Within a Tenant)
|
||||
export type GroupMember = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -165,8 +167,34 @@ export type GroupCreateRequest = {
|
||||
export async function fetchGroups(tenantId: string) {
|
||||
const { data } = await apiClient.get<GroupSummary[]>(
|
||||
`/v1/admin/tenants/${tenantId}/groups`,
|
||||
=======
|
||||
// Tenant Group Management
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createGroup(
|
||||
tenantId: string,
|
||||
payload: GroupCreateRequest,
|
||||
) {
|
||||
const { data } = await apiClient.post<GroupSummary>(
|
||||
`/v1/admin/tenants/${tenantId}/groups`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteGroup(groupId: string) {
|
||||
await apiClient.delete(`/v1/admin/groups/${groupId}`);
|
||||
}
|
||||
|
||||
export async function addGroupMember(groupId: string, userId: string) {
|
||||
await apiClient.post(`/v1/admin/groups/${groupId}/members`, { userId });
|
||||
}
|
||||
|
||||
export async function removeGroupMember(groupId: string, userId: string) {
|
||||
await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`);
|
||||
}
|
||||
|
||||
// Tenant Group Management (Global Grouping of Tenants)
|
||||
export type TenantGroupSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -191,19 +219,10 @@ export async function fetchTenantGroups(limit = 50, offset = 0) {
|
||||
{
|
||||
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}`,
|
||||
@@ -218,24 +237,11 @@ export async function createTenantGroup(payload: {
|
||||
}) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
export async function addGroupMember(groupId: string, userId: string) {
|
||||
await apiClient.post(`/v1/admin/groups/${groupId}/members`, { userId });
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -259,7 +265,6 @@ 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)
|
||||
@@ -441,4 +446,4 @@ export async function updateRelyingParty(id: string, payload: HydraClientReq) {
|
||||
|
||||
export async function deleteRelyingParty(id: string) {
|
||||
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func main() {
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
|
||||
adminHandler := handler.NewAdminHandler()
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService)
|
||||
tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
|
||||
kratosAdminService := service.NewKratosAdminService()
|
||||
|
||||
@@ -14,22 +14,24 @@ import (
|
||||
type TenantHandler struct {
|
||||
DB *gorm.DB
|
||||
Service service.TenantService
|
||||
Keto service.KetoService
|
||||
}
|
||||
|
||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService) *TenantHandler {
|
||||
return &TenantHandler{DB: db, Service: svc}
|
||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService) *TenantHandler {
|
||||
return &TenantHandler{DB: db, Service: svc, Keto: keto}
|
||||
}
|
||||
|
||||
type tenantSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Config domain.JSONMap `json:"config,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
TenantGroupID *string `json:"tenantGroupId,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Config domain.JSONMap `json:"config,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type tenantListResponse struct {
|
||||
@@ -123,7 +125,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var tenant domain.Tenant
|
||||
if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil {
|
||||
if err := h.DB.Preload("Domains").Preload("TenantGroup").First(&tenant, "id = ?", tenantID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"})
|
||||
}
|
||||
@@ -204,12 +206,13 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Slug *string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
Domains []string `json:"domains"`
|
||||
Config map[string]any `json:"config"`
|
||||
Name *string `json:"name"`
|
||||
Slug *string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
TenantGroupID *string `json:"tenantGroupId"`
|
||||
Domains []string `json:"domains"`
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
@@ -251,6 +254,29 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
tenant.Config = req.Config
|
||||
}
|
||||
|
||||
// Handle Group Change
|
||||
if req.TenantGroupID != nil {
|
||||
oldGroupID := tenant.TenantGroupID
|
||||
newGroupID := req.TenantGroupID
|
||||
if *newGroupID == "" {
|
||||
newGroupID = nil
|
||||
}
|
||||
|
||||
// Update Keto if group changed
|
||||
if h.Keto != nil {
|
||||
// Remove old group relation if existed
|
||||
if oldGroupID != nil && (newGroupID == nil || *oldGroupID != *newGroupID) {
|
||||
_ = h.Keto.DeleteRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *oldGroupID)
|
||||
}
|
||||
// Add new group relation
|
||||
if newGroupID != nil && (oldGroupID == nil || *oldGroupID != *newGroupID) {
|
||||
_ = h.Keto.CreateRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *newGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
tenant.TenantGroupID = newGroupID
|
||||
}
|
||||
|
||||
if err := h.DB.Save(&tenant).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
@@ -308,15 +334,16 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
|
||||
}
|
||||
|
||||
return tenantSummary{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Slug: t.Slug,
|
||||
Description: t.Description,
|
||||
Status: t.Status,
|
||||
Domains: domains,
|
||||
Config: t.Config,
|
||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Slug: t.Slug,
|
||||
Description: t.Description,
|
||||
Status: t.Status,
|
||||
TenantGroupID: t.TenantGroupID,
|
||||
Domains: domains,
|
||||
Config: t.Config,
|
||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user