forked from baron/baron-sso
Merge pull request 'feature/multi-tenant-and-ui-improvements' (#723) from feature/multi-tenant-and-ui-improvements into dev
Reviewed-on: baron/baron-sso#723
This commit is contained in:
@@ -4,9 +4,15 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileSpreadsheet,
|
||||
Pencil,
|
||||
LayoutDashboard,
|
||||
List,
|
||||
Network,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
@@ -35,6 +41,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import { Separator } from "../../../components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -43,6 +51,12 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../../components/ui/tabs";
|
||||
import {
|
||||
type TenantSummary,
|
||||
deleteTenant,
|
||||
@@ -71,8 +85,21 @@ type SortConfig = {
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
const getTenantIcon = (type?: string) => {
|
||||
switch (type?.toUpperCase()) {
|
||||
case "COMPANY_GROUP":
|
||||
return Network;
|
||||
case "ORGANIZATION":
|
||||
case "USER_GROUP":
|
||||
return Network;
|
||||
default:
|
||||
return Building2;
|
||||
}
|
||||
};
|
||||
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
|
||||
@@ -536,15 +563,44 @@ function TenantListPage() {
|
||||
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
|
||||
count: query.data?.total ?? 0,
|
||||
})}
|
||||
</CardDescription>
|
||||
<div className="flex items-center gap-6">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.registry.count",
|
||||
"총 {{count}}개 테넌트",
|
||||
{
|
||||
count: query.data?.total ?? 0,
|
||||
},
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={viewMode}
|
||||
onValueChange={(v) => setViewMode(v as "list" | "hierarchy")}
|
||||
className="w-[280px]"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 p-1 bg-muted/60 border border-border/50">
|
||||
<TabsTrigger
|
||||
value="list"
|
||||
className="text-xs font-semibold data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||
>
|
||||
<List className="w-3.5 h-3.5 mr-1.5" />
|
||||
{t("ui.admin.tenants.view.list", "평면 목록")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="hierarchy"
|
||||
className="text-xs font-semibold data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||
>
|
||||
<Network className="w-3.5 h-3.5 mr-1.5" />
|
||||
{t("ui.admin.tenants.view.hierarchy", "계층 구조")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
@@ -554,214 +610,207 @@ function TenantListPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table className="min-w-[1180px]">
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[48px] whitespace-nowrap">
|
||||
<Checkbox
|
||||
checked={
|
||||
tenants.length > 0 &&
|
||||
deletableTenants.length > 0 &&
|
||||
selectedIds.length === deletableTenants.length
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelectAll(!!checked)
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("id")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.id", "ID")}
|
||||
{getSortIcon("id")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("name")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("type")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
{getSortIcon("type")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("slug")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
{getSortIcon("slug")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("status")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("recursiveMemberCount")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
{getSortIcon("recursiveMemberCount")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("updatedAt")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
{getSortIcon("updatedAt")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px] whitespace-nowrap text-right">
|
||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && tenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={9}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.empty",
|
||||
"아직 등록된 테넌트가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="text-center">
|
||||
{isSeedTenant(tenant) ? (
|
||||
<span className="inline-block h-4 w-4" />
|
||||
) : (
|
||||
<Tabs value={viewMode} className="flex-1 flex flex-col min-h-0">
|
||||
<TabsContent
|
||||
value="list"
|
||||
className="flex-1 flex flex-col min-h-0 m-0"
|
||||
>
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table className="min-w-[1180px]">
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[48px] whitespace-nowrap">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(tenant.id)}
|
||||
checked={
|
||||
tenants.length > 0 &&
|
||||
deletableTenants.length > 0 &&
|
||||
selectedIds.length === deletableTenants.length
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelect(tenant, !!checked)
|
||||
handleSelectAll(!!checked)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
||||
data-testid={`tenant-internal-id-${tenant.id}`}
|
||||
>
|
||||
{tenant.id}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{tenant.name}</span>
|
||||
{isSeedTenant(tenant) && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t("ui.admin.tenants.seed_badge", "초기 설정")}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("id")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.id", "ID")}
|
||||
{getSortIcon("id")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("name")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("type")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
{getSortIcon("type")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("slug")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
{getSortIcon("slug")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("status")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("recursiveMemberCount")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
{getSortIcon("recursiveMemberCount")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("updatedAt")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
{getSortIcon("updatedAt")}
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && tenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={9}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.empty",
|
||||
"아직 등록된 테넌트가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="text-center">
|
||||
{isSeedTenant(tenant) ? (
|
||||
<span className="inline-block h-4 w-4" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(tenant.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelect(tenant, !!checked)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
||||
data-testid={`tenant-internal-id-${tenant.id}`}
|
||||
>
|
||||
{tenant.id}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}`}
|
||||
className="hover:underline text-primary cursor-pointer"
|
||||
>
|
||||
{tenant.name}
|
||||
</Link>
|
||||
{isSeedTenant(tenant) && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.tenants.seed_badge",
|
||||
"초기 설정",
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-mono"
|
||||
>
|
||||
{tenant.type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-mono"
|
||||
>
|
||||
{tenant.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
? "default"
|
||||
: tenant.status === "pending"
|
||||
? "secondary"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
`ui.common.status.${tenant.status}`,
|
||||
tenant.status,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{tenant.recursiveMemberCount}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
{t("ui.common.edit", "편집")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tenant.id, tenant.name)}
|
||||
disabled={
|
||||
deleteMutation.isPending || isSeedTenant(tenant)
|
||||
}
|
||||
title={
|
||||
isSeedTenant(tenant)
|
||||
? t(
|
||||
"msg.admin.tenants.seed_delete_blocked",
|
||||
"초기 설정 테넌트는 삭제할 수 없습니다.",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
? "default"
|
||||
: tenant.status === "pending"
|
||||
? "secondary"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
`ui.common.status.${tenant.status}`,
|
||||
tenant.status,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{tenant.recursiveMemberCount}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString(
|
||||
"ko-KR",
|
||||
)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="hierarchy"
|
||||
className="flex-1 flex flex-col min-h-0 m-0 overflow-hidden"
|
||||
>
|
||||
<TenantHierarchyView tenants={allTenants} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -938,4 +987,276 @@ function TenantListPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Internal Support Components ---
|
||||
|
||||
const HierarchyNode: React.FC<{
|
||||
node: TenantNode;
|
||||
level: number;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
isExpandedInitial?: boolean;
|
||||
}> = ({ node, level, selectedId, onSelect, isExpandedInitial = false }) => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(isExpandedInitial);
|
||||
const isSelected = selectedId === node.id;
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const TypeIcon = getTenantIcon(node.type);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node.id)}
|
||||
className={`flex items-center justify-between w-full px-2 py-1.5 rounded-md text-left transition-colors ${
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center min-w-0">
|
||||
<div className="w-5 flex items-center justify-center mr-1">
|
||||
{hasChildren ? (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
}}
|
||||
className="p-0.5 hover:bg-black/5 rounded cursor-pointer"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
level > 0 && <div className="w-1 h-1 rounded-full bg-border" />
|
||||
)}
|
||||
</div>
|
||||
<TypeIcon
|
||||
size={14}
|
||||
className={`mr-2 shrink-0 ${isSelected ? "text-primary-foreground" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="text-xs truncate font-medium">{node.name}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={isSelected ? "secondary" : "outline"}
|
||||
className="ml-2 text-[9px] px-1 h-3.5 min-w-[1.2rem] justify-center"
|
||||
>
|
||||
{node.recursiveMemberCount}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{isExpanded && hasChildren && (
|
||||
<div className="flex flex-col ml-3 pl-1 border-l border-border/50">
|
||||
{node.children.map((child) => (
|
||||
<HierarchyNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TenantHierarchyView: React.FC<{
|
||||
tenants: TenantSummary[];
|
||||
}> = ({ tenants }) => {
|
||||
const navigate = useNavigate();
|
||||
const { subTree } = React.useMemo(
|
||||
() => buildTenantFullTree(tenants),
|
||||
[tenants],
|
||||
);
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(
|
||||
subTree[0]?.id || null,
|
||||
);
|
||||
|
||||
const tenantMap = React.useMemo(() => {
|
||||
const m = new Map<string, TenantNode>();
|
||||
const fill = (nodes: TenantNode[]) => {
|
||||
for (const n of nodes) {
|
||||
m.set(n.id, n);
|
||||
if (n.children) fill(n.children);
|
||||
}
|
||||
};
|
||||
fill(subTree);
|
||||
return m;
|
||||
}, [subTree]);
|
||||
|
||||
const selectedNode = selectedId ? tenantMap.get(selectedId) : null;
|
||||
|
||||
const siblings = React.useMemo(() => {
|
||||
if (!selectedNode) return [];
|
||||
if (!selectedNode.parentId) return subTree;
|
||||
const parent = tenantMap.get(selectedNode.parentId);
|
||||
return parent?.children ?? [];
|
||||
}, [selectedNode, subTree, tenantMap]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex gap-4 overflow-hidden min-h-0 mt-4">
|
||||
{/* Sidebar: Tree */}
|
||||
<Card className="w-72 flex flex-col bg-muted/20 border shadow-none">
|
||||
<CardHeader className="p-3 border-b bg-muted/10">
|
||||
<CardTitle className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||
조직 계층도
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2 overflow-hidden flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-0.5">
|
||||
{subTree.map((node) => (
|
||||
<HierarchyNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
isExpandedInitial={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Main: Details & Lists */}
|
||||
<div className="flex-1 flex flex-col gap-4 overflow-hidden">
|
||||
{selectedNode ? (
|
||||
<>
|
||||
<Card className="shadow-none border bg-card">
|
||||
<CardHeader className="p-4 flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
||||
{React.createElement(getTenantIcon(selectedNode.type), {
|
||||
size: 24,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{selectedNode.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs font-mono">
|
||||
{selectedNode.slug} ({selectedNode.type})
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${selectedNode.id}`)}
|
||||
>
|
||||
<ExternalLink size={14} className="mr-2" /> 상세보기
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="flex-1 grid grid-cols-2 gap-4 min-h-0">
|
||||
{/* Siblings (Same Depth) */}
|
||||
<Card className="flex flex-col shadow-none border overflow-hidden">
|
||||
<CardHeader className="p-3 bg-muted/10 border-b">
|
||||
<CardTitle className="text-xs font-semibold">
|
||||
동일 레벨 조직 ({siblings.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-1">
|
||||
{siblings.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => setSelectedId(s.id)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
setSelectedId(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors border ${
|
||||
s.id === selectedId
|
||||
? "bg-primary/5 border-primary/20"
|
||||
: "hover:bg-muted border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{React.createElement(getTenantIcon(s.type), {
|
||||
size: 14,
|
||||
className: "text-muted-foreground",
|
||||
})}
|
||||
<span className="text-sm truncate">{s.name}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{s.recursiveMemberCount}명
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Children */}
|
||||
<Card className="flex flex-col shadow-none border overflow-hidden">
|
||||
<CardHeader className="p-3 bg-muted/10 border-b">
|
||||
<CardTitle className="text-xs font-semibold">
|
||||
하위 조직 ({selectedNode.children.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-1">
|
||||
{selectedNode.children.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
onClick={() => setSelectedId(c.id)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
setSelectedId(c.id);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between p-2 rounded-md hover:bg-muted cursor-pointer transition-colors border border-transparent"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{React.createElement(getTenantIcon(c.type), {
|
||||
size: 14,
|
||||
className: "text-muted-foreground",
|
||||
})}
|
||||
<span className="text-sm truncate">{c.name}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{c.recursiveMemberCount}명
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{selectedNode.children.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground opacity-50">
|
||||
하위 조직이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center border-2 border-dashed rounded-xl opacity-30">
|
||||
조직을 선택하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantListPage;
|
||||
|
||||
@@ -229,7 +229,8 @@ const MemberTable: React.FC<{
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) => updateUser(userId, { tenantSlug: "" }),
|
||||
mutationFn: (userId: string) =>
|
||||
updateUser(userId, { tenantSlug, isRemoveTenant: true }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
||||
toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다."));
|
||||
|
||||
35
backend/check_aaa2.go
Normal file
35
backend/check_aaa2.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
CompanyCode string
|
||||
Status string
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := "host=localhost user=baron password=password dbname=baron_sso port=5432 sslmode=disable TimeZone=Asia/Seoul"
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var users []User
|
||||
err = db.Raw("SELECT id, email, name, company_code, status FROM users WHERE company_code = 'aaa2' OR 'aaa2' = ANY(company_codes)").Scan(&users).Error
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Total users for aaa2: %d\n", len(users))
|
||||
for _, u := range users {
|
||||
fmt.Printf("- %s (%s) | status: %s | primary: %s\n", u.Name, u.Email, u.Status, u.CompanyCode)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
module baron-sso-backend
|
||||
|
||||
go 1.26.2
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.42.0
|
||||
@@ -90,15 +90,12 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
|
||||
@@ -191,10 +191,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -220,8 +216,6 @@ github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -269,8 +263,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
|
||||
@@ -276,24 +276,56 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
||||
compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
||||
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
|
||||
secondaryCodes := extractTraitStringArray(identity.Traits, "companyCodes")
|
||||
|
||||
// Tenant Admin & Member filtering
|
||||
if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin {
|
||||
if !manageableSlugs[compCode] && !manageableSlugs[tID] {
|
||||
hasAccess := manageableSlugs[compCode] || manageableSlugs[tID]
|
||||
if !hasAccess && len(secondaryCodes) > 0 {
|
||||
for _, code := range secondaryCodes {
|
||||
if manageableSlugs[strings.ToLower(code)] {
|
||||
hasAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasAccess {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated tenantSlug filter
|
||||
if tenantSlug != "" && !strings.EqualFold(compCode, tenantSlug) && tID != targetTenantID {
|
||||
continue
|
||||
if tenantSlug != "" {
|
||||
matches := strings.EqualFold(compCode, tenantSlug) || tID == targetTenantID
|
||||
if !matches && len(secondaryCodes) > 0 {
|
||||
for _, code := range secondaryCodes {
|
||||
if strings.EqualFold(code, tenantSlug) {
|
||||
matches = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Search filtering (Keyword search in email, name, or companyCode)
|
||||
if search != "" {
|
||||
if !strings.Contains(email, searchLower) &&
|
||||
!strings.Contains(name, searchLower) &&
|
||||
!strings.Contains(strings.ToLower(compCode), searchLower) {
|
||||
matchesSearch := strings.Contains(email, searchLower) ||
|
||||
strings.Contains(name, searchLower) ||
|
||||
strings.Contains(strings.ToLower(compCode), searchLower)
|
||||
|
||||
if !matchesSearch && len(secondaryCodes) > 0 {
|
||||
for _, code := range secondaryCodes {
|
||||
if strings.Contains(strings.ToLower(code), searchLower) {
|
||||
matchesSearch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !matchesSearch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -1588,27 +1620,54 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal update: replace primary company code and ensure it's in existingCodes
|
||||
traits["companyCode"] = code
|
||||
// Resolve TenantID for Kratos Trait
|
||||
if h.TenantService != nil && code != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
||||
traits["tenant_id"] = tenant.ID
|
||||
}
|
||||
}
|
||||
// Normal update (Move): replace primary company code and remove the old one from existingCodes
|
||||
currentPrimary := extractTraitString(traits, "companyCode")
|
||||
if currentPrimary != "" && currentPrimary != code {
|
||||
// Remove old primary from existingCodes
|
||||
var newCodes []string
|
||||
for _, existing := range existingCodes {
|
||||
if existing != currentPrimary {
|
||||
newCodes = append(newCodes, existing)
|
||||
}
|
||||
}
|
||||
existingCodes = newCodes
|
||||
|
||||
found := false
|
||||
for _, existing := range existingCodes {
|
||||
if existing == code {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && code != "" {
|
||||
existingCodes = append(existingCodes, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
// [Keto Sync] Remove membership for the old tenant
|
||||
if h.TenantService != nil && h.KetoOutboxRepo != nil {
|
||||
go func(removedSlug string) {
|
||||
bgCtx := context.Background()
|
||||
if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil {
|
||||
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: t.ID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
}(currentPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
traits["companyCode"] = code
|
||||
// Resolve TenantID for Kratos Trait
|
||||
if h.TenantService != nil && code != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
||||
traits["tenant_id"] = tenant.ID
|
||||
}
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, existing := range existingCodes {
|
||||
if existing == code {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && code != "" {
|
||||
existingCodes = append(existingCodes, code)
|
||||
}
|
||||
} }
|
||||
|
||||
// Deduplicate and save back companyCodes
|
||||
var codesToSave []string
|
||||
@@ -2128,6 +2187,27 @@ func extractTraitString(traits map[string]interface{}, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractTraitStringArray(traits map[string]interface{}, key string) []string {
|
||||
if traits == nil {
|
||||
return nil
|
||||
}
|
||||
if raw, ok := traits[key]; ok {
|
||||
if slice, ok := raw.([]interface{}); ok {
|
||||
var result []string
|
||||
for _, v := range slice {
|
||||
if s, ok := v.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
if slice, ok := raw.([]string); ok {
|
||||
return slice
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolvePasswordLoginID(traits map[string]interface{}) string {
|
||||
// First check custom_login_ids (array)
|
||||
if raw, ok := traits["custom_login_ids"]; ok {
|
||||
|
||||
Reference in New Issue
Block a user