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:
@@ -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">
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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]";
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = "인증 가드"
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = "인증 가드"
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user