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,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
|
Building2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
|
ExternalLink,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Pencil,
|
LayoutDashboard,
|
||||||
|
List,
|
||||||
|
Network,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
@@ -35,6 +41,8 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||||
|
import { Separator } from "../../../components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -43,6 +51,12 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "../../../components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
deleteTenant,
|
deleteTenant,
|
||||||
@@ -71,8 +85,21 @@ type SortConfig = {
|
|||||||
direction: "asc" | "desc";
|
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() {
|
function TenantListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
|
||||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||||
const [search, setSearch] = React.useState("");
|
const [search, setSearch] = React.useState("");
|
||||||
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
|
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">
|
<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">
|
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||||
<div>
|
<div className="flex items-center gap-6">
|
||||||
<CardTitle>
|
<div>
|
||||||
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
<CardTitle>
|
||||||
</CardTitle>
|
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
||||||
<CardDescription>
|
</CardTitle>
|
||||||
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
|
<CardDescription>
|
||||||
count: query.data?.total ?? 0,
|
{t(
|
||||||
})}
|
"msg.admin.tenants.registry.count",
|
||||||
</CardDescription>
|
"총 {{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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||||
@@ -554,214 +610,207 @@ function TenantListPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<Tabs value={viewMode} className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<TabsContent
|
||||||
<Table className="min-w-[1180px]">
|
value="list"
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
className="flex-1 flex flex-col min-h-0 m-0"
|
||||||
<TableRow>
|
>
|
||||||
<TableHead className="w-[48px] whitespace-nowrap">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
<Checkbox
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
checked={
|
<Table className="min-w-[1180px]">
|
||||||
tenants.length > 0 &&
|
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||||
deletableTenants.length > 0 &&
|
<TableRow>
|
||||||
selectedIds.length === deletableTenants.length
|
<TableHead className="w-[48px] whitespace-nowrap">
|
||||||
}
|
|
||||||
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" />
|
|
||||||
) : (
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.includes(tenant.id)}
|
checked={
|
||||||
|
tenants.length > 0 &&
|
||||||
|
deletableTenants.length > 0 &&
|
||||||
|
selectedIds.length === deletableTenants.length
|
||||||
|
}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
handleSelect(tenant, !!checked)
|
handleSelectAll(!!checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
</TableHead>
|
||||||
</TableCell>
|
<TableHead
|
||||||
<TableCell
|
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
onClick={() => requestSort("id")}
|
||||||
data-testid={`tenant-internal-id-${tenant.id}`}
|
>
|
||||||
>
|
<div className="flex items-center">
|
||||||
{tenant.id}
|
{t("ui.admin.tenants.table.id", "ID")}
|
||||||
</TableCell>
|
{getSortIcon("id")}
|
||||||
<TableCell className="font-semibold">
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</TableHead>
|
||||||
<span>{tenant.name}</span>
|
<TableHead
|
||||||
{isSeedTenant(tenant) && (
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
onClick={() => requestSort("name")}
|
||||||
{t("ui.admin.tenants.seed_badge", "초기 설정")}
|
>
|
||||||
|
<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>
|
</Badge>
|
||||||
)}
|
</TableCell>
|
||||||
</div>
|
<TableCell className="font-mono text-xs">
|
||||||
</TableCell>
|
{tenant.slug}
|
||||||
<TableCell className="whitespace-nowrap">
|
</TableCell>
|
||||||
<Badge
|
<TableCell className="whitespace-nowrap">
|
||||||
variant="outline"
|
<Badge
|
||||||
className="text-[10px] font-mono"
|
variant={
|
||||||
>
|
tenant.status === "active"
|
||||||
{tenant.type}
|
? "default"
|
||||||
</Badge>
|
: tenant.status === "pending"
|
||||||
</TableCell>
|
? "secondary"
|
||||||
<TableCell className="font-mono text-xs">
|
: "muted"
|
||||||
{tenant.slug}
|
}
|
||||||
</TableCell>
|
>
|
||||||
<TableCell className="whitespace-nowrap">
|
{t(
|
||||||
<Badge
|
`ui.common.status.${tenant.status}`,
|
||||||
variant={
|
tenant.status,
|
||||||
tenant.status === "active"
|
)}
|
||||||
? "default"
|
</Badge>
|
||||||
: tenant.status === "pending"
|
</TableCell>
|
||||||
? "secondary"
|
<TableCell className="font-medium">
|
||||||
: "muted"
|
{tenant.recursiveMemberCount}
|
||||||
}
|
</TableCell>
|
||||||
>
|
<TableCell className="whitespace-nowrap text-xs">
|
||||||
{t(
|
{tenant.updatedAt
|
||||||
`ui.common.status.${tenant.status}`,
|
? new Date(tenant.updatedAt).toLocaleString(
|
||||||
tenant.status,
|
"ko-KR",
|
||||||
)}
|
)
|
||||||
</Badge>
|
: "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">
|
</TableRow>
|
||||||
{tenant.recursiveMemberCount}
|
))}
|
||||||
</TableCell>
|
</TableBody>
|
||||||
<TableCell className="whitespace-nowrap text-xs">
|
</Table>
|
||||||
{tenant.updatedAt
|
</div>
|
||||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
</div>
|
||||||
: "-"}
|
</TabsContent>
|
||||||
</TableCell>
|
|
||||||
<TableCell className="whitespace-nowrap text-right">
|
<TabsContent
|
||||||
<div className="flex justify-end gap-2">
|
value="hierarchy"
|
||||||
<Button
|
className="flex-1 flex flex-col min-h-0 m-0 overflow-hidden"
|
||||||
variant="outline"
|
>
|
||||||
size="sm"
|
<TenantHierarchyView tenants={allTenants} />
|
||||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
</TabsContent>
|
||||||
>
|
</Tabs>
|
||||||
<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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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;
|
export default TenantListPage;
|
||||||
|
|||||||
@@ -229,7 +229,8 @@ const MemberTable: React.FC<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
const removeMutation = useMutation({
|
const removeMutation = useMutation({
|
||||||
mutationFn: (userId: string) => updateUser(userId, { tenantSlug: "" }),
|
mutationFn: (userId: string) =>
|
||||||
|
updateUser(userId, { tenantSlug, isRemoveTenant: true }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
||||||
toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다."));
|
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
|
module baron-sso-backend
|
||||||
|
|
||||||
go 1.26.2
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/ClickHouse/clickhouse-go/v2 v2.42.0
|
github.com/ClickHouse/clickhouse-go/v2 v2.42.0
|
||||||
@@ -90,15 +90,12 @@ require (
|
|||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // 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/rivo/uniseg v0.2.0 // indirect
|
||||||
github.com/segmentio/asm v1.2.1 // indirect
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // 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/go-sysconf v0.3.12 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // 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/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 h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
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 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
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 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ=
|
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/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 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
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.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 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.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=
|
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"))
|
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
||||||
compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
||||||
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
|
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
|
||||||
|
secondaryCodes := extractTraitStringArray(identity.Traits, "companyCodes")
|
||||||
|
|
||||||
// Tenant Admin & Member filtering
|
// Tenant Admin & Member filtering
|
||||||
if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin {
|
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
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dedicated tenantSlug filter
|
// Dedicated tenantSlug filter
|
||||||
if tenantSlug != "" && !strings.EqualFold(compCode, tenantSlug) && tID != targetTenantID {
|
if tenantSlug != "" {
|
||||||
continue
|
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)
|
// Search filtering (Keyword search in email, name, or companyCode)
|
||||||
if search != "" {
|
if search != "" {
|
||||||
if !strings.Contains(email, searchLower) &&
|
matchesSearch := strings.Contains(email, searchLower) ||
|
||||||
!strings.Contains(name, searchLower) &&
|
strings.Contains(name, searchLower) ||
|
||||||
!strings.Contains(strings.ToLower(compCode), 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
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1588,27 +1620,54 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal update: replace primary company code and ensure it's in existingCodes
|
// Normal update (Move): replace primary company code and remove the old one from existingCodes
|
||||||
traits["companyCode"] = code
|
currentPrimary := extractTraitString(traits, "companyCode")
|
||||||
// Resolve TenantID for Kratos Trait
|
if currentPrimary != "" && currentPrimary != code {
|
||||||
if h.TenantService != nil && code != "" {
|
// Remove old primary from existingCodes
|
||||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
var newCodes []string
|
||||||
traits["tenant_id"] = tenant.ID
|
for _, existing := range existingCodes {
|
||||||
}
|
if existing != currentPrimary {
|
||||||
}
|
newCodes = append(newCodes, existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existingCodes = newCodes
|
||||||
|
|
||||||
found := false
|
// [Keto Sync] Remove membership for the old tenant
|
||||||
for _, existing := range existingCodes {
|
if h.TenantService != nil && h.KetoOutboxRepo != nil {
|
||||||
if existing == code {
|
go func(removedSlug string) {
|
||||||
found = true
|
bgCtx := context.Background()
|
||||||
break
|
if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil {
|
||||||
}
|
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
|
||||||
}
|
Namespace: "Tenant",
|
||||||
if !found && code != "" {
|
Object: t.ID,
|
||||||
existingCodes = append(existingCodes, code)
|
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
|
// Deduplicate and save back companyCodes
|
||||||
var codesToSave []string
|
var codesToSave []string
|
||||||
@@ -2128,6 +2187,27 @@ func extractTraitString(traits map[string]interface{}, key string) string {
|
|||||||
return ""
|
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 {
|
func resolvePasswordLoginID(traits map[string]interface{}) string {
|
||||||
// First check custom_login_ids (array)
|
// First check custom_login_ids (array)
|
||||||
if raw, ok := traits["custom_login_ids"]; ok {
|
if raw, ok := traits["custom_login_ids"]; ok {
|
||||||
|
|||||||
Reference in New Issue
Block a user