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

View File

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

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

View File

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