1
0
forked from baron/baron-sso

Merge pull request 'feature/org-chart-tab-separation' (#547) from feature/org-chart-tab-separation into dev

Reviewed-on: baron/baron-sso#547
This commit is contained in:
2026-04-13 13:26:07 +09:00
13 changed files with 335 additions and 154 deletions

View File

@@ -7,6 +7,7 @@ import {
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
Moon, Moon,
Network,
NotebookTabs, NotebookTabs,
ShieldHalf, ShieldHalf,
Sun, Sun,
@@ -105,6 +106,11 @@ function AppLayout() {
to: "/tenants", to: "/tenants",
icon: Building2, icon: Building2,
}); });
filteredItems.splice(2, 0, {
label: "ui.admin.nav.org_chart",
to: "/tenants/org-chart",
icon: Network,
});
} else if (isTenantAdmin || manageableCount > 0) { } else if (isTenantAdmin || manageableCount > 0) {
if (manageableCount <= 1 && profile?.tenantId) { if (manageableCount <= 1 && profile?.tenantId) {
filteredItems.splice(1, 0, { filteredItems.splice(1, 0, {
@@ -119,6 +125,22 @@ function AppLayout() {
icon: Building2, icon: Building2,
}); });
} }
filteredItems.splice(
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
0,
{
label: "ui.admin.nav.org_chart",
to: "/tenants/org-chart",
icon: Network,
},
);
} else {
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
filteredItems.splice(1, 0, {
label: "ui.admin.nav.org_chart",
to: "/tenants/org-chart",
icon: Network,
});
} }
return filteredItems; return filteredItems;
@@ -418,23 +440,33 @@ function AppLayout() {
</div> </div>
<nav className="px-2 pb-4 md:px-3 md:pb-8"> <nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{navItems.map(({ label, to, icon: Icon }) => ( {navItems.map(({ label, to, icon: Icon }) => {
<NavLink const isOrgChart = location.pathname === "/tenants/org-chart";
key={to} const isTenantsRoot = to === "/tenants";
to={to} const isCustomActive = isTenantsRoot
className={({ isActive }) => ? location.pathname.startsWith("/tenants") && !isOrgChart
[ : to === "/"
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition", ? location.pathname === "/"
isActive : location.pathname.startsWith(to);
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground", return (
].join(" ") <NavLink
} key={to}
> to={to}
<Icon size={18} /> className={() =>
<span>{t(label, label)}</span> [
</NavLink> "flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
))} isCustomActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{t(label, label)}</span>
</NavLink>
);
})}
</div> </div>
<div className="border-t border-border/50 px-3 pt-4"> <div className="border-t border-border/50 px-3 pt-4">

View File

@@ -216,12 +216,6 @@ function TenantListPage() {
/> />
</RoleGuard> </RoleGuard>
<Button asChild variant="outline" className="gap-2">
<Link to="/tenants/org-chart">
{t("ui.admin.tenants.view_org_chart", "전체 조직도 보기")}
</Link>
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => query.refetch()} onClick={() => query.refetch()}

View File

@@ -3,22 +3,26 @@ import { ChevronLeft } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { type UserSummary, fetchUsers } from "../../../lib/adminApi"; import {
type UserSummary,
fetchTenants,
fetchUsers,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
type UserWithPath = UserSummary & { _path: { level: number; name: string }[] };
interface OrgNode { interface OrgNode {
id: string;
name: string; name: string;
level: number; level: number;
members: UserWithPath[]; members: UserSummary[];
subData: UserWithPath[];
children: OrgNode[]; children: OrgNode[];
totalCount?: number; totalCount?: number;
companyCode?: string;
type?: string;
} }
export function TenantOrgChartPage() { export function TenantOrgChartPage() {
const [selectedDept, setSelectedDept] = React.useState<string>("전체");
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const [lines, setLines] = React.useState< const [lines, setLines] = React.useState<
{ {
@@ -32,37 +36,66 @@ export function TenantOrgChartPage() {
>([]); >([]);
const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 }); const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 });
const query = useQuery({ const tenantsQuery = useQuery({
queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchTenants(10000, 0),
});
const usersQuery = useQuery({
queryKey: ["users", { limit: 5000, offset: 0 }], queryKey: ["users", { limit: 5000, offset: 0 }],
queryFn: () => fetchUsers(5000, 0), queryFn: () => fetchUsers(5000, 0),
}); });
const users = React.useMemo(() => { const { rootNodes, usersMap } = React.useMemo(() => {
if (!query.data?.items) return []; if (!tenantsQuery.data?.items || !usersQuery.data?.items) {
return query.data.items return { rootNodes: [], usersMap: new Map<string, UserSummary[]>() };
.filter((u) => u.status === "active") }
.map((u) => {
const deptStr = u.department || "";
const parts = deptStr.includes(" > ")
? deptStr.split(" > ")
: deptStr.split("/");
return { const uMap = new Map<string, UserSummary[]>();
...u,
_path: parts // Process users to map them to multiple tenants if applicable
.map((name, i) => ({ level: i, name: name.trim() })) for (const u of usersQuery.data.items) {
.filter((p) => p.name), if (u.status !== "active") continue;
};
}); // Extract all associated tenant slugs
}, [query.data]); const slugs = new Set<string>();
const primarySlug =
u.tenantSlug?.toLowerCase() || u.companyCode?.toLowerCase() || "";
if (primarySlug) {
slugs.add(primarySlug);
}
if (u.joinedTenants && Array.isArray(u.joinedTenants)) {
for (const jt of u.joinedTenants) {
if (jt.slug) {
slugs.add(jt.slug.toLowerCase());
}
}
}
// Add user to all matching slugs in the map
for (const slug of slugs) {
const list = uMap.get(slug) || [];
// Prevent duplicate user references in the same list
if (!list.some((existing) => existing.id === u.id)) {
list.push(u);
}
uMap.set(slug, list);
}
}
const allTenants = tenantsQuery.data.items;
const { subTree: roots } = buildTenantFullTree(allTenants);
return { rootNodes: roots, usersMap: uMap };
}, [tenantsQuery.data, usersQuery.data]);
const [selectedDept, setSelectedDept] = React.useState<string>("전체");
const depts = React.useMemo(() => { const depts = React.useMemo(() => {
const s = new Set<string>(); return rootNodes.map((n) => n.name).sort();
for (const u of users) { }, [rootNodes]);
if (u._path[0]) s.add(u._path[0].name);
}
return Array.from(s).sort();
}, [users]);
React.useEffect(() => { React.useEffect(() => {
if (selectedDept !== "전체" && !depts.includes(selectedDept)) { if (selectedDept !== "전체" && !depts.includes(selectedDept)) {
@@ -70,44 +103,28 @@ export function TenantOrgChartPage() {
} }
}, [selectedDept, depts]); }, [selectedDept, depts]);
const buildHierarchy = (data: UserWithPath[], depth: number): OrgNode[] => { const buildHierarchy = (tNode: TenantNode, depth: number): OrgNode => {
if (!data.length) return []; const slug = tNode.slug.toLowerCase();
const map: Record<string, OrgNode> = {}; const members = usersMap.get(slug) || [];
const groups: OrgNode[] = [];
for (const m of data) { const children = tNode.children.map((c) => buildHierarchy(c, depth + 1));
const step = m._path[depth];
if (!step) continue; // Calculate recursive total users instead of simple tenant count to account for actual mapped members
if (!map[step.name]) { let recursiveTotal = members.length;
map[step.name] = { for (const child of children) {
name: step.name, recursiveTotal += child.totalCount || 0;
level: step.level,
members: [],
subData: [],
children: [],
};
groups.push(map[step.name]);
}
if (m._path.length === depth + 1) {
map[step.name].members.push(m);
} else {
map[step.name].subData.push(m);
}
} }
return groups.map((g) => ({ return {
...g, id: tNode.id,
children: buildHierarchy(g.subData, depth + 1), name: tNode.name,
})); level: depth,
}; members,
children,
const calculateTotalCount = (node: OrgNode): number => { totalCount: recursiveTotal,
let count = node.members.length; companyCode: slug,
for (const c of node.children) { type: tNode.type,
count += calculateTotalCount(c); };
}
node.totalCount = count;
return count;
}; };
const drawLines = React.useCallback(() => { const drawLines = React.useCallback(() => {
@@ -184,42 +201,38 @@ export function TenantOrgChartPage() {
}, []); }, []);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
// Biome requires used variables. We use users and selectedDept length just to satisfy the linter const _forceTrigger = rootNodes.length + usersMap.size;
// so it knows to re-run this effect when they change.
const _forceTrigger = selectedDept + users.length;
const timeout = setTimeout(drawLines, 150); const timeout = setTimeout(drawLines, 150);
window.addEventListener("resize", drawLines); window.addEventListener("resize", drawLines);
return () => { return () => {
clearTimeout(timeout); clearTimeout(timeout);
window.removeEventListener("resize", drawLines); window.removeEventListener("resize", drawLines);
}; };
}, [drawLines, selectedDept, users]); }, [drawLines, rootNodes.length, usersMap.size]);
if (query.isLoading) { if (tenantsQuery.isLoading || usersQuery.isLoading) {
return ( return (
<div className="p-8 text-center text-muted-foreground"> ...</div> <div className="p-8 text-center text-muted-foreground"> ...</div>
); );
} }
const targetDepts = selectedDept === "전체" ? depts : [selectedDept]; // Count unique users across the fetched payload
const totalUsers = targetDepts.reduce((acc, d) => { const totalUniqueUsers =
return acc + users.filter((u) => u._path[0]?.name === d).length; usersQuery.data?.items?.filter((u) => u.status === "active").length || 0;
}, 0);
const targetNodes =
selectedDept === "전체"
? rootNodes
: rootNodes.filter((n) => n.name === selectedDept);
return ( return (
<div className="flex flex-col h-[calc(100vh-theme(spacing.32))] bg-slate-50 rounded-xl overflow-hidden shadow-sm border border-slate-200"> <div className="flex flex-col h-[calc(100vh-theme(spacing.32))] bg-slate-50 rounded-xl overflow-hidden shadow-sm border border-slate-200">
<header className="flex items-center justify-between px-6 py-4 bg-white border-b border-slate-200 shadow-sm z-10 shrink-0"> <header className="flex items-center justify-between px-6 py-4 bg-white border-b border-slate-200 shadow-sm z-10 shrink-0">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="outline" size="icon" asChild className="h-8 w-8">
<Link to="/tenants">
<ChevronLeft size={16} />
</Link>
</Button>
<div> <div>
<h2 className="text-xl font-bold text-slate-800"> </h2> <h2 className="text-xl font-bold text-slate-800"></h2>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
. () .
</p> </p>
</div> </div>
</div> </div>
@@ -242,7 +255,7 @@ export function TenantOrgChartPage() {
</button> </button>
))} ))}
<div className="ml-2 whitespace-nowrap px-4 py-1.5 bg-blue-50 text-blue-700 font-bold rounded-full border border-blue-200 text-sm flex items-center"> <div className="ml-2 whitespace-nowrap px-4 py-1.5 bg-blue-50 text-blue-700 font-bold rounded-full border border-blue-200 text-sm flex items-center">
{totalUsers} {totalUniqueUsers}
</div> </div>
</div> </div>
</header> </header>
@@ -272,17 +285,15 @@ export function TenantOrgChartPage() {
</svg> </svg>
<div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-24"> <div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-24">
{targetDepts.map((dName) => { {targetNodes.map((tNode) => {
const dData = users.filter((u) => u._path[0]?.name === dName); const orgNode = buildHierarchy(tNode, 0);
const hierarchy = buildHierarchy(dData, 0);
const dNode = hierarchy[0];
if (!dNode) return null;
calculateTotalCount(dNode);
return ( return (
<div key={dName} className="flex flex-col items-center w-full"> <div
key={orgNode.id}
className="flex flex-col items-center w-full"
>
<OrgNodeView <OrgNodeView
node={dNode} node={orgNode}
parentId={null} parentId={null}
onToggle={drawLines} onToggle={drawLines}
/> />
@@ -310,7 +321,7 @@ const ROLE_ORDER = [
"사원", "사원",
]; ];
function getRankWeight(u: UserWithPath) { function getRankWeight(u: UserSummary) {
const role = u.position || ""; const role = u.position || "";
let idx = ROLE_ORDER.indexOf(role); let idx = ROLE_ORDER.indexOf(role);
if (idx === -1) idx = 99; if (idx === -1) idx = 99;
@@ -328,7 +339,7 @@ function OrgNodeView({
onToggle: () => void; onToggle: () => void;
}) { }) {
const [collapsed, setCollapsed] = React.useState(false); const [collapsed, setCollapsed] = React.useState(false);
const myId = `node-${node.level}-${node.name.replace(/\s/g, "")}`; const myId = `node-${node.level}-${node.id}`;
const toggle = () => { const toggle = () => {
setCollapsed(!collapsed); setCollapsed(!collapsed);
@@ -345,10 +356,9 @@ function OrgNodeView({
(a, b) => getRankWeight(a) - getRankWeight(b), (a, b) => getRankWeight(a) - getRankWeight(b),
); );
const isVerticalChildren = node.level >= 1; // Children of Level 1+ are vertical const isVerticalChildren = node.level >= 1;
const isVerticallyStacked = node.level >= 1; // Level 1+ are vertically stacked inside parent const isVerticallyStacked = node.level >= 1;
// 하위 조직이 모두 말단(Leaf) 조직일 경우, 부모 박스 내부에 회색 그룹으로 묶어서(임베딩) 표시합니다.
const embedChildren = const embedChildren =
node.children.length > 0 && node.children.length > 0 &&
node.children.every((c) => c.children.length === 0); node.children.every((c) => c.children.length === 0);
@@ -378,14 +388,18 @@ function OrgNodeView({
> >
<span>{node.name}</span> <span>{node.name}</span>
<span className="text-slate-400 font-normal text-xs ml-4"> <span className="text-slate-400 font-normal text-xs ml-4">
({node.totalCount}) ({node.totalCount || node.members.length})
</span> </span>
</button> </button>
{!collapsed && membersToShow.length > 0 && ( {!collapsed && membersToShow.length > 0 && (
<div className="p-1.5 pt-0 grid grid-cols-2 gap-1 w-full"> <div className="p-1.5 pt-0 grid grid-cols-2 gap-1 w-full">
{membersToShow.map((m) => ( {membersToShow.map((m) => (
<MemberCard key={m.id} member={m} /> <MemberCard
key={m.id}
member={m}
companyCode={node.companyCode}
/>
))} ))}
</div> </div>
)} )}
@@ -398,19 +412,23 @@ function OrgNodeView({
); );
return ( return (
<div <div
key={child.name} key={child.id}
className="bg-slate-50 border border-slate-200 rounded-lg p-1.5 flex flex-col gap-1.5 w-full" className="bg-slate-50 border border-slate-200 rounded-lg p-1.5 flex flex-col gap-1.5 w-full"
> >
<div className="text-[11px] font-bold text-slate-600 flex justify-between px-1"> <div className="text-[11px] font-bold text-slate-600 flex justify-between px-1">
<span>{child.name}</span> <span>{child.name}</span>
<span className="text-slate-400 font-normal"> <span className="text-slate-400 font-normal">
({child.totalCount}) ({child.totalCount || child.members.length})
</span> </span>
</div> </div>
{childMembers.length > 0 && ( {childMembers.length > 0 && (
<div className="grid grid-cols-2 gap-1 w-full"> <div className="grid grid-cols-2 gap-1 w-full">
{childMembers.map((m) => ( {childMembers.map((m) => (
<MemberCard key={m.id} member={m} /> <MemberCard
key={m.id}
member={m}
companyCode={child.companyCode}
/>
))} ))}
</div> </div>
)} )}
@@ -431,7 +449,7 @@ function OrgNodeView({
> >
{node.children.map((c) => ( {node.children.map((c) => (
<OrgNodeView <OrgNodeView
key={c.name} key={c.id}
node={c} node={c}
parentId={myId} parentId={myId}
onToggle={onToggle} onToggle={onToggle}
@@ -443,9 +461,12 @@ function OrgNodeView({
); );
} }
function MemberCard({ member }: { member: UserWithPath }) { function MemberCard({
member,
companyCode,
}: { member: UserSummary; companyCode?: string }) {
const coColor = (() => { const coColor = (() => {
const c = (member.companyCode || "").toLowerCase(); const c = (companyCode || member.companyCode || "").toLowerCase();
if (c.includes("hanmac")) return "bg-[#1E3A8A] text-white border-[#1E3A8A]"; if (c.includes("hanmac")) return "bg-[#1E3A8A] text-white border-[#1E3A8A]";
if (c.includes("saman")) return "bg-[#047857] text-white border-[#047857]"; if (c.includes("saman")) return "bg-[#047857] text-white border-[#047857]";
if (c.includes("ptc")) return "bg-[#C2410C] text-white border-[#C2410C]"; if (c.includes("ptc")) return "bg-[#C2410C] text-white border-[#C2410C]";

View File

@@ -828,6 +828,7 @@ plane = "ADMIN PLANE"
subtitle = "Manage your organization" subtitle = "Manage your organization"
[ui.admin.nav] [ui.admin.nav]
org_chart = "Org Chart"
api_keys = "API Keys" api_keys = "API Keys"
audit_logs = "Audit Logs" audit_logs = "Audit Logs"
auth_guard = "Auth Guard" auth_guard = "Auth Guard"

View File

@@ -830,6 +830,7 @@ plane = "ADMIN PLANE"
subtitle = "Manage your organization" subtitle = "Manage your organization"
[ui.admin.nav] [ui.admin.nav]
org_chart = "조직도"
api_keys = "API 키" api_keys = "API 키"
audit_logs = "감사 로그" audit_logs = "감사 로그"
auth_guard = "인증 가드" auth_guard = "인증 가드"

View File

@@ -829,6 +829,7 @@ plane = ""
subtitle = "" subtitle = ""
[ui.admin.nav] [ui.admin.nav]
org_chart = ""
api_keys = "" api_keys = ""
audit_logs = "" audit_logs = ""
auth_guard = "" auth_guard = ""

View File

@@ -599,16 +599,21 @@ func main() {
KetoService: ketoService, KetoService: ketoService,
}) })
requireAdmin := middleware.RequireRole(middleware.RBACConfig{ requireAdmin := middleware.RequireRole(middleware.RBACConfig{
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin}, AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin},
AuthHandler: authHandler, AuthHandler: authHandler,
KetoService: ketoService, KetoService: ketoService,
})
requireAnyUser := middleware.RequireRole(middleware.RBACConfig{
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser},
AuthHandler: authHandler,
KetoService: ketoService,
}) })
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
// Tenant Management (Mixed roles, handler filters results) // Tenant Management (Mixed roles, handler filters results)
admin.Get("/tenants", requireAdmin, tenantHandler.ListTenants) admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant) admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
admin.Delete("/tenants/bulk", requireSuperAdmin, tenantHandler.DeleteTenantsBulk) admin.Delete("/tenants/bulk", requireSuperAdmin, tenantHandler.DeleteTenantsBulk)
admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant) admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant)
@@ -668,9 +673,8 @@ func main() {
relyingPartyHandler.Delete) relyingPartyHandler.Delete)
// Admin User Management // Admin User Management
admin.Get("/users", requireAdmin, userHandler.ListUsers) admin.Get("/users", requireAnyUser, userHandler.ListUsers)
admin.Get("/users/export", userHandler.ExportUsersCSV) // Removed requireAdmin to handle mock role in query param admin.Get("/users/export", userHandler.ExportUsersCSV) // Removed requireAdmin to handle mock role in query param admin.Post("/users", requireAdmin, userHandler.CreateUser)
admin.Post("/users", requireAdmin, userHandler.CreateUser)
admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers) admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers)
admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers) admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers)
admin.Delete("/users/bulk", requireAdmin, userHandler.BulkDeleteUsers) admin.Delete("/users/bulk", requireAdmin, userHandler.BulkDeleteUsers)

View File

@@ -6,7 +6,6 @@ import (
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils" "baron-sso-backend/internal/utils"
"errors" "errors"
"log/slog"
"strings" "strings"
"time" "time"
@@ -114,16 +113,72 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
var err error var err error
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
role := ""
if profile != nil {
role = domain.NormalizeRole(profile.Role)
}
// If Tenant Admin, only list manageable tenants if role != domain.RoleSuperAdmin {
if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleTenantAdmin { // Not a super admin: Only return the entire tree(s) of the tenants they belong to
slog.Info("Listing manageable tenants for tenant admin", "userID", profile.ID) allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "")
tenants, err = h.Service.ListManageableTenants(c.Context(), profile.ID)
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to list manageable tenants: "+err.Error()) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
if profile != nil {
baseTenantIDs := []string{}
for _, t := range profile.ManageableTenants {
baseTenantIDs = append(baseTenantIDs, t.ID)
}
for _, t := range profile.JoinedTenants {
baseTenantIDs = append(baseTenantIDs, t.ID)
}
if profile.TenantID != nil {
baseTenantIDs = append(baseTenantIDs, *profile.TenantID)
}
// Try to find by companyCode if needed
if profile.CompanyCode != "" {
for _, t := range allTenants {
if strings.EqualFold(t.Slug, profile.CompanyCode) {
baseTenantIDs = append(baseTenantIDs, t.ID)
}
}
}
parentMap := make(map[string]string)
for _, t := range allTenants {
if t.ParentID != nil {
parentMap[t.ID] = *t.ParentID
}
}
findRoot := func(id string) string {
curr := id
for {
p, exists := parentMap[curr]
if !exists || p == "" {
break
}
curr = p
}
return curr
}
roots := make(map[string]bool)
for _, id := range baseTenantIDs {
roots[findRoot(id)] = true
}
// Filter tenants that belong to the same tree family
for _, t := range allTenants {
if roots[findRoot(t.ID)] {
tenants = append(tenants, t)
}
}
}
total = int64(len(tenants)) total = int64(len(tenants))
// Apply basic pagination if needed (optional for usually small number of manageable tenants)
if offset < len(tenants) { if offset < len(tenants) {
end := offset + limit end := offset + limit
if end > len(tenants) { if end > len(tenants) {
@@ -751,8 +806,8 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided") return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
} }
// Permission check: Super Admin can delete anything. // Permission check: Super Admin can delete anything.
// Tenant Admin should theoretically only delete manageable sub-tenants, // Tenant Admin should theoretically only delete manageable sub-tenants,
// but currently bulk delete is intended for Super Admin. // but currently bulk delete is intended for Super Admin.
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin { if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin {

View File

@@ -193,15 +193,25 @@ func TestTenantHandler_ListTenants(t *testing.T) {
UserRepo: mockUserRepo, UserRepo: mockUserRepo,
} }
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants) app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{ tenants := []domain.Tenant{
{ID: "t1", Name: "Tenant A", Slug: "slug-a"}, {ID: "t1", Name: "Tenant A", Slug: "slug-a"},
{ID: "t2", Name: "Tenant B", Slug: "slug-b"}, {ID: "t2", Name: "Tenant B", Slug: "slug-b"},
} }
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(2), nil)
mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"slug-a", "slug-b"}). // Mocking for the new allTenants check in ListTenants
Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil) mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
mockUserRepo.On("CountByCompanyCodes", mock.Anything, mock.Anything).
Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil).Maybe()
mockUserRepo.On("CountByTenantIDs", mock.Anything, mock.Anything).
Return(map[string]int64{}, nil).Maybe()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req) resp, _ := app.Test(req)

View File

@@ -100,12 +100,19 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
// [New] Manageable Tenants Map for efficient lookup // [New] Manageable Tenants Map for efficient lookup
manageableSlugs := make(map[string]bool) manageableSlugs := make(map[string]bool)
if requesterRole == domain.RoleTenantAdmin { if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile != nil { if profile != nil {
var baseTenantIDs []string
for _, t := range profile.ManageableTenants { for _, t := range profile.ManageableTenants {
manageableSlugs[strings.ToLower(t.Slug)] = true manageableSlugs[strings.ToLower(t.Slug)] = true
manageableSlugs[strings.ToLower(t.ID)] = true // Add ID as well manageableSlugs[strings.ToLower(t.ID)] = true
baseTenantIDs = append(baseTenantIDs, t.ID)
}
for _, t := range profile.JoinedTenants {
manageableSlugs[strings.ToLower(t.Slug)] = true
manageableSlugs[strings.ToLower(t.ID)] = true
baseTenantIDs = append(baseTenantIDs, t.ID)
} }
// Include primary tenant slug if not already there // Include primary tenant slug if not already there
if profile.CompanyCode != "" { if profile.CompanyCode != "" {
@@ -113,6 +120,47 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
} }
if profile.TenantID != nil { if profile.TenantID != nil {
manageableSlugs[strings.ToLower(*profile.TenantID)] = true manageableSlugs[strings.ToLower(*profile.TenantID)] = true
baseTenantIDs = append(baseTenantIDs, *profile.TenantID)
}
// Expand manageableSlugs to the entire tenant tree (root + all descendants)
if h.TenantService != nil && len(baseTenantIDs) > 0 {
allTenants, _, err := h.TenantService.ListTenants(c.Context(), 10000, 0, "")
if err == nil {
parentMap := make(map[string]string)
for _, t := range allTenants {
if t.ParentID != nil {
parentMap[t.ID] = *t.ParentID
}
}
// Function to find the root of any given tenant
findRoot := func(id string) string {
curr := id
for {
p, exists := parentMap[curr]
if !exists || p == "" {
break
}
curr = p
}
return curr
}
// Collect root IDs for all base tenants
roots := make(map[string]bool)
for _, id := range baseTenantIDs {
roots[findRoot(id)] = true
}
// If a tenant shares a root with any base tenant, it's in the same tree family
for _, t := range allTenants {
if roots[findRoot(t.ID)] {
manageableSlugs[strings.ToLower(t.Slug)] = true
manageableSlugs[strings.ToLower(t.ID)] = true
}
}
}
} }
} }
} }
@@ -137,8 +185,8 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
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"))
// Tenant Admin filtering // Tenant Admin & Member filtering
if requesterRole == domain.RoleTenantAdmin { if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin {
if !manageableSlugs[compCode] && !manageableSlugs[tID] { if !manageableSlugs[compCode] && !manageableSlugs[tID] {
continue continue
} }
@@ -194,6 +242,15 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
// 2. Fallback to Local DB if Kratos is down // 2. Fallback to Local DB if Kratos is down
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err) slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
// If requester is not Super Admin, we should technically filter by manageable slugs in DB too.
// For simplicity in fallback, if tenantSlug is empty we default to their primary company code.
if (requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin) && tenantSlug == "" {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile != nil && profile.CompanyCode != "" {
tenantSlug = profile.CompanyCode
}
}
// Fetch from UserRepo // Fetch from UserRepo
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantSlug) users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantSlug)
if err != nil { if err != nil {

View File

@@ -963,6 +963,7 @@ plane = "Admin Plane"
subtitle = "Manage tenants, policies, and operators" subtitle = "Manage tenants, policies, and operators"
[ui.admin.nav] [ui.admin.nav]
org_chart = "Org Chart"
api_keys = "API Keys" api_keys = "API Keys"
audit_logs = "Audit Logs" audit_logs = "Audit Logs"
auth_guard = "Auth Guard" auth_guard = "Auth Guard"

View File

@@ -296,6 +296,7 @@ plane = "Admin Plane"
subtitle = "관리 및 정책 운영" subtitle = "관리 및 정책 운영"
[ui.admin.nav] [ui.admin.nav]
org_chart = "조직도"
api_keys = "API 키" api_keys = "API 키"
audit_logs = "감사 로그" audit_logs = "감사 로그"
auth_guard = "인증 가드" auth_guard = "인증 가드"
@@ -1362,6 +1363,7 @@ plane = "Admin Plane"
subtitle = "관리 및 정책 운영" subtitle = "관리 및 정책 운영"
[ui.admin.nav] [ui.admin.nav]
org_chart = "조직도"
api_keys = "API 키" api_keys = "API 키"
audit_logs = "감사 로그" audit_logs = "감사 로그"
auth_guard = "인증 가드" auth_guard = "인증 가드"

View File

@@ -171,6 +171,7 @@ plane = ""
subtitle = "" subtitle = ""
[ui.admin.nav] [ui.admin.nav]
org_chart = ""
api_keys = "" api_keys = ""
audit_logs = "" audit_logs = ""
auth_guard = "" auth_guard = ""
@@ -1237,6 +1238,7 @@ plane = ""
subtitle = "" subtitle = ""
[ui.admin.nav] [ui.admin.nav]
org_chart = ""
api_keys = "" api_keys = ""
audit_logs = "" audit_logs = ""
auth_guard = "" auth_guard = ""