forked from baron/baron-sso
Merge branch 'feature/user-group' into dev
This commit is contained in:
957
adminfront/package-lock.json
generated
957
adminfront/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.1.2",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@tanstack/react-query": "^5.66.8",
|
||||
@@ -28,6 +30,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
|
||||
@@ -8,18 +8,15 @@ import AuthPage from "../features/auth/AuthPage";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import TenantGroupAdminsTab from "../features/tenant-groups/routes/TenantGroupAdminsTab";
|
||||
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 TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab";
|
||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||
import GlobalUserGroupListPage from "../features/user-groups/routes/GlobalUserGroupListPage";
|
||||
import { TenantUserGroupsTab } from "../features/user-groups/routes/TenantUserGroupsTab";
|
||||
import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage";
|
||||
import UserCreatePage from "../features/users/UserCreatePage";
|
||||
import UserDetailPage from "../features/users/UserDetailPage";
|
||||
import UserListPage from "../features/users/UserListPage";
|
||||
@@ -45,28 +42,23 @@ export const router = createBrowserRouter(
|
||||
{ path: "users", element: <UserListPage /> },
|
||||
{ path: "users/new", element: <UserCreatePage /> },
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "user-groups", element: <GlobalUserGroupListPage /> },
|
||||
{ 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: "admins", element: <TenantGroupAdminsTab /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
children: [
|
||||
{ index: true, element: <TenantProfilePage /> },
|
||||
{ path: "admins", element: <TenantAdminsTab /> },
|
||||
{ path: "user-groups", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId/user-groups/:id",
|
||||
element: <UserGroupDetailPage />,
|
||||
},
|
||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||
],
|
||||
|
||||
@@ -23,7 +23,16 @@ const navItems = [
|
||||
to: "/dashboard",
|
||||
icon: ShieldHalf,
|
||||
},
|
||||
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
|
||||
{
|
||||
label: "ui.admin.nav.tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
label: "ui.admin.nav.user_groups",
|
||||
to: "/user-groups",
|
||||
icon: Users,
|
||||
},
|
||||
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
||||
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
||||
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
||||
|
||||
120
adminfront/src/components/ui/dialog.tsx
Normal file
120
adminfront/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
158
adminfront/src/components/ui/select.tsx
Normal file
158
adminfront/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=top]:slide-in-from-bottom-2 data-[state=bottom]:slide-in-from-top-2",
|
||||
position === "popper" &&
|
||||
"data-[state=open]:slide-in-from-top-2 data-[state=bottom]:translate-y-1 data-[state=left]:-translate-x-1 data-[state=right]:translate-x-1 data-[state=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-content-available-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -163,7 +163,14 @@ export type GroupCreateRequest = {
|
||||
|
||||
export async function fetchGroups(tenantId: string) {
|
||||
const { data } = await apiClient.get<GroupSummary[]>(
|
||||
`/v1/admin/tenants/${tenantId}/groups`,
|
||||
`/v1/admin/tenants/${tenantId}/user-groups`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchGroup(tenantId: string, groupId: string) {
|
||||
const { data } = await apiClient.get<GroupSummary>(
|
||||
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -173,22 +180,71 @@ export async function createGroup(
|
||||
payload: GroupCreateRequest,
|
||||
) {
|
||||
const { data } = await apiClient.post<GroupSummary>(
|
||||
`/v1/admin/tenants/${tenantId}/groups`,
|
||||
`/v1/admin/tenants/${tenantId}/user-groups`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteGroup(groupId: string) {
|
||||
await apiClient.delete(`/v1/admin/groups/${groupId}`);
|
||||
export async function deleteGroup(tenantId: string, groupId: string) {
|
||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}/user-groups/${groupId}`);
|
||||
}
|
||||
|
||||
export async function addGroupMember(groupId: string, userId: string) {
|
||||
await apiClient.post(`/v1/admin/groups/${groupId}/members`, { userId });
|
||||
export async function addGroupMember(
|
||||
tenantId: string,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
) {
|
||||
await apiClient.post(
|
||||
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/members`,
|
||||
{ userId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeGroupMember(groupId: string, userId: string) {
|
||||
await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`);
|
||||
export async function removeGroupMember(
|
||||
tenantId: string,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
) {
|
||||
await apiClient.delete(
|
||||
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/members/${userId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export type GroupRole = {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
relation: string;
|
||||
};
|
||||
|
||||
export async function fetchGroupRoles(tenantId: string, groupId: string) {
|
||||
const { data } = await apiClient.get<GroupRole[]>(
|
||||
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/roles`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function assignGroupRole(
|
||||
tenantId: string,
|
||||
groupId: string,
|
||||
targetTenantId: string,
|
||||
relation: string,
|
||||
) {
|
||||
await apiClient.post(
|
||||
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/roles`,
|
||||
{ tenantId: targetTenantId, relation },
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeGroupRole(
|
||||
tenantId: string,
|
||||
groupId: string,
|
||||
targetTenantId: string,
|
||||
relation: string,
|
||||
) {
|
||||
await apiClient.delete(
|
||||
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/roles/${targetTenantId}/${relation}`,
|
||||
);
|
||||
}
|
||||
|
||||
// API Key Management (M2M)
|
||||
|
||||
@@ -244,10 +244,15 @@ func main() {
|
||||
|
||||
// 2. Initialize Handlers
|
||||
tenantRepo := repository.NewTenantRepository(db)
|
||||
tenantService := service.NewTenantService(tenantRepo)
|
||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
// relyingPartyRepo removed as SSOT is now Hydra+Keto
|
||||
kratosAdminService := service.NewKratosAdminService()
|
||||
oryAdminProvider := service.NewOryProvider()
|
||||
|
||||
tenantService := service.NewTenantService(tenantRepo)
|
||||
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, kratosAdminService)
|
||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||
|
||||
hydraService := service.NewHydraAdminService()
|
||||
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
|
||||
secretRepo := repository.NewClientSecretRepository(db)
|
||||
@@ -255,12 +260,11 @@ func main() {
|
||||
|
||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||
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)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
|
||||
kratosAdminService := service.NewKratosAdminService()
|
||||
oryAdminProvider := service.NewOryProvider()
|
||||
adminHandler := handler.NewAdminHandler(ketoService)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
|
||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
|
||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||
|
||||
@@ -535,6 +539,22 @@ func main() {
|
||||
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
|
||||
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
|
||||
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
|
||||
admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
|
||||
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
|
||||
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
|
||||
|
||||
// User Group Management (Tenant Admin/Super Admin)
|
||||
userGroups := admin.Group("/tenants/:tenantId/user-groups", requireAdmin)
|
||||
userGroups.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
|
||||
userGroups.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
|
||||
userGroups.Get("/:id", userGroupHandler.Get) // 권한 체크 일시 제거
|
||||
userGroups.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update)
|
||||
userGroups.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete)
|
||||
userGroups.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember)
|
||||
userGroups.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember)
|
||||
userGroups.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles)
|
||||
userGroups.Post("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AssignRole)
|
||||
userGroups.Delete("/:id/roles/:tenantId/:relation", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveRole)
|
||||
|
||||
// Relying Party Management (Global List)
|
||||
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
||||
|
||||
@@ -34,6 +34,7 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.Tenant{},
|
||||
&domain.TenantDomain{},
|
||||
&domain.User{},
|
||||
&domain.UserGroup{},
|
||||
&domain.ApiKey{},
|
||||
&domain.IdentityProviderConfig{},
|
||||
&domain.ClientSecret{},
|
||||
|
||||
@@ -21,6 +21,12 @@ type UserGroup struct {
|
||||
Members []User `gorm:"-" json:"members,omitempty"`
|
||||
}
|
||||
|
||||
type GroupRole struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantName string `json:"tenantName"`
|
||||
Relation string `json:"relation"`
|
||||
}
|
||||
|
||||
func (ug *UserGroup) TableName() string {
|
||||
return "user_groups"
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func (h *UserGroupHandler) Get(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
group, err := h.Service.Get(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "group not found"})
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to get group: " + err.Error()})
|
||||
}
|
||||
return c.JSON(group)
|
||||
}
|
||||
@@ -93,3 +93,39 @@ func (h *UserGroupHandler) RemoveMember(c *fiber.Ctx) error {
|
||||
}
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *UserGroupHandler) AssignRole(c *fiber.Ctx) error {
|
||||
groupID := c.Params("id")
|
||||
var req struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
Relation string `json:"relation"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
|
||||
}
|
||||
|
||||
if err := h.Service.AssignRoleToTenant(c.Context(), groupID, req.TenantID, req.Relation); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
||||
func (h *UserGroupHandler) ListRoles(c *fiber.Ctx) error {
|
||||
groupID := c.Params("id")
|
||||
roles, err := h.Service.ListRoles(c.Context(), groupID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(roles)
|
||||
}
|
||||
|
||||
func (h *UserGroupHandler) RemoveRole(c *fiber.Ctx) error {
|
||||
groupID := c.Params("id")
|
||||
tenantID := c.Params("tenantId")
|
||||
relation := c.Params("relation")
|
||||
|
||||
if err := h.Service.RemoveRoleFromTenant(c.Context(), groupID, tenantID, relation); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -37,20 +37,38 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
||||
}
|
||||
|
||||
// Get object ID from path (e.g., tenant ID)
|
||||
objectID := c.Params("id")
|
||||
if objectID == "" {
|
||||
// Fix: For Tenant namespace, prioritize tenantId param if available
|
||||
objectID := ""
|
||||
if namespace == "Tenant" {
|
||||
objectID = c.Params("tenantId")
|
||||
}
|
||||
|
||||
if objectID == "" {
|
||||
objectID = c.Params("id")
|
||||
}
|
||||
|
||||
if objectID == "" {
|
||||
slog.Error("RBAC Keto check failed: missing object id", "path", c.Path())
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
|
||||
}
|
||||
|
||||
slog.Info("Performing Keto permission check", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
|
||||
|
||||
// Set tenant_id for audit logging if namespace is Tenant
|
||||
if namespace == "Tenant" {
|
||||
c.Locals("tenant_id", objectID)
|
||||
}
|
||||
|
||||
// Check with Keto
|
||||
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
||||
if err != nil || !allowed {
|
||||
if err != nil {
|
||||
slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied"})
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied for " + namespace + ":" + objectID})
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
@@ -136,7 +154,26 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
|
||||
targetTenantID = c.Params("id") // common for /tenants/:id
|
||||
}
|
||||
|
||||
if profile.TenantID == nil || *profile.TenantID != targetTenantID {
|
||||
if targetTenantID == "" {
|
||||
return c.Next() // No target specified, let Keto or next handler decide
|
||||
}
|
||||
|
||||
// Check primary tenant match
|
||||
if profile.TenantID != nil && *profile.TenantID == targetTenantID {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Check inherited manageable tenants
|
||||
isAllowed := false
|
||||
for _, t := range profile.ManageableTenants {
|
||||
if t.ID == targetTenantID {
|
||||
isAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isAllowed {
|
||||
slog.Warn("Tenant match failed", "userID", profile.ID, "targetTenantID", targetTenantID)
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "forbidden: you do not have access to this tenant",
|
||||
})
|
||||
|
||||
@@ -37,7 +37,8 @@ func (r *userGroupRepository) Delete(ctx context.Context, id string) error {
|
||||
|
||||
func (r *userGroupRepository) FindByID(ctx context.Context, id string) (*domain.UserGroup, error) {
|
||||
var group domain.UserGroup
|
||||
if err := r.db.WithContext(ctx).First(&group, "id = ?", id).Error; err != nil {
|
||||
// Using Where to be more explicit and avoid issues with GORM's default primary key handling if ID is string/uuid
|
||||
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&group).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &group, nil
|
||||
|
||||
@@ -169,7 +169,7 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
|
||||
}
|
||||
|
||||
func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.writeURL))
|
||||
u, _ := url.Parse(fmt.Sprintf("%s/admin/relation-tuples", s.writeURL))
|
||||
q := u.Query()
|
||||
q.Set("namespace", namespace)
|
||||
q.Set("object", object)
|
||||
|
||||
@@ -39,6 +39,74 @@ func (s *tenantService) GetTenant(ctx context.Context, id string) (*domain.Tenan
|
||||
return s.repo.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *tenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
if s.keto == nil {
|
||||
return nil, errors.New("keto service not initialized")
|
||||
}
|
||||
|
||||
// 1. 직접 관리자인 테넌트 ID 목록 (Tenant:ID#admins@User:ID)
|
||||
directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to list direct tenants", "userID", userID, "error", err)
|
||||
}
|
||||
|
||||
// 2. 관리 권한이 있는 유저 그룹 목록 (UserGroup:ID#owners@User:ID)
|
||||
// 정책: 그룹장은 해당 그룹(테넌트)의 어드민이 된다.
|
||||
ownedGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "owners", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to list owned groups", "userID", userID, "error", err)
|
||||
}
|
||||
|
||||
// 3. 멤버로 속한 유저 그룹 목록 (UserGroup:ID#members@User:ID)
|
||||
memberGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "members", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to list group memberships", "userID", userID, "error", err)
|
||||
}
|
||||
|
||||
// 4. 유저 그룹을 통해 상속받은 테넌트 목록 조회 (Tenant:ID#manage@UserGroup:ID#members)
|
||||
var inheritedTenantIDs []string
|
||||
allMyGroups := append(ownedGroupIDs, memberGroupIDs...)
|
||||
for _, groupID := range allMyGroups {
|
||||
// 해당 그룹에 부여된 테넌트 관리 권한 역추적
|
||||
relations, err := s.keto.ListRelations(ctx, "Tenant", "", "manage", "UserGroup:"+groupID+"#members")
|
||||
if err == nil {
|
||||
for _, r := range relations {
|
||||
inheritedTenantIDs = append(inheritedTenantIDs, r.Object)
|
||||
}
|
||||
}
|
||||
// view 권한도 관리 가능 목록에 포함 (필요 시)
|
||||
relationsView, err := s.keto.ListRelations(ctx, "Tenant", "", "view", "UserGroup:"+groupID+"#members")
|
||||
if err == nil {
|
||||
for _, r := range relationsView {
|
||||
inheritedTenantIDs = append(inheritedTenantIDs, r.Object)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 합산 및 중복 제거
|
||||
allIDsMap := make(map[string]bool)
|
||||
for _, id := range directTenantIDs {
|
||||
allIDsMap[id] = true
|
||||
}
|
||||
for _, id := range ownedGroupIDs {
|
||||
allIDsMap[id] = true // 그룹 자체도 테넌트이므로 포함
|
||||
}
|
||||
for _, id := range inheritedTenantIDs {
|
||||
allIDsMap[id] = true
|
||||
}
|
||||
|
||||
allIDs := make([]string, 0, len(allIDsMap))
|
||||
for id := range allIDsMap {
|
||||
allIDs = append(allIDs, id)
|
||||
}
|
||||
|
||||
if len(allIDs) == 0 {
|
||||
return []domain.Tenant{}, nil
|
||||
}
|
||||
|
||||
return s.repo.FindByIDs(ctx, allIDs)
|
||||
}
|
||||
|
||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
|
||||
// Validate Slug
|
||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||
|
||||
@@ -17,19 +17,34 @@ type UserGroupService interface {
|
||||
// Member Management with Keto Sync
|
||||
AddMember(ctx context.Context, groupID, userID string) error
|
||||
RemoveMember(ctx context.Context, groupID, userID string) error
|
||||
|
||||
// Permission Management
|
||||
ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error)
|
||||
AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error
|
||||
RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error
|
||||
}
|
||||
|
||||
type userGroupService struct {
|
||||
repo repository.UserGroupRepository
|
||||
userRepo repository.UserRepository
|
||||
tenantRepo repository.TenantRepository
|
||||
ketoService KetoService
|
||||
kratos *KratosAdminService
|
||||
}
|
||||
|
||||
func NewUserGroupService(repo repository.UserGroupRepository, userRepo repository.UserRepository, keto KetoService) UserGroupService {
|
||||
func NewUserGroupService(
|
||||
repo repository.UserGroupRepository,
|
||||
userRepo repository.UserRepository,
|
||||
tenantRepo repository.TenantRepository,
|
||||
keto KetoService,
|
||||
kratos *KratosAdminService,
|
||||
) UserGroupService {
|
||||
return &userGroupService{
|
||||
repo: repo,
|
||||
userRepo: userRepo,
|
||||
tenantRepo: tenantRepo,
|
||||
ketoService: keto,
|
||||
kratos: kratos,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,36 +81,75 @@ func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGrou
|
||||
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", group.ID, "members", "")
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID)
|
||||
// Return group without members rather than failing?
|
||||
// But if we fail here, we might hide partial failure. Let's log and proceed or return error?
|
||||
// For now, let's proceed with empty members to avoid blocking UI if keto is down?
|
||||
// No, SSOT is Keto. If Keto is down, we can't show members.
|
||||
// Returning error might be safer.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var userIDs []string
|
||||
for _, t := range tuples {
|
||||
// SubjectID is like "User:uuid"
|
||||
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
|
||||
userIDs = append(userIDs, t.SubjectID[5:])
|
||||
sid := t.SubjectID
|
||||
if len(sid) > 5 && sid[:5] == "User:" {
|
||||
userIDs = append(userIDs, sid[5:])
|
||||
} else {
|
||||
userIDs = append(userIDs, sid)
|
||||
}
|
||||
}
|
||||
|
||||
if len(userIDs) > 0 {
|
||||
// 1. Try to find in local DB
|
||||
members, err := s.userRepo.FindByIDs(ctx, userIDs)
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch member details from db", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
group.Members = members
|
||||
|
||||
// 2. Map existing DB members
|
||||
memberMap := make(map[string]domain.User)
|
||||
for _, m := range members {
|
||||
memberMap[m.ID] = m
|
||||
}
|
||||
|
||||
// 3. For IDs not in DB, fetch from Kratos
|
||||
var finalMembers []domain.User
|
||||
for _, uid := range userIDs {
|
||||
if m, ok := memberMap[uid]; ok {
|
||||
finalMembers = append(finalMembers, m)
|
||||
} else if s.kratos != nil {
|
||||
// Fallback to Kratos
|
||||
identity, err := s.kratos.GetIdentity(ctx, uid)
|
||||
if err == nil && identity != nil {
|
||||
name, _ := identity.Traits["name"].(string)
|
||||
email, _ := identity.Traits["email"].(string)
|
||||
finalMembers = append(finalMembers, domain.User{
|
||||
ID: uid,
|
||||
Name: name,
|
||||
Email: email,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
group.Members = finalMembers
|
||||
} else {
|
||||
group.Members = []domain.User{}
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
|
||||
return s.repo.ListByTenantID(ctx, tenantID)
|
||||
groups, err := s.repo.ListByTenantID(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For each group, fetch member count from Keto
|
||||
for i := range groups {
|
||||
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", groups[i].ID, "members", "")
|
||||
if err == nil {
|
||||
// Create dummy members just to carry the count for the JSON response
|
||||
groups[i].Members = make([]domain.User, len(tuples))
|
||||
}
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error {
|
||||
@@ -119,3 +173,63 @@ func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID str
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) {
|
||||
// Query: namespace=Tenant, subject=UserGroup:groupID#members
|
||||
subject := "UserGroup:" + groupID + "#members"
|
||||
tuples, err := s.ketoService.ListRelations(ctx, "Tenant", "", "", subject)
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch group roles from keto", "error", err, "group_id", groupID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var roles []domain.GroupRole
|
||||
tenantIDs := make([]string, 0, len(tuples))
|
||||
for _, t := range tuples {
|
||||
tenantIDs = append(tenantIDs, t.Object)
|
||||
}
|
||||
|
||||
if len(tenantIDs) > 0 {
|
||||
tenantList, err := s.tenantRepo.FindByIDs(ctx, tenantIDs)
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch tenant details for roles", "error", err)
|
||||
}
|
||||
|
||||
tenantMap := make(map[string]string)
|
||||
for _, t := range tenantList {
|
||||
tenantMap[t.ID] = t.Name
|
||||
}
|
||||
|
||||
for _, t := range tuples {
|
||||
roles = append(roles, domain.GroupRole{
|
||||
TenantID: t.Object,
|
||||
TenantName: tenantMap[t.Object],
|
||||
Relation: t.Relation,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (s *userGroupService) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error {
|
||||
// Keto: Tenant:<tenantID>#<relation>@UserGroup:<groupID>#members
|
||||
// This means all members of the group have the relation on the tenant.
|
||||
subject := "UserGroup:" + groupID + "#members"
|
||||
err := s.ketoService.CreateRelation(ctx, "Tenant", tenantID, relation, subject)
|
||||
if err != nil {
|
||||
slog.Error("Failed to assign group role to tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *userGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error {
|
||||
subject := "UserGroup:" + groupID + "#members"
|
||||
err := s.ketoService.DeleteRelation(ctx, "Tenant", tenantID, relation, subject)
|
||||
if err != nil {
|
||||
slog.Error("Failed to remove group role from tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ class UserGroup implements Namespace {
|
||||
|
||||
class Tenant implements Namespace {
|
||||
related: {
|
||||
admins: User[]
|
||||
members: User[]
|
||||
admins: (User | SubjectSet<UserGroup, "members">)[]
|
||||
members: (User | SubjectSet<UserGroup, "members">)[]
|
||||
parent: Tenant[]
|
||||
parent_group: TenantGroup[]
|
||||
}
|
||||
|
||||
143
docs/keto-rebac-policy-guide.md
Normal file
143
docs/keto-rebac-policy-guide.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Ory Keto ReBAC 정책 및 관계 튜플 가이드
|
||||
|
||||
이 문서는 Baron SSO의 통합 권한 정책을 Ory Keto(Zanzibar 스타일 권한 엔진)에서 구현하기 위한 네임스페이스 설계와 관계 튜플(Relationship Tuples) 예제를 정의합니다.
|
||||
|
||||
## 0. 권한 흐름 다이어그램 (Permission Flow)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
%% Subjects
|
||||
U[User: 사용자]
|
||||
|
||||
%% Intermediate Groups
|
||||
subgraph Groups [유저 그룹 / 테넌트 관리 주체]
|
||||
UG_O[UserGroup: Owners / 그룹장]
|
||||
UG_M[UserGroup: Members / 멤버]
|
||||
end
|
||||
|
||||
%% Resources
|
||||
subgraph Resources [테넌트 및 하위 자원]
|
||||
T[Tenant: 부모 테넌트]
|
||||
CT[Tenant: 자식 테넌트]
|
||||
RP[RelyingParty: 앱/클라이언트]
|
||||
end
|
||||
|
||||
%% Relations (Solid = Direct, Dash = Inherited)
|
||||
U -- "owner of" --> UG_O
|
||||
U -- "member of" --> UG_M
|
||||
|
||||
UG_O -- "becomes Admin of" --> T
|
||||
UG_M -- "gets View/Manage of" --> T
|
||||
|
||||
T -- "controls" --> CT
|
||||
T -- "owns" --> RP
|
||||
|
||||
%% Effective Permissions (Dash)
|
||||
U -. "inherits Admin" .-> T
|
||||
U -. "inherits Access" .-> CT
|
||||
U -. "can manage" .-> RP
|
||||
|
||||
%% Styles
|
||||
style UG_O fill:#ff9,stroke:#333
|
||||
style T fill:#dfd,stroke:#333
|
||||
style RP fill:#bbf,stroke:#333
|
||||
```
|
||||
|
||||
## 1. 네임스페이스 정의 (Namespaces)
|
||||
|
||||
| 네임스페이스 | 역할 | 비고 |
|
||||
| :--- | :--- | :--- |
|
||||
| `Tenant` | 격리된 자원 공간 (Workspace) | 모든 유저 그룹은 테넌트의 한 종류임 |
|
||||
| `UserGroup` | 사용자의 집합 (Specialized Tenant) | `Tenant` 네임스페이스를 상속하거나 호환됨 |
|
||||
| `RelyingParty` | OAuth2 클라이언트 앱 | 특정 테넌트에 소속됨 |
|
||||
| `System` | 시스템 전역 권한 | Super Admin 등을 관리 |
|
||||
|
||||
## 2. 핵심 정책의 Keto 구현
|
||||
|
||||
### 2.1 "모든 유저 그룹은 테넌트이다"
|
||||
유저 그룹이 생성될 때, 해당 ID는 `Tenant` 네임스페이스에도 동시에 존재하며 동일한 상속 로직을 공유합니다.
|
||||
|
||||
### 2.2 "그룹장은 해당 테넌트의 어드민이다"
|
||||
그룹장(Leader/Owner) 관계가 형성되면, Keto의 **Subject Set** 기능을 통해 테넌트의 `admins` 권한으로 자동 전파됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Keto 관계 튜플 예제 (Relationship Tuples)
|
||||
|
||||
Keto에 저장되는 데이터 포맷 예시입니다: `namespace:object#relation@subject`
|
||||
|
||||
### 3.1 사용자-그룹 관계 (Identity to Group)
|
||||
| 설명 | Keto 튜플 예제 |
|
||||
| :--- | :--- |
|
||||
| **그룹 멤버 추가** | `UserGroup:dev-team#members@User:uuid-123` |
|
||||
| **그룹장 임명 (Leader)** | `UserGroup:dev-team#owners@User:uuid-leader` |
|
||||
|
||||
### 3.2 그룹-테넌트 권한 전파 (Group to Resource)
|
||||
| 설명 | Keto 튜플 예제 |
|
||||
| :--- | :--- |
|
||||
| **그룹장 -> 어드민 자동 상속** | `Tenant:dev-team#admins@UserGroup:dev-team#owners` |
|
||||
| **그룹 -> 하위 테넌트 관리 권한** | `Tenant:project-alpha#manage@UserGroup:dev-team#members` |
|
||||
| **그룹 -> 하위 테넌트 조회 권한** | `Tenant:project-beta#view@UserGroup:dev-team#members` |
|
||||
|
||||
### 3.3 테넌트-자원 소속 관계 (Hierarchy)
|
||||
| 설명 | Keto 튜플 예제 |
|
||||
| :--- | :--- |
|
||||
| **자식 테넌트 설정** | `Tenant:child-dept#parents@Tenant:parent-corp` |
|
||||
| **앱(RP) 소속 테넌트 지정** | `RelyingParty:auth-app#parents@Tenant:hanmac-family` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 권한 검증 로직 (Permission Check Logic)
|
||||
|
||||
시스템이 권한을 확인할 때(Check API) 사용하는 로직입니다.
|
||||
|
||||
### 4.1 그룹장의 테넌트 관리 권한 확인
|
||||
사용자 `uuid-leader`가 `dev-team` 테넌트를 관리할 수 있는지 확인:
|
||||
```bash
|
||||
# 요청 (Check)
|
||||
GET /relation-tuples/check?namespace=Tenant&object=dev-team&relation=manage&subject_id=uuid-leader
|
||||
|
||||
# Keto 내부 추론 경로
|
||||
1. Tenant:dev-team#manage 권한은 Tenant:dev-team#admins에게 있음.
|
||||
2. Tenant:dev-team#admins는 UserGroup:dev-team#owners를 포함함.
|
||||
3. UserGroup:dev-team#owners에 User:uuid-leader가 존재함.
|
||||
=> 결과: ALLOW (True)
|
||||
```
|
||||
|
||||
### 4.2 그룹 멤버의 하위 자원(RP) 접근 확인
|
||||
사용자 `uuid-123`이 `auth-app` 설정을 볼 수 있는지 확인:
|
||||
```bash
|
||||
# 요청 (Check)
|
||||
GET /relation-tuples/check?namespace=RelyingParty&object=auth-app&relation=view&subject_id=uuid-123
|
||||
|
||||
# Keto 내부 추론 경로
|
||||
1. RelyingParty:auth-app#view 권한은 부모인 Tenant:hanmac-family#view에 의존함.
|
||||
2. Tenant:hanmac-family#view 권한은 UserGroup:dev-team#members에게 부여됨.
|
||||
3. UserGroup:dev-team#members에 User:uuid-123이 존재함.
|
||||
=> 결과: ALLOW (True)
|
||||
```
|
||||
|
||||
## 5. 정책 요약 코드 (Namespace Config - DSL Style)
|
||||
|
||||
이 정책을 지원하기 위한 Keto 네임스페이스 설정 스키마 개념도입니다.
|
||||
|
||||
```typescript
|
||||
class Tenant implements Namespace {
|
||||
related: {
|
||||
admins: (User | UserGroup#owners)[]
|
||||
members: (User | UserGroup#members)[]
|
||||
parents: Tenant[]
|
||||
}
|
||||
|
||||
permits: {
|
||||
view: (ctx: Context): boolean =>
|
||||
this.related.members.includes(ctx.subject) ||
|
||||
this.related.admins.includes(ctx.subject) ||
|
||||
this.related.parents.traverse((p) => p.permits.view(ctx)),
|
||||
|
||||
manage: (ctx: Context): boolean =>
|
||||
this.related.admins.includes(ctx.subject) ||
|
||||
this.related.parents.traverse((p) => p.permits.manage(ctx))
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -18,9 +18,9 @@
|
||||
- Keto의 관계 튜플에 기반해 `CheckPermission`을 수행합니다.
|
||||
|
||||
### 2.3 RequireTenantMatch
|
||||
- 테넌트 관리자 권한을 가진 사용자가 **자신의 테넌트**에만 접근하도록 보장합니다.
|
||||
- Super Admin은 즉시 통과합니다.
|
||||
- API Key 인증은 우회합니다.
|
||||
- 사용자가 요청한 테넌트에 대한 관리 자격이 있는지 검증합니다.
|
||||
- **상속 권한 인정:** 사용자의 기본 테넌트뿐만 아니라, 유저 그룹 멤버십이나 그룹장 직책을 통해 **상속받은 모든 테넌트**를 대상으로 합니다.
|
||||
- Super Admin 및 유효한 API Key 요청은 통과합니다.
|
||||
|
||||
## 3. ReBAC 기반인데도 RBAC가 필요한 이유
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
- 불필요한 ReBAC 호출을 줄여 장애 전파를 줄입니다.
|
||||
|
||||
3) **테넌트 범위 제어의 명확성**
|
||||
- "Tenant Admin은 자기 테넌트만"은 자주 쓰는 규칙으로, 미들웨어 단에서 즉시 판단이 효율적입니다.
|
||||
- "Tenant Admin은 자기 테넌트만"은 자주 쓰는 규칙으로, 미들웨어 단에서 즉시 판단이 효율적입니다. 유저 그룹 도입 이후에는 "상속받은 모든 관리 대상 테넌트"로 범위가 확장됩니다.
|
||||
|
||||
4) **성능 및 안정성**
|
||||
- Keto는 외부 서비스 호출이므로 지연/실패 가능성이 있습니다.
|
||||
@@ -48,16 +48,19 @@
|
||||
|
||||
### 4.2 권한/정책 SoT
|
||||
- **1순위: Keto(ReBAC) 관계 튜플**
|
||||
- 리소스 접근 권한의 최종 판단 기준
|
||||
- 리소스 접근 권한의 최종 판단 기준.
|
||||
- **유저 그룹 상속:** 사용자가 속한 유저 그룹에 부여된 권한은 Keto를 통해 실시간으로 상속됩니다.
|
||||
- **그룹장-어드민 연동:** 유저 그룹의 장(Leader)은 해당 그룹(테넌트)의 어드민 권한을 자동으로 가집니다.
|
||||
- **2순위: RBAC(Role)**
|
||||
- 전역/상위 정책의 단축 규칙
|
||||
- ReBAC와 충돌 시, ReBAC 결과가 항상 우선
|
||||
- 전역/상위 정책의 단축 규칙.
|
||||
- ReBAC와 충돌 시, ReBAC 결과가 항상 우선.
|
||||
|
||||
### 4.3 테넌트 컨텍스트 SoT
|
||||
- **1순위: 서버 측 프로필(예: UserProfile.tenantId)**
|
||||
- **1순위: 서버 측 프로필 및 상속된 권한 (ManageableTenants)**
|
||||
- 사용자의 기본 `tenantId`뿐만 아니라, 유저 그룹을 통해 **상속받은 관리 가능 테넌트 목록** 전체를 기준으로 판단합니다.
|
||||
- **2순위: 요청 헤더(X-Tenant-ID)**
|
||||
- 헤더는 "요청 의도"를 나타내지만, 항상 서버 프로필과 일치해야 함
|
||||
- 불일치 시 차단
|
||||
- 헤더는 "요청 의도"를 나타내며, `ManageableTenants` 목록에 포함된 ID여야 합니다.
|
||||
- 불일치 시 차단.
|
||||
|
||||
### 4.4 OIDC/RP 정보 SoT
|
||||
- **1순위: Hydra Client/Consent 데이터**
|
||||
|
||||
68
docs/tenant-usergroup-policy.md
Normal file
68
docs/tenant-usergroup-policy.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 유저 그룹 및 테넌트 통합 권한 정책 (Integrated Policy)
|
||||
|
||||
이 문서는 Baron SSO의 테넌트(Tenant)와 유저 그룹(User Group) 간의 관계 및 권한 상속에 관한 공식 정책을 정의합니다.
|
||||
|
||||
## 1. 기본 원칙 (Core Axioms)
|
||||
|
||||
### 1.1 유저 그룹의 테넌트성 (User Group as a Tenant)
|
||||
- **모든 테넌트가 유저 그룹은 아니지만, 모든 유저 그룹은 반드시 테넌트의 속성을 가집니다.**
|
||||
- 유저 그룹은 "사용자들의 집합"인 동시에, 그 자체가 권한을 담고 다른 자원을 소유할 수 있는 **격리된 공간(Tenant)**으로 취급됩니다.
|
||||
|
||||
### 1.2 권한 상속 로직의 단일화 (Unified Inheritance)
|
||||
- 테넌트 간의 상속(Parent-Child Tenant)과 유저 그룹의 권한 전파(Group-Member)는 **기술적으로 동일한 ReBAC 로직**을 사용합니다.
|
||||
- `UserGroup:members` 관계는 `Tenant:members`와 동일한 우선순위를 가지며, 시스템은 이를 구분 없이 하나의 상속 트리로 처리합니다.
|
||||
|
||||
### 1.3 그룹장-어드민 연동 (Leader-Admin Mapping)
|
||||
- 특정 유저 그룹에 명시적으로 **'그룹장(Group Leader)'**을 지정하면, 시스템은 해당 사용자를 해당 유저 그룹(테넌트)의 **'테넌트 어드민(Tenant Admin)'**으로 자동 격상합니다.
|
||||
- 그룹장은 해당 그룹이 소유한 모든 하위 테넌트 및 리소스에 대해 완전한 제어권을 가집니다.
|
||||
|
||||
## 2. 권한 흐름도 (Mermaid)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
%% Roles
|
||||
Leader[Group Leader / 그룹장]
|
||||
Member[Group Member / 멤버]
|
||||
|
||||
%% Entities (Polymorphic)
|
||||
subgraph UG_T [User Group / Specialized Tenant]
|
||||
UG_ID[Group: Hanmac 운영팀]
|
||||
end
|
||||
|
||||
subgraph Child_T [Child Tenants / 하위 테넌트]
|
||||
T1[Tenant: 한맥 엔지니어링]
|
||||
T2[Tenant: 한맥 IT]
|
||||
end
|
||||
|
||||
%% Policy Links
|
||||
Leader -- "Explicitly Assigned" --> UG_ID
|
||||
Leader -. "Automatically Becomes" .-> Admin[Tenant Admin]
|
||||
|
||||
Member -- "is member of" --> UG_ID
|
||||
|
||||
%% Inheritance (Identical Logic)
|
||||
UG_ID -- "Inherits Access To" --> T1
|
||||
UG_ID -- "Inherits Access To" --> T2
|
||||
|
||||
%% Effective Access
|
||||
Admin -- "Full Control" --> UG_ID
|
||||
Member -- "Shared Access" --> T1
|
||||
Member -- "Shared Access" --> T2
|
||||
|
||||
%% Styles
|
||||
style UG_ID fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style Leader fill:#ff9,stroke:#333
|
||||
style Admin fill:#ffd,stroke:#333,stroke-dasharray: 5 5
|
||||
```
|
||||
|
||||
## 3. 기술적 구현 가이드 (Implementation)
|
||||
|
||||
### 3.1 Keto Relationship Tuples
|
||||
- **그룹장 임명:** `UserGroup:<ID>#owners@User:<UserID>`
|
||||
- **어드민 자동 승격:** `Tenant:<ID>#admins@UserGroup:<ID>#owners` (그룹 소유자는 해당 테넌트의 어드민)
|
||||
- **멤버십:** `UserGroup:<ID>#members@User:<UserID>`
|
||||
|
||||
### 3.2 기대 효과
|
||||
- **정책 단순화:** '어드민'과 '그룹장'을 별도로 관리할 필요가 없어 시스템 복잡도가 감소합니다.
|
||||
- **책임 명확화:** 그룹의 장이 해당 자원의 최종 책임자가 되는 직관적인 거버넌스를 수립합니다.
|
||||
- **일관된 UX:** 사용자는 자신이 관리하는 것이 '테넌트'인지 '그룹'인지 고민할 필요 없이 동일한 관리 도구를 사용합니다.
|
||||
83
docs/user-group-rebac-architecture.md
Normal file
83
docs/user-group-rebac-architecture.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 유저 그룹 기반 ReBAC 권한 아키텍처 (User Group-based ReBAC)
|
||||
|
||||
이 문서는 Baron SSO의 이슈 #239를 통해 구현된 유저 그룹 중심의 권한 체계와 Ory Keto를 이용한 ReBAC(Relationship-Based Access Control) 설계 방식을 설명합니다.
|
||||
|
||||
## 1. 개요
|
||||
기존의 '테넌트 그룹(Tenant Group)' 방식에서 '유저 그룹(User Group)' 방식으로 전환하여, 권한 부여의 주체(Subject)를 그룹화하고 자원(Tenant)에 대한 권한을 상속받는 구조로 설계되었습니다.
|
||||
|
||||
## 2. 권한 상속 다이어그램
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
%% Entities
|
||||
subgraph Identity [사용자 계정]
|
||||
U1[User: A]
|
||||
U2[User: B]
|
||||
end
|
||||
|
||||
subgraph Subjects [권한 부여 주체]
|
||||
UG[User Group: 개발팀]
|
||||
end
|
||||
|
||||
subgraph Resources [보호 대상 자원]
|
||||
T1[Tenant: Project Alpha]
|
||||
T2[Tenant: Project Beta]
|
||||
RP[Relying Party: Auth App]
|
||||
end
|
||||
|
||||
%% Relationships
|
||||
U1 -- "is member of" --> UG
|
||||
U2 -- "is member of" --> UG
|
||||
|
||||
UG -- "assigned role: manage" --> T1
|
||||
UG -- "assigned role: view" --> T2
|
||||
|
||||
%% Inheritance Logic (Keto ReBAC)
|
||||
T1 -- "owns" --> RP
|
||||
|
||||
%% Direct Inheritance
|
||||
U1 -. "inherits: manage" .-> T1
|
||||
U1 -. "inherits: view" .-> T2
|
||||
U2 -. "inherits: manage" .-> T1
|
||||
|
||||
%% Recursive Permission
|
||||
T1 -. "allows access" .-> RP
|
||||
U1 -. "can manage" .-> RP
|
||||
|
||||
%% Styles
|
||||
style Identity fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style Subjects fill:#bbf,stroke:#333,stroke-width:2px
|
||||
style Resources fill:#dfd,stroke:#333,stroke-width:2px
|
||||
linkStyle 4,5,6,7,8,9 stroke:#ff944d,stroke-width:2px,stroke-dasharray: 5 5
|
||||
```
|
||||
|
||||
## 3. 기술적 관계 설계 (Ory Keto Tuples)
|
||||
|
||||
Ory Keto 내부적으로는 다음과 같은 관계 튜플(Relationship Tuples)을 통해 권한을 관리합니다.
|
||||
|
||||
### 3.1 그룹 멤버십 (Group Membership)
|
||||
사용자를 특정 유저 그룹의 멤버로 등록합니다.
|
||||
- **Tuple:** `UserGroup:<GroupID>#members@User:<UserID>`
|
||||
- **의미:** `UserID` 사용자는 `GroupID` 유저 그룹의 멤버이다.
|
||||
|
||||
### 3.2 테넌트 권한 할당 (Tenant Role Assignment)
|
||||
유저 그룹 전체에 특정 테넌트에 대한 역할을 부여합니다.
|
||||
- **Tuple:** `Tenant:<TenantID>#<Relation>@UserGroup:<GroupID>#members`
|
||||
- **의미:** `GroupID` 유저 그룹의 모든 멤버는 `TenantID` 테넌트에 대해 `<Relation>`(예: `view`, `manage`, `admins`) 권한을 가진다.
|
||||
|
||||
### 3.3 자원 소유 및 전파 (Resource Ownership)
|
||||
테넌트가 소유한 하위 자원(RP, API Key 등)에 대한 권한 전파 규칙입니다.
|
||||
- **Tuple:** `RelyingParty:<ClientID>#parents@Tenant:<TenantID>`
|
||||
- **검증 논리:** 사용자가 `ClientID`에 대한 `view` 권한을 요청하면, Keto는 해당 사용자가 부모인 `TenantID`에 대해 `view` 권한이 있는지 역추적하여 승인합니다.
|
||||
|
||||
## 4. 주요 장점
|
||||
|
||||
1. **중앙 집중식 관리:** 사용자의 부서 이동이나 퇴사 시, 개별 테넌트의 권한을 수정할 필요 없이 유저 그룹의 멤버십만 변경하면 모든 연관 권한이 즉시 회수/부여됩니다.
|
||||
2. **복합 권한 구성:** 하나의 그룹이 여러 테넌트에 대해 서로 다른 수준의 권한을 가질 수 있어, 실제 조직 구조와 프로젝트 협업 모델을 유연하게 반영할 수 있습니다.
|
||||
3. **Zanzibar 스타일 확장성:** Google Zanzibar 논리를 따르는 Ory Keto를 활용함으로써, 향후 수만 명의 사용자와 수천 개의 테넌트 환경에서도 성능 저하 없이 정교한 권한 체크가 가능합니다.
|
||||
|
||||
## 5. 관련 구현 파일
|
||||
- **Backend Service:** `backend/internal/service/user_group_service.go`
|
||||
- **Backend Handler:** `backend/internal/handler/user_group_handler.go`
|
||||
- **Frontend API:** `adminfront/src/lib/adminApi.ts`
|
||||
- **Frontend UI:** `adminfront/src/features/user-groups/`
|
||||
Reference in New Issue
Block a user