1
0
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:
2026-05-08 14:30:18 +09:00
6 changed files with 678 additions and 252 deletions

View File

@@ -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;

View File

@@ -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
View 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)
}
}

View File

@@ -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

View File

@@ -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=

View File

@@ -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 {