diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index e3459b49..3f073d06 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -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([]); const [search, setSearch] = React.useState(""); const [sortConfig, setSortConfig] = React.useState(null); @@ -536,15 +563,44 @@ function TenantListPage() { -
- - {t("ui.admin.tenants.registry.title", "Tenant Registry")} - - - {t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", { - count: query.data?.total ?? 0, - })} - +
+
+ + {t("ui.admin.tenants.registry.title", "Tenant Registry")} + + + {t( + "msg.admin.tenants.registry.count", + "총 {{count}}개 테넌트", + { + count: query.data?.total ?? 0, + }, + )} + +
+ + setViewMode(v as "list" | "hierarchy")} + className="w-[280px]" + > + + + + {t("ui.admin.tenants.view.list", "평면 목록")} + + + + {t("ui.admin.tenants.view.hierarchy", "계층 구조")} + + +
@@ -554,214 +610,207 @@ function TenantListPage() {
)} -
-
- - - - - 0 && - deletableTenants.length > 0 && - selectedIds.length === deletableTenants.length - } - onCheckedChange={(checked) => - handleSelectAll(!!checked) - } - /> - - requestSort("id")} - > -
- {t("ui.admin.tenants.table.id", "ID")} - {getSortIcon("id")} -
-
- requestSort("name")} - > -
- {t("ui.admin.tenants.table.name", "NAME")} - {getSortIcon("name")} -
-
- requestSort("type")} - > -
- {t("ui.admin.tenants.table.type", "TYPE")} - {getSortIcon("type")} -
-
- requestSort("slug")} - > -
- {t("ui.admin.tenants.table.slug", "SLUG")} - {getSortIcon("slug")} -
-
- requestSort("status")} - > -
- {t("ui.admin.tenants.table.status", "STATUS")} - {getSortIcon("status")} -
-
- requestSort("recursiveMemberCount")} - > -
- {t("ui.admin.tenants.table.members", "MEMBERS")} - {getSortIcon("recursiveMemberCount")} -
-
- requestSort("updatedAt")} - > -
- {t("ui.admin.tenants.table.updated", "UPDATED")} - {getSortIcon("updatedAt")} -
-
- - {t("ui.admin.tenants.table.actions", "ACTIONS")} - -
-
- - {query.isLoading && ( - - - {t("msg.common.loading", "로딩 중...")} - - - )} - {!query.isLoading && tenants.length === 0 && ( - - - {t( - "msg.admin.tenants.empty", - "아직 등록된 테넌트가 없습니다.", - )} - - - )} - {tenants.map((tenant) => ( - - - {isSeedTenant(tenant) ? ( - - ) : ( + + +
+
+
+ + + 0 && + deletableTenants.length > 0 && + selectedIds.length === deletableTenants.length + } onCheckedChange={(checked) => - handleSelect(tenant, !!checked) + handleSelectAll(!!checked) } /> - )} - - - {tenant.id} - - -
- {tenant.name} - {isSeedTenant(tenant) && ( - - {t("ui.admin.tenants.seed_badge", "초기 설정")} + + requestSort("id")} + > +
+ {t("ui.admin.tenants.table.id", "ID")} + {getSortIcon("id")} +
+
+ requestSort("name")} + > +
+ {t("ui.admin.tenants.table.name", "NAME")} + {getSortIcon("name")} +
+
+ requestSort("type")} + > +
+ {t("ui.admin.tenants.table.type", "TYPE")} + {getSortIcon("type")} +
+
+ requestSort("slug")} + > +
+ {t("ui.admin.tenants.table.slug", "SLUG")} + {getSortIcon("slug")} +
+
+ requestSort("status")} + > +
+ {t("ui.admin.tenants.table.status", "STATUS")} + {getSortIcon("status")} +
+
+ requestSort("recursiveMemberCount")} + > +
+ {t("ui.admin.tenants.table.members", "MEMBERS")} + {getSortIcon("recursiveMemberCount")} +
+
+ requestSort("updatedAt")} + > +
+ {t("ui.admin.tenants.table.updated", "UPDATED")} + {getSortIcon("updatedAt")} +
+
+ + + + {query.isLoading && ( + + + {t("msg.common.loading", "로딩 중...")} + + + )} + {!query.isLoading && tenants.length === 0 && ( + + + {t( + "msg.admin.tenants.empty", + "아직 등록된 테넌트가 없습니다.", + )} + + + )} + {tenants.map((tenant) => ( + + + {isSeedTenant(tenant) ? ( + + ) : ( + + handleSelect(tenant, !!checked) + } + /> + )} + + + {tenant.id} + + +
+ + {tenant.name} + + {isSeedTenant(tenant) && ( + + {t( + "ui.admin.tenants.seed_badge", + "초기 설정", + )} + + )} +
+
+ + + {tenant.type} - )} -
-
- - - {tenant.type} - - - - {tenant.slug} - - - - {t( - `ui.common.status.${tenant.status}`, - tenant.status, - )} - - - - {tenant.recursiveMemberCount} - - - {tenant.updatedAt - ? new Date(tenant.updatedAt).toLocaleString("ko-KR") - : "-"} - - -
- - -
-
-
- ))} - -
-
-
+ + + {tenant.slug} + + + + {t( + `ui.common.status.${tenant.status}`, + tenant.status, + )} + + + + {tenant.recursiveMemberCount} + + + {tenant.updatedAt + ? new Date(tenant.updatedAt).toLocaleString( + "ko-KR", + ) + : "-"} + + + ))} + + + + + + + + + +
@@ -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 ( +
+ + + {isExpanded && hasChildren && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +}; + +const TenantHierarchyView: React.FC<{ + tenants: TenantSummary[]; +}> = ({ tenants }) => { + const navigate = useNavigate(); + const { subTree } = React.useMemo( + () => buildTenantFullTree(tenants), + [tenants], + ); + const [selectedId, setSelectedId] = React.useState( + subTree[0]?.id || null, + ); + + const tenantMap = React.useMemo(() => { + const m = new Map(); + 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 ( +
+ {/* Sidebar: Tree */} + + + + 조직 계층도 + + + + +
+ {subTree.map((node) => ( + + ))} +
+
+
+
+ + {/* Main: Details & Lists */} +
+ {selectedNode ? ( + <> + + +
+
+ {React.createElement(getTenantIcon(selectedNode.type), { + size: 24, + })} +
+
+ + {selectedNode.name} + + + {selectedNode.slug} ({selectedNode.type}) + +
+
+
+ +
+
+
+ +
+ {/* Siblings (Same Depth) */} + + + + 동일 레벨 조직 ({siblings.length}) + + + + +
+ {siblings.map((s) => ( +
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" + }`} + > +
+ {React.createElement(getTenantIcon(s.type), { + size: 14, + className: "text-muted-foreground", + })} + {s.name} +
+ + {s.recursiveMemberCount}명 + +
+ ))} +
+
+
+
+ + {/* Children */} + + + + 하위 조직 ({selectedNode.children.length}) + + + + +
+ {selectedNode.children.map((c) => ( +
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" + > +
+ {React.createElement(getTenantIcon(c.type), { + size: 14, + className: "text-muted-foreground", + })} + {c.name} +
+ + {c.recursiveMemberCount}명 + +
+ ))} + {selectedNode.children.length === 0 && ( +
+ 하위 조직이 없습니다. +
+ )} +
+
+
+
+
+ + ) : ( +
+ 조직을 선택하세요. +
+ )} +
+
+ ); +}; + export default TenantListPage; diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index f6aa4060..8dd1f01e 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -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", "조직에서 제외되었습니다.")); diff --git a/backend/check_aaa2.go b/backend/check_aaa2.go new file mode 100644 index 00000000..29a5df6c --- /dev/null +++ b/backend/check_aaa2.go @@ -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) + } +} diff --git a/backend/go.mod b/backend/go.mod index cb9b8736..e25ff780 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index b75f78d3..5f118676 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 8502e096..8e841a6d 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -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 {