1
0
forked from baron/baron-sso

조직도 기능 추가

This commit is contained in:
2026-04-10 11:38:47 +09:00
parent 6971b69b79
commit 5211842d47
28 changed files with 1845 additions and 447 deletions

View File

@@ -11,6 +11,7 @@ import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdmin
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantOrgChartPage } from "../features/tenants/routes/TenantOrgChartPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
@@ -40,6 +41,7 @@ export const router = createBrowserRouter(
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/org-chart", element: <TenantOrgChartPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{
path: "tenants/:tenantId",

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface CheckboxProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
onCheckedChange?: (checked: boolean | "indeterminate") => void;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, onCheckedChange, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onCheckedChange?.(e.target.checked);
};
return (
<input
type="checkbox"
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary",
className,
)}
onChange={handleChange}
ref={ref}
{...props}
/>
);
},
);
Checkbox.displayName = "Checkbox";
export { Checkbox };

View File

@@ -1,6 +1,13 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Download, FileText, Loader2, Upload } from "lucide-react";
import {
AlertCircle,
CheckCircle2,
Download,
FileText,
Loader2,
Upload,
} from "lucide-react";
import * as React from "react";
import { Button } from "../../../components/ui/button";
import {
@@ -13,7 +20,11 @@ import {
DialogTrigger,
} from "../../../components/ui/dialog";
import { toast } from "../../../components/ui/use-toast";
import { importOrgChart } from "../../../lib/adminApi";
import {
type ImportResult,
fetchImportProgress,
importOrgChart,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
interface OrgChartUploadModalProps {
@@ -27,52 +38,76 @@ export function OrgChartUploadModal({
}: OrgChartUploadModalProps) {
const [open, setOpen] = React.useState(false);
const [file, setFile] = React.useState<File | null>(null);
const [result, setResult] = React.useState<ImportResult | null>(null);
const [progressId, setProgressId] = React.useState<string | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const mutation = useMutation({
mutationFn: (file: File) => importOrgChart(tenantId, file),
onSuccess: () => {
toast.success(
t(
"msg.admin.org.import_success",
"조직도가 성공적으로 업로드되었습니다.",
),
);
setOpen(false);
mutationFn: ({ file, pid }: { file: File; pid: string }) =>
importOrgChart(tenantId, file, pid),
onSuccess: (data) => {
setResult(data);
setProgressId(null);
if (data.errors.length === 0) {
toast.success(
t(
"msg.admin.org.import_success",
"조직도가 성공적으로 업로드되었습니다.",
),
);
} else {
toast.error(
t(
"msg.admin.org.import_partial_success",
"일부 데이터 업로드 중 오류가 발생했습니다.",
),
);
}
onSuccess?.();
},
onError: (error: AxiosError<{ error?: string }>) => {
setProgressId(null);
toast.error(t("msg.admin.org.import_error", "조직도 업로드 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
const { data: progressData } = useQuery({
queryKey: ["importProgress", progressId],
queryFn: () => fetchImportProgress(tenantId, progressId!),
enabled: !!progressId && mutation.isPending,
refetchInterval: 500,
});
const percent =
progressData && progressData.total > 0
? Math.round((progressData.current / progressData.total) * 100)
: 0;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
setResult(null);
}
};
const handleUpload = () => {
if (file) {
mutation.mutate(file);
const pid = Math.random().toString(36).substring(2, 15);
setProgressId(pid);
mutation.mutate({ file, pid });
}
};
const downloadTemplate = () => {
const headers = "email,name,organization,position,jobtitle,is_owner";
const example = `ceo@example.com,홍길동,경영진,대표이사,경영총괄,true
cto@example.com,이몽룡,기술부문,이사,기술총괄,true
user1@example.com,성춘향,기술부문/개발팀,팀원,프론트엔드 개발,false`;
const blob = new Blob(
[
`${headers}
${example}`,
],
{ type: "text/csv" },
);
const headers = "이메일,이름,소속,직급,직무,구분,그룹,디비젼,팀,셀";
const example = `test1@example.com,홍길동,한맥,수석,기획,팀장,전략그룹,기획실,인사팀,-
test2@example.com,이몽룡,삼안,선임,개발,팀원,기술본부,개발실,개발1팀,A셀`;
const blob = new Blob([`\uFEFF${headers}\n${example}`], {
type: "text/csv;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -82,14 +117,23 @@ ${example}`,
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
setFile(null);
setResult(null);
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Upload size={14} />
{t("ui.admin.org.import_btn", "조직도 임포트 (CSV)")}
{t("ui.admin.org.import_btn", "조직도 임포트 (CSV/XLSX)")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.org.import_title", "조직도 일괄 등록")}
@@ -97,64 +141,151 @@ ${example}`,
<DialogDescription>
{t(
"msg.admin.org.import_description",
"CSV 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.",
"CSV 또는 XLSX 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex justify-between items-center">
<Button
variant="ghost"
size="sm"
onClick={downloadTemplate}
className="gap-2"
>
<Download size={14} />
{t("ui.admin.org.download_template", "템플릿 다운로드")}
</Button>
<input
type="file"
accept=".csv"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
size="sm"
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button>
{!result ? (
<div className="space-y-4 py-4">
<div className="flex justify-between items-center">
<Button
variant="ghost"
size="sm"
onClick={downloadTemplate}
className="gap-2"
disabled={mutation.isPending}
>
<Download size={14} />
{t("ui.admin.org.download_template", "템플릿 다운로드")}
</Button>
<input
type="file"
accept=".csv, .xlsx, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
disabled={mutation.isPending}
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
size="sm"
disabled={mutation.isPending}
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button>
</div>
{file && (
<div className="rounded-lg border p-4 bg-muted/20 flex flex-col gap-3">
<div className="flex items-center gap-3">
<FileText className="text-primary" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{file.name}</div>
<div className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(1)} KB
</div>
</div>
</div>
{mutation.isPending && progressId && (
<div className="w-full mt-2 animate-in fade-in slide-in-from-bottom-2 duration-500">
<div className="flex justify-between text-xs mb-1 font-medium text-muted-foreground">
<span> ...</span>
<span>
{percent}% ({progressData?.current || 0} /{" "}
{progressData?.total || 0})
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden relative">
<div
className="bg-primary h-full rounded-full transition-all duration-300 ease-out absolute top-0 left-0"
style={{ width: `${Math.max(5, percent)}%` }}
/>
</div>
</div>
)}
</div>
)}
</div>
{file && (
<div className="rounded-lg border p-4 bg-muted/20 flex items-center gap-3">
<FileText className="text-primary" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{file.name}</div>
<div className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(1)} KB
) : (
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-primary/5 border border-primary/10">
<div className="text-sm font-medium text-muted-foreground">
</div>
<div className="text-2xl font-bold">{result.totalRows}</div>
</div>
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/10">
<div className="text-sm font-medium text-muted-foreground">
</div>
<div className="text-2xl font-bold text-green-600">
{result.processed}
</div>
</div>
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/10">
<div className="text-sm font-medium text-muted-foreground">
/
</div>
<div className="text-xl font-bold text-blue-600">
{result.userCreated} / {result.userUpdated}
</div>
</div>
<div className="p-4 rounded-lg bg-orange-500/5 border border-orange-500/10">
<div className="text-sm font-medium text-muted-foreground">
()
</div>
<div className="text-2xl font-bold text-orange-600">
{result.tenantCreated}
</div>
</div>
</div>
)}
</div>
{result.errors.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
<AlertCircle size={16} />
({result.errors.length})
</div>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 bg-destructive/5 text-xs font-mono space-y-1">
{result.errors.map((err, idx) => (
<div
key={idx}
className="text-destructive border-b border-destructive/10 pb-1 last:border-0"
>
{err}
</div>
))}
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button
onClick={handleUpload}
disabled={!file || mutation.isPending}
className="w-full sm:w-auto"
>
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.org.start_import", "임포트 시작")}
</Button>
{!result ? (
<Button
onClick={handleUpload}
disabled={!file || mutation.isPending}
className="w-full sm:w-auto relative"
>
{mutation.isPending ? (
<>
<Loader2 size={16} className="mr-2 animate-spin" />
({percent}%)
</>
) : (
t("ui.admin.org.start_import", "임포트 시작")
)}
</Button>
) : (
<Button onClick={() => setOpen(false)} className="w-full sm:w-auto">
{t("ui.common.close", "닫기")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -13,7 +13,7 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
import { Checkbox } from "../../../components/ui/checkbox";
import {
Table,
TableBody,
@@ -25,13 +25,17 @@ import {
import {
type TenantSummary,
deleteTenant,
deleteTenantsBulk,
fetchMe,
fetchTenants,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
function TenantListPage() {
const navigate = useNavigate();
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
@@ -41,7 +45,6 @@ function TenantListPage() {
React.useEffect(() => {
if (profile?.role === "tenant_admin") {
const manageableCount = profile.manageableTenants?.length ?? 0;
// If only 1 in array, OR array is empty but we have a primary tenantId
if (
(manageableCount === 1 || manageableCount === 0) &&
profile.tenantId
@@ -67,6 +70,14 @@ function TenantListPage() {
},
});
const deleteBulkMutation = useMutation({
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
onSuccess: () => {
setSelectedIds([]);
query.refetch();
},
});
if (
profile &&
profile.role !== "super_admin" &&
@@ -84,7 +95,6 @@ function TenantListPage() {
);
}
// While redirecting (only if exactly one manageable tenant)
if (
profile?.role === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) <= 1
@@ -101,8 +111,40 @@ function TenantListPage() {
const tenants = query.data?.items ?? [];
// [New] Find a primary COMPANY_GROUP tenant to act as the root for matrix org charts
const rootTenant = tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(tenants.map((t) => t.id));
} else {
setSelectedIds([]);
}
};
const handleSelect = (id: string, checked: boolean) => {
if (checked) {
setSelectedIds((prev) => [...prev, id]);
} else {
setSelectedIds((prev) => prev.filter((i) => i !== id));
}
};
const handleDeleteBulk = () => {
if (selectedIds.length === 0) return;
if (
!window.confirm(
t(
"msg.admin.tenants.delete_bulk_confirm",
"선택한 {{count}}개 테넌트를 삭제할까요?",
{ count: selectedIds.length },
),
)
) {
return;
}
deleteBulkMutation.mutate(selectedIds);
};
const rootTenant =
tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
const handleDelete = (tenantId: string, tenantName: string) => {
if (
@@ -134,16 +176,34 @@ function TenantListPage() {
</p>
</div>
<div className="flex items-center gap-2">
{/* [New] Add Upload Modal to global list page, visible to Super Admin */}
<RoleGuard roles={["super_admin"]}>
{rootTenant && (
<OrgChartUploadModal
tenantId={rootTenant.id}
onSuccess={() => query.refetch()}
/>
{selectedIds.length > 0 && (
<Button
variant="destructive"
onClick={handleDeleteBulk}
disabled={deleteBulkMutation.isPending}
className="gap-2"
>
<Trash2 size={16} />
{t("ui.admin.tenants.delete_selected", "선택 삭제")} (
{selectedIds.length})
</Button>
)}
</RoleGuard>
<RoleGuard roles={["super_admin"]}>
<OrgChartUploadModal
tenantId={rootTenant?.id || "root"}
onSuccess={() => query.refetch()}
/>
</RoleGuard>
<Button asChild variant="outline" className="gap-2">
<Link to="/tenants/org-chart">
{t("ui.admin.tenants.view_org_chart", "전체 조직도 보기")}
</Link>
</Button>
<Button
variant="outline"
onClick={() => query.refetch()}
@@ -191,6 +251,17 @@ function TenantListPage() {
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[40px]">
<Checkbox
checked={
tenants.length > 0 &&
selectedIds.length === tenants.length
}
onCheckedChange={(checked) =>
handleSelectAll(!!checked)
}
/>
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
@@ -217,7 +288,7 @@ function TenantListPage() {
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={7}>
<TableCell colSpan={8}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
@@ -225,7 +296,7 @@ function TenantListPage() {
{!query.isLoading && tenants.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
colSpan={8}
className="text-center py-8 text-muted-foreground"
>
{t(
@@ -237,6 +308,14 @@ function TenantListPage() {
)}
{tenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(tenant.id)}
onCheckedChange={(checked) =>
handleSelect(tenant.id, !!checked)
}
/>
</TableCell>
<TableCell className="font-semibold">
{tenant.name}
</TableCell>

View File

@@ -0,0 +1,417 @@
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft } from "lucide-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import { type UserSummary, fetchUsers } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type UserWithPath = UserSummary & { _path: { level: number; name: string }[] };
interface OrgNode {
name: string;
level: number;
members: UserWithPath[];
subData: UserWithPath[];
children: OrgNode[];
totalCount?: number;
}
export function TenantOrgChartPage() {
const [selectedDept, setSelectedDept] = React.useState<string>("전체");
const containerRef = React.useRef<HTMLDivElement>(null);
const [lines, setLines] = React.useState<
{
x1: number;
y1: number;
x2: number;
y2: number;
key: string;
path: string;
}[]
>([]);
const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 });
const query = useQuery({
queryKey: ["users", { limit: 5000, offset: 0 }],
queryFn: () => fetchUsers(5000, 0),
});
const users = React.useMemo(() => {
if (!query.data?.items) return [];
return query.data.items
.filter((u) => u.status === "active")
.map((u) => {
const parts = (u.department || "").split("/").filter(Boolean);
return {
...u,
_path: parts.map((name, i) => ({ level: i, name })),
};
});
}, [query.data]);
const depts = React.useMemo(() => {
const s = new Set<string>();
for (const u of users) {
if (u._path[0]) s.add(u._path[0].name);
}
return Array.from(s).sort();
}, [users]);
React.useEffect(() => {
if (selectedDept !== "전체" && !depts.includes(selectedDept)) {
setSelectedDept("전체");
}
}, [selectedDept, depts]);
const buildHierarchy = (data: UserWithPath[], depth: number): OrgNode[] => {
if (!data.length) return [];
const map: Record<string, OrgNode> = {};
const groups: OrgNode[] = [];
for (const m of data) {
const step = m._path[depth];
if (!step) continue;
if (!map[step.name]) {
map[step.name] = {
name: step.name,
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) => ({
...g,
children: buildHierarchy(g.subData, depth + 1),
}));
};
const calculateTotalCount = (node: OrgNode): number => {
let count = node.members.length;
for (const c of node.children) {
count += calculateTotalCount(c);
}
node.totalCount = count;
return count;
};
const drawLines = React.useCallback(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const rect = container.getBoundingClientRect();
const scrollTop = container.scrollTop;
const scrollLeft = container.scrollLeft;
const childBoxes = container.querySelectorAll("[data-parent]");
const newLines: any[] = [];
for (const box of Array.from(childBoxes)) {
const parentId = box.getAttribute("data-parent");
if (!parentId) continue;
const parent = document.getElementById(parentId);
if (!parent) continue;
const pRect = parent.getBoundingClientRect();
const cRect = box.getBoundingClientRect();
if (pRect.width === 0 || cRect.width === 0) continue;
const parentLevel = Number.parseInt(parent.getAttribute("data-level") || "0", 10);
if (parentLevel === 0) {
// Horizontal fork for Level 0 -> Level 1
const px = pRect.left + pRect.width / 2 - rect.left + scrollLeft;
const py = pRect.bottom - rect.top + scrollTop;
const cx = cRect.left + cRect.width / 2 - rect.left + scrollLeft;
const cy = cRect.top - rect.top + scrollTop;
const midY = py + (cy - py) / 2;
newLines.push({
key: `${parentId}->${box.id}`,
x1: px, y1: py, x2: cx, y2: cy,
path: `M ${px} ${py} L ${px} ${midY} L ${cx} ${midY} L ${cx} ${cy}`,
});
} else {
// Vertical spine for Level >= 1 -> Level >= 2
const spineX = pRect.left + 32 - rect.left + scrollLeft; // 32px indent from parent's left edge
const py = pRect.bottom - rect.top + scrollTop;
const cx = cRect.left - rect.left + scrollLeft; // Child's left edge
const cy = cRect.top + 24 - rect.top + scrollTop; // Middle of child's header
newLines.push({
key: `${parentId}->${box.id}`,
x1: spineX, y1: py, x2: cx, y2: cy,
path: `M ${spineX} ${py} L ${spineX} ${cy} L ${cx} ${cy}`,
});
}
}
setLines(newLines);
setSvgSize({
width: Math.max(container.scrollWidth, rect.width),
height: Math.max(container.scrollHeight, rect.height),
});
}, []);
React.useLayoutEffect(() => {
const timeout = setTimeout(drawLines, 150);
window.addEventListener("resize", drawLines);
return () => {
clearTimeout(timeout);
window.removeEventListener("resize", drawLines);
};
}, [drawLines, selectedDept, users]);
if (query.isLoading) {
return (
<div className="p-8 text-center text-muted-foreground"> ...</div>
);
}
const targetDepts = selectedDept === "전체" ? depts : [selectedDept];
const totalUsers = targetDepts.reduce((acc, d) => {
return acc + users.filter((u) => u._path[0]?.name === d).length;
}, 0);
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">
<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">
<Button variant="outline" size="icon" asChild className="h-8 w-8">
<Link to="/tenants">
<ChevronLeft size={16} />
</Link>
</Button>
<div>
<h2 className="text-xl font-bold text-slate-800"> </h2>
<p className="text-xs text-slate-500">
.
</p>
</div>
</div>
<div className="flex gap-2 overflow-x-auto max-w-2xl custom-scrollbar pb-1">
{["전체", ...depts].map((d) => (
<button
key={d}
type="button"
onClick={() => {
setSelectedDept(d);
setLines([]); // Reset lines during switch
}}
className={`whitespace-nowrap px-4 py-1.5 text-sm font-semibold rounded-full border transition-colors ${
selectedDept === d
? "bg-slate-800 text-white border-slate-800"
: "bg-white text-slate-600 border-slate-200 hover:bg-slate-100"
}`}
>
{d}
</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">
{totalUsers}
</div>
</div>
</header>
<div
className="flex-1 overflow-auto relative p-8 bg-[#f8fafc]"
ref={containerRef}
>
<svg
aria-hidden="true"
className="absolute top-0 left-0 pointer-events-none z-0"
style={{
width: svgSize.width ? `${svgSize.width}px` : "100%",
height: svgSize.height ? `${svgSize.height}px` : "100%"
}}
>
{lines.map((l) => (
<path
key={l.key}
d={l.path}
stroke="#cbd5e1"
strokeWidth="2"
fill="none"
strokeLinejoin="round"
/>
))}
</svg>
<div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-24">
{targetDepts.map((dName) => {
const dData = users.filter((u) => u._path[0]?.name === dName);
const hierarchy = buildHierarchy(dData, 0);
const dNode = hierarchy[0];
if (!dNode) return null;
calculateTotalCount(dNode);
return (
<div key={dName} className="flex flex-col items-center w-full">
<OrgNodeView
node={dNode}
parentId={null}
onToggle={drawLines}
/>
</div>
);
})}
</div>
</div>
</div>
);
}
// --------------------- Node Rendering --------------------- //
const ROLE_ORDER = [
"사장",
"부사장",
"전무",
"상무",
"이사",
"수석",
"책임",
"선임",
"주임",
"사원",
];
function getRankWeight(u: UserWithPath) {
const role = u.position || "";
let idx = ROLE_ORDER.indexOf(role);
if (idx === -1) idx = 99;
const isLeader = u.position?.endsWith("장") || u.jobTitle?.endsWith("장");
return (isLeader ? -100 : 0) + idx;
}
function OrgNodeView({
node,
parentId,
onToggle,
}: {
node: OrgNode;
parentId: string | null;
onToggle: () => void;
}) {
const [collapsed, setCollapsed] = React.useState(false);
const myId = `node-${node.level}-${node.name.replace(/\s/g, "")}`;
const toggle = () => {
setCollapsed(!collapsed);
setTimeout(onToggle, 100);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
toggle();
}
};
const membersToShow = [...node.members].sort(
(a, b) => getRankWeight(a) - getRankWeight(b),
);
const isVerticalChildren = node.level >= 1; // Children of Level 1+ are vertical
const isVerticallyStacked = node.level >= 1; // Level 1+ are vertically stacked inside parent
return (
<div className={`flex flex-col ${isVerticallyStacked ? "items-start w-full" : "items-center"} mb-3`}>
<div
id={myId}
data-parent={parentId || undefined}
data-level={node.level}
className={`bg-white border rounded-xl shadow-sm mb-4 flex flex-col transition-all shrink-0 ${
node.level === 0 ? "border-slate-800 border-t-4" : "border-slate-300"
} ${collapsed ? "opacity-80" : ""}`}
style={{ width: "fit-content", minWidth: "260px", maxWidth: "400px" }}
>
<div
role="button"
tabIndex={0}
className={`px-4 py-2 font-bold flex justify-between items-center cursor-pointer select-none hover:bg-slate-50 transition-colors rounded-t-xl outline-none focus-visible:ring-2 focus-visible:ring-primary ${
node.level === 0
? "text-slate-800 text-lg"
: "text-slate-700 text-sm"
}`}
onClick={toggle}
onKeyDown={handleKeyDown}
>
<span>{node.name}</span>
<span className="text-slate-400 font-normal text-xs ml-4">
({node.totalCount})
</span>
</div>
{!collapsed && membersToShow.length > 0 && (
<div className="p-1.5 pt-0 grid grid-cols-2 gap-1 w-full">
{membersToShow.map((m) => (
<MemberCard key={m.id} member={m} />
))}
</div>
)}
</div>
{!collapsed && node.children.length > 0 && (
<div
className={`flex ${
isVerticalChildren
? "flex-col items-start pl-12 gap-4 w-full"
: "flex-row gap-10 justify-center items-start"
} relative`}
>
{node.children.map((c) => (
<OrgNodeView
key={c.name}
node={c}
parentId={myId}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
);
}
function MemberCard({ member }: { member: UserWithPath }) {
const coColor = (() => {
const c = (member.companyCode || "").toLowerCase();
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("ptc")) return "bg-[#C2410C] text-white border-[#C2410C]";
if (c.includes("baron")) return "bg-[#4338CA] text-white border-[#4338CA]";
return "bg-slate-600 text-white border-slate-700";
})();
const roleBadge = member.jobTitle && member.jobTitle !== member.position
? member.jobTitle
: (member.position?.endsWith("장") ? member.position : null);
return (
<div
className={`relative flex items-center px-1.5 h-[30px] rounded border shadow-sm overflow-hidden w-full leading-none ${coColor}`}
>
<div className="flex items-center gap-1 min-w-0 w-full">
<div className="flex items-baseline gap-1 truncate shrink-0">
<span className="font-bold text-[11px] whitespace-nowrap">{member.name}</span>
{member.position && member.position !== roleBadge && (
<span className="text-[10px] opacity-90 whitespace-nowrap font-medium">{member.position}</span>
)}
</div>
{roleBadge && (
<span className="bg-white/20 text-[9px] px-1 py-[1.5px] rounded-[3px] font-bold tracking-tight shrink-0 whitespace-nowrap ml-auto">
{roleBadge}
</span>
)}
</div>
</div>
);
}

View File

@@ -72,7 +72,7 @@ function UserListPage() {
Record<string, boolean>
>({});
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const limit = 50;
const limit = 1000;
const offset = (page - 1) * limit;
const { data: profile } = useQuery({
@@ -190,13 +190,14 @@ function UserListPage() {
const bulkDeleteMutation = useMutation({
mutationFn: bulkDeleteUsers,
onSuccess: () => {
onSuccess: (_, variables) => {
query.refetch();
setSelectedUserIds([]);
toast.success(
t(
"msg.admin.users.bulk.delete_success",
"선택한 사용자들이 삭제되었습니다.",
"{{count}}명의 사용자 삭제되었습니다.",
{ count: variables.length },
),
);
},
@@ -493,9 +494,18 @@ function UserListPage() {
<TableCell>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
checked={selectedUserIds.includes(user.id)}
onChange={() => toggleSelectUser(user.id)}
disabled={user.id === profile?.id}
title={
user.id === profile?.id
? t(
"msg.admin.users.self_delete_blocked",
"본인 계정은 삭제할 수 없습니다.",
)
: undefined
}
/>
</TableCell>
<TableCell>
@@ -559,9 +569,20 @@ function UserListPage() {
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
className="text-destructive hover:text-destructive disabled:opacity-30 disabled:cursor-not-allowed"
onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending}
disabled={
deleteMutation.isPending ||
user.id === profile?.id
}
title={
user.id === profile?.id
? t(
"msg.admin.users.self_delete_blocked",
"본인 계정은 삭제할 수 없습니다.",
)
: undefined
}
>
<Trash2 size={16} />
</Button>

View File

@@ -74,15 +74,14 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
};
const downloadTemplate = () => {
const headers = "email,name,phone,role,tenant,department,employee_id";
const headers = "email,name,phone,role,tenant,department,position,jobTitle,employee_id";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
const blob = new Blob(
[
`${headers}
${example}`,
`${headers}\n${example}`,
],
{ type: "text/csv" },
{ type: "text/csv;charset=utf-8;" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");

View File

@@ -34,6 +34,10 @@ export function parseUserCSV(text: string): BulkUserItem[] {
item.tenantSlug = value;
} else if (header === "department") {
item.department = value;
} else if (header === "position") {
item.position = value;
} else if (header === "jobtitle") {
item.jobTitle = value;
} else {
item.metadata[header] = value;
}

View File

@@ -139,6 +139,12 @@ export async function deleteTenant(tenantId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
}
export async function deleteTenantsBulk(ids: string[]) {
await apiClient.delete("/v1/admin/tenants/bulk", {
data: { ids },
});
}
export async function approveTenant(tenantId: string) {
const { data } = await apiClient.post<TenantSummary>(
`/v1/admin/tenants/${tenantId}/approve`,
@@ -260,11 +266,31 @@ export async function removeGroupMember(
);
}
export async function importOrgChart(tenantId: string, file: File) {
export interface ImportResult {
totalRows: number;
processed: number;
userCreated: number;
userUpdated: number;
tenantCreated: number;
errors: string[];
}
export async function fetchImportProgress(tenantId: string, progressId: string) {
const { data } = await apiClient.get<{ current: number; total: number }>(
`/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}`
);
return data;
}
export async function importOrgChart(tenantId: string, file: File, progressId?: string) {
const formData = new FormData();
formData.append("file", file);
const { data } = await apiClient.post(
`/v1/admin/tenants/${tenantId}/organization/import`,
const url = progressId
? `/v1/admin/tenants/${tenantId}/organization/import?progressId=${progressId}`
: `/v1/admin/tenants/${tenantId}/organization/import`;
const { data } = await apiClient.post<{ data: ImportResult }>(
url,
formData,
{
headers: {
@@ -272,7 +298,7 @@ export async function importOrgChart(tenantId: string, file: File) {
},
},
);
return data;
return data.data;
}
export type GroupRole = {
@@ -354,6 +380,7 @@ export type UserSummary = {
role: string;
status: string;
tenantSlug?: string;
companyCode?: string;
tenant?: TenantSummary;
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record<string, unknown>;

View File

@@ -159,7 +159,7 @@ scope = "관리 기능은 /admin 네임스페이스에서만 노출합니다."
[msg.admin.org]
hover_member_info = "마우스를 올리면 상세 정보를 확인할 수 있습니다."
import_description = "CSV 파일을 업로드하여 조직도를 일괄 등록합니다."
import_description = "CSV 또는 XLSX 파일을 업로드하여 조직도를 일괄 등록합니다. (필수 컬럼: 이메일, 이름)"
import_error = "조직도 임포트 중 오류가 발생했습니다."
import_success = "조직도가 성공적으로 임포트되었습니다."
@@ -187,6 +187,7 @@ total_tenants = "전체 테넌트 수"
[msg.admin.tenants]
approve_confirm = "이 테넌트를 승인하시겠습니까?"
approve_success = "테넌트가 승인되었습니다."
delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?"
delete_confirm = "테넌트 \"{{name}}\"를 삭제할까요?"
delete_success = "테넌트가 삭제되었습니다."
empty = "아직 등록된 테넌트가 없습니다."
@@ -280,6 +281,7 @@ edit_subtitle = "{{email}} 계정의 정보를 수정합니다."
not_found = "사용자를 찾을 수 없습니다."
update_error = "사용자 수정에 실패했습니다."
update_success = "사용자 정보가 수정되었습니다."
self_delete_blocked = "본인 계정은 삭제할 수 없습니다."
[msg.admin.users.detail.form]
field_required = "필수입니다."
@@ -877,6 +879,7 @@ user = "일반 사용자 (Tenant Member)"
[ui.admin.tenants]
add = "테넌트 추가"
delete_selected = "선택 삭제"
title = "테넌트 목록"
[ui.admin.tenants.admins]

View File

@@ -0,0 +1,53 @@
package main
import (
"context"
"fmt"
"log"
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
)
func main() {
kratosAdmin := service.NewKratosAdminService()
ctx := context.Background()
identities, err := kratosAdmin.ListIdentities(ctx)
if err != nil {
log.Fatalf("Failed to list identities: %v", err)
}
count := 0
for _, id := range identities {
traits := id.Traits
changed := false
if r, ok := traits["role"].(string); ok {
norm := domain.NormalizeRole(r)
if norm != r && norm == domain.RoleUser {
traits["role"] = norm
traits["grade"] = norm
changed = true
}
} else if g, ok := traits["grade"].(string); ok {
norm := domain.NormalizeRole(g)
if norm != g && norm == domain.RoleUser {
traits["role"] = norm
traits["grade"] = norm
changed = true
}
}
if changed {
_, err := kratosAdmin.UpdateIdentity(ctx, id.ID, traits, id.State)
if err != nil {
log.Printf("Failed to update %s: %v", id.ID, err)
} else {
count++
fmt.Printf("Updated %s\n", id.ID)
}
}
}
fmt.Printf("Total updated: %d\n", count)
}

View File

@@ -608,6 +608,7 @@ func main() {
// Tenant Management (Mixed roles, handler filters results)
admin.Get("/tenants", requireAdmin, tenantHandler.ListTenants)
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
admin.Delete("/tenants/bulk", requireSuperAdmin, tenantHandler.DeleteTenantsBulk)
admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant)
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
@@ -620,8 +621,10 @@ func main() {
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
org := admin.Group("/tenants/:tenantId/organization", requireAdmin)
org.Post("/import", orgChartHandler.ImportCSV) // CSV Import API
org := admin.Group("/tenants/:tenantId/organization")
org.Post("/import", orgChartHandler.ImportOrgChart) // Org Chart Bulk Import API
org.Get("/import/progress/:progressId", orgChartHandler.GetImportProgress) // Progress API
org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
org.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
org.Get("/:id", userGroupHandler.Get)

View File

@@ -11,14 +11,18 @@ require (
github.com/bwmarrin/snowflake v0.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/descope/go-sdk v1.7.0
github.com/go-jose/go-jose/v4 v4.1.3
github.com/go-redis/redis/v8 v8.11.5
github.com/gofiber/fiber/v2 v2.52.10
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.11.1
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.46.0
golang.org/x/oauth2 v0.34.0
github.com/testcontainers/testcontainers-go v0.40.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
github.com/xuri/excelize/v2 v2.10.1
golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.35.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
@@ -58,11 +62,11 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
@@ -96,30 +100,36 @@ 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/rogpeppe/go-internal v1.14.1 // 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/testcontainers/testcontainers-go v0.40.0 // indirect
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 // 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
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,5 +1,7 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/ClickHouse/ch-go v0.69.0 h1:nO0OJkpxOlN/eaXFj0KzjTz5p7vwP1/y3GN4qc5z/iM=
@@ -58,6 +60,8 @@ github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNv
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -110,6 +114,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -130,8 +136,8 @@ github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -161,12 +167,16 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
@@ -199,13 +209,16 @@ 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=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
@@ -226,6 +239,8 @@ 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=
@@ -239,6 +254,12 @@ github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7Fw
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
@@ -253,20 +274,30 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e h1:Ctm9yurWsg7aWwIpH9Bnap/IdSVxixymIb3MhiMEQQA=
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
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=
@@ -274,10 +305,10 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -298,15 +329,19 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@@ -315,8 +350,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -332,3 +375,5 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=

View File

@@ -85,6 +85,7 @@ type UserProfileResponse struct {
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
}
type UpdateUserRequest struct {

View File

@@ -21,13 +21,18 @@ const (
func NormalizeRole(role string) string {
normalized := strings.ToLower(strings.TrimSpace(role))
switch normalized {
case "tenant_member":
return RoleUser
case "admin":
// Legacy admin is treated as tenant admin for least-privilege compatibility.
return RoleTenantAdmin
default:
case RoleSuperAdmin, RoleTenantAdmin, RoleUser:
return normalized
case "tenant_member", "member":
return RoleUser
case "admin", "tenantadmin", "tenant-admin":
return RoleTenantAdmin
case "superadmin", "super-admin":
return RoleSuperAdmin
default:
// Default any other business title (팀장, 그룹장, etc.) to a regular user.
// These should be mapped to JobTitle or Position instead.
return RoleUser
}
}

View File

@@ -5213,11 +5213,18 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
}
}
// [New] Fetch manageable tenants for Tenant Admin
if profile.Role == domain.RoleTenantAdmin && h.TenantService != nil {
manageable, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID)
// [New] Fetch manageable and joined tenants
if h.TenantService != nil {
if profile.Role == domain.RoleTenantAdmin {
manageable, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID)
if err == nil {
profile.ManageableTenants = manageable
}
}
joined, err := h.TenantService.ListJoinedTenants(c.Context(), profile.ID)
if err == nil {
profile.ManageableTenants = manageable
profile.JoinedTenants = joined
}
}

View File

@@ -15,7 +15,7 @@ func NewOrgChartHandler(s service.OrgChartService) *OrgChartHandler {
return &OrgChartHandler{Service: s}
}
func (h *OrgChartHandler) ImportCSV(c *fiber.Ctx) error {
func (h *OrgChartHandler) ImportOrgChart(c *fiber.Ctx) error {
tenantID := c.Params("tenantId")
if tenantID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"})
@@ -32,10 +32,30 @@ func (h *OrgChartHandler) ImportCSV(c *fiber.Ctx) error {
}
defer f.Close()
if err := h.Service.ImportCSV(c.Context(), tenantID, f); err != nil {
slog.Error("Failed to import CSV", "error", err, "tenantID", tenantID)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
progressID := c.Query("progressId")
result, err := h.Service.ImportOrgChart(c.Context(), tenantID, f, file.Filename, progressID)
if err != nil {
slog.Error("Failed to import org chart", "error", err, "tenantID", tenantID, "filename", file.Filename)
// If we have a result even with error, return it
if result != nil {
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Import completed with errors",
"data": result,
})
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(fiber.Map{"message": "Import completed successfully"})
return c.JSON(fiber.Map{
"message": "Import completed",
"data": result,
})
}
func (h *OrgChartHandler) GetImportProgress(c *fiber.Ctx) error {
pid := c.Params("progressId")
if val, ok := service.ImportProgressCache.Load(pid); ok {
return c.JSON(val)
}
return c.JSON(fiber.Map{"current": 0, "total": 0})
}

View File

@@ -739,6 +739,36 @@ func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
var req struct {
IDs []string `json:"ids"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if len(req.IDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
}
// Permission check: Super Admin can delete anything.
// Tenant Admin should theoretically only delete manageable sub-tenants,
// but currently bulk delete is intended for Super Admin.
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "only super admin can perform bulk deletion")
}
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Tenants deleted successfully",
"count": len(req.IDs),
})
}
func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains {

View File

@@ -105,11 +105,23 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
if profile != nil {
for _, t := range profile.ManageableTenants {
manageableSlugs[strings.ToLower(t.Slug)] = true
manageableSlugs[strings.ToLower(t.ID)] = true // Add ID as well
}
// Include primary tenant slug if not already there
if profile.CompanyCode != "" {
manageableSlugs[strings.ToLower(profile.CompanyCode)] = true
}
if profile.TenantID != nil {
manageableSlugs[strings.ToLower(*profile.TenantID)] = true
}
}
}
var targetTenantID string
if tenantSlug != "" && h.TenantService != nil {
t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
if err == nil && t != nil {
targetTenantID = strings.ToLower(t.ID)
}
}
@@ -123,16 +135,17 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
// Tenant Admin filtering
if requesterRole == domain.RoleTenantAdmin {
if !manageableSlugs[compCode] {
if !manageableSlugs[compCode] && !manageableSlugs[tID] {
continue
}
}
// Dedicated tenantSlug filter
if tenantSlug != "" && !strings.EqualFold(compCode, tenantSlug) {
if tenantSlug != "" && !strings.EqualFold(compCode, tenantSlug) && tID != targetTenantID {
continue
}
@@ -261,15 +274,17 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
var req struct {
Email string `json:"email"`
LoginID string `json:"loginId"`
Password string `json:"password"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Metadata map[string]any `json:"metadata"`
Email string `json:"email"`
LoginID string `json:"loginId"`
Password string `json:"password"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
Metadata map[string]any `json:"metadata"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
@@ -321,6 +336,8 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
attributes := map[string]interface{}{
"department": req.Department,
"position": req.Position,
"jobTitle": req.JobTitle,
"affiliationType": "internal",
"companyCode": req.CompanyCode,
"grade": role,
@@ -454,6 +471,8 @@ type bulkUserItem struct {
Role string `json:"role"`
TenantSlug string `json:"tenantSlug"`
Department string `json:"department"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
Metadata map[string]any `json:"metadata"`
}
@@ -570,6 +589,8 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
attributes := map[string]interface{}{
"department": dept,
"position": strings.TrimSpace(item.Position),
"jobTitle": strings.TrimSpace(item.JobTitle),
"affiliationType": "internal",
"companyCode": tenantSlug,
"tenant_id": tItem.ID,
@@ -845,6 +866,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
Role *string `json:"role"`
CompanyCode *string `json:"companyCode"`
Department *string `json:"department"`
Position *string `json:"position"`
JobTitle *string `json:"jobTitle"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
@@ -879,6 +902,16 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
results := make([]map[string]any, 0, len(req.UserIDs))
for _, id := range req.UserIDs {
// [Safety] Cannot delete yourself
if id == requester.ID {
results = append(results, map[string]any{
"id": id,
"success": false,
"message": "cannot delete your own account for safety",
})
continue
}
identity, err := h.KratosAdmin.GetIdentity(c.Context(), id)
if err != nil {
results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"})
@@ -924,6 +957,12 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if req.Department != nil {
traits["department"] = *req.Department
}
if req.Position != nil {
traits["position"] = *req.Position
}
if req.JobTitle != nil {
traits["jobTitle"] = *req.JobTitle
}
state := identity.State
if req.Status != nil {
@@ -958,6 +997,12 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if req.Department != nil {
localUser.Department = *req.Department
}
if req.Position != nil {
localUser.Position = *req.Position
}
if req.JobTitle != nil {
localUser.JobTitle = *req.JobTitle
}
// Resolve TenantID if changing companyCode
if req.CompanyCode != nil && h.TenantService != nil {
@@ -1011,6 +1056,16 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
results := make([]map[string]any, 0, len(req.UserIDs))
for _, id := range req.UserIDs {
// [Safety] Cannot delete yourself
if id == requester.ID {
results = append(results, map[string]any{
"id": id,
"success": false,
"message": "cannot delete your own account for safety",
})
continue
}
identity, err := h.KratosAdmin.GetIdentity(c.Context(), id)
if err != nil {
results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"})
@@ -1093,6 +1148,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
Status *string `json:"status"`
CompanyCode *string `json:"companyCode"`
Department *string `json:"department"`
Position *string `json:"position"`
JobTitle *string `json:"jobTitle"`
Metadata map[string]any `json:"metadata"`
}
if err := c.BodyParser(&req); err != nil {
@@ -1176,6 +1233,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if req.Department != nil {
traits["department"] = strings.TrimSpace(*req.Department)
}
if req.Position != nil {
traits["position"] = strings.TrimSpace(*req.Position)
}
if req.JobTitle != nil {
traits["jobTitle"] = strings.TrimSpace(*req.JobTitle)
}
if req.Role != nil {
role := domain.NormalizeRole(*req.Role)
if role == "" {
@@ -1189,6 +1252,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"position": true, "jobTitle": true,
"affiliationType": true, "role": true, "tenant_id": true,
"custom_login_ids": true, "id": true,
}
@@ -1339,6 +1403,12 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
// [New] Check access scope before deletion
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
// [Safety] Cannot delete yourself
if requester != nil && userID == requester.ID {
return errorJSON(c, fiber.StatusForbidden, "cannot delete your own account for safety")
}
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
@@ -1407,14 +1477,16 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
Status: normalizeStatus(identity.State),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
Position: extractTraitString(traits, "position"),
JobTitle: extractTraitString(traits, "jobTitle"),
Metadata: make(domain.JSONMap),
CreatedAt: formatTime(identity.CreatedAt),
UpdatedAt: formatTime(identity.UpdatedAt),
}
// [New] Fetch all manageable tenants (for Multi-tenancy support)
// [New] Fetch all joined tenants (for Multi-tenancy support)
if h.TenantService != nil {
if joined, err := h.TenantService.ListManageableTenants(ctx, identity.ID); err == nil {
if joined, err := h.TenantService.ListJoinedTenants(ctx, identity.ID); err == nil {
summary.JoinedTenants = joined
}
}
@@ -1426,6 +1498,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"position": true, "jobTitle": true,
"affiliationType": true, "role": true, "tenant_id": true,
"custom_login_ids": true, "id": true,
}
@@ -1476,6 +1549,8 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
Status: normalizeStatus(identity.State),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
Position: extractTraitString(traits, "position"),
JobTitle: extractTraitString(traits, "jobTitle"),
AffiliationType: extractTraitString(traits, "affiliationType"),
CreatedAt: identity.CreatedAt,
UpdatedAt: identity.UpdatedAt,
@@ -1501,6 +1576,7 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"position": true, "jobTitle": true,
"affiliationType": true, "role": true, "tenant_id": true, "company_code": true,
"custom_login_ids": true, "id": true,
}

View File

@@ -4,6 +4,7 @@ import (
"baron-sso-backend/internal/domain"
"context"
"strings"
"time"
"gorm.io/gorm"
)
@@ -19,6 +20,7 @@ type TenantRepository interface {
AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error
List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error)
ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error)
DeleteBulk(ctx context.Context, ids []string) error
}
type tenantRepository struct {
@@ -121,3 +123,30 @@ func (r *tenantRepository) ListByType(ctx context.Context, tenantType string) ([
}
return tenants, nil
}
func (r *tenantRepository) DeleteBulk(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. Release slugs for all target tenants to allow reuse
suffix := "-deleted-" + time.Now().Format("20060102150405")
if err := tx.Model(&domain.Tenant{}).Where("id IN ?", ids).
Update("slug", gorm.Expr("slug || ?", suffix)).Error; err != nil {
return err
}
// 2. Soft delete tenants
if err := tx.Where("id IN ?", ids).Delete(&domain.Tenant{}).Error; err != nil {
return err
}
// 3. Also delete related UserGroups if any (Type USER_GROUP tenants have records in user_groups table)
if err := tx.Where("id IN ?", ids).Delete(&domain.UserGroup{}).Error; err != nil {
return err
}
return nil
})
}

View File

@@ -42,11 +42,30 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
}
func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
// Use Upsert logic: if email exists, update all fields
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
UpdateAll: true,
}).Save(user).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. Resolve email conflicts: If another user in the local DB has this email but a different ID,
// we must remove the old local record because Kratos is the source of truth for ID <-> Email mapping.
var existing domain.User
if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil {
if existing.ID != user.ID {
// Delete associated login IDs first to prevent FK constraint violation
if err := tx.Unscoped().Where("user_id = ?", existing.ID).Delete(&domain.UserLoginID{}).Error; err != nil {
return err
}
// Different ID holds this email locally. Hard delete the old record to avoid constraint violation.
if err := tx.Unscoped().Delete(&domain.User{}, "id = ?", existing.ID).Error; err != nil {
return err
}
}
}
// 2. Perform Upsert based on ID.
// In GORM v2, true upsert requires Create() with OnConflict on the primary key.
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,
}).Create(user).Error
})
}
func (r *userRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
@@ -175,13 +194,14 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
db := r.db.WithContext(ctx).Model(&domain.User{})
if companyCode != "" {
db = db.Where("company_code = ?", companyCode)
// [Matrix Fix] Match users either by their primary company code OR by the slug of the department they are attached to
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
Where("users.company_code = ? OR tenants.slug = ?", companyCode, companyCode)
}
if search != "" {
searchTerm := "%" + search + "%"
// Search in basic fields and metadata (PostgreSQL JSONB)
db = db.Where("(email LIKE ? OR name LIKE ? OR company_code LIKE ? OR metadata::text LIKE ?)",
db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.company_code LIKE ? OR users.metadata::text LIKE ?)",
searchTerm, searchTerm, searchTerm, searchTerm)
}
@@ -189,7 +209,7 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
return nil, 0, err
}
if err := db.Offset(offset).Limit(limit).Find(&users).Error; err != nil {
if err := db.Offset(offset).Limit(limit).Preload("Tenant").Find(&users).Error; err != nil {
return nil, 0, err
}

View File

@@ -94,7 +94,6 @@ func (m *MockKratosAdminServiceShared) GetIdentity(ctx context.Context, identity
}
return args.Get(0).(*KratosIdentity), args.Error(1)
}
func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
args := m.Called(ctx, identityID, traits, state)
if args.Get(0) == nil {
@@ -104,9 +103,18 @@ func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, ident
}
func (m *MockKratosAdminServiceShared) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
return m.Called(ctx, identityID, newPassword).Error(0)
args := m.Called(ctx, identityID, newPassword)
return args.Error(0)
}
func (m *MockKratosAdminServiceShared) DeleteIdentity(ctx context.Context, identityID string) error {
return m.Called(ctx, identityID).Error(0)
args := m.Called(ctx, identityID)
return args.Error(0)
}
func (m *MockKratosAdminServiceShared) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
args := m.Called(ctx, user, password)
return args.String(0), args.Error(1)
}

View File

@@ -3,19 +3,43 @@ package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/csv"
"fmt"
"io"
"log/slog"
"regexp"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/xuri/excelize/v2"
)
var whitespaceRegex = regexp.MustCompile(`\s+`)
var nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`)
type ProgressData struct {
Current int `json:"current"`
Total int `json:"total"`
}
var ImportProgressCache sync.Map
type ImportResult struct {
TotalRows int `json:"totalRows"`
Processed int `json:"processed"`
UserCreated int `json:"userCreated"`
UserUpdated int `json:"userUpdated"`
TenantCreated int `json:"tenantCreated"`
Errors []string `json:"errors"`
}
type OrgChartService interface {
ImportCSV(ctx context.Context, tenantID string, r io.Reader) error
ImportOrgChart(ctx context.Context, tenantID string, r io.Reader, filename string, progressID string) (*ImportResult, error)
}
type orgChartService struct {
@@ -42,364 +66,443 @@ func NewOrgChartService(
}
}
func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.Reader) error {
reader := csv.NewReader(r)
header, err := reader.Read()
if err != nil {
return fmt.Errorf("failed to read CSV header: %w", err)
}
func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r io.Reader, filename string, progressID string) (*ImportResult, error) {
result := &ImportResult{Errors: make([]string, 0)}
var allSheetsRecords [][][]string
var err error
// Map header columns (Support both English and Korean)
colMap := make(map[string]int)
for i, name := range header {
cleanName := strings.ToLower(strings.TrimSpace(name))
colMap[cleanName] = i
}
// Dynamic column detection for hierarchy
hierarchyCols := []string{"그룹", "디비젼", "팀", "셀"}
hierarchyIdx := make([]int, 0)
for _, col := range hierarchyCols {
if idx, ok := colMap[col]; ok {
hierarchyIdx = append(hierarchyIdx, idx)
if strings.HasSuffix(strings.ToLower(filename), ".xlsx") {
allSheetsRecords, err = s.readAllXLSXSheets(r)
} else {
csvRecords, csvErr := s.readCSV(r)
if csvErr == nil {
allSheetsRecords = [][][]string{csvRecords}
}
err = csvErr
}
if err != nil {
return nil, err
}
// Map English keys for core fields
fieldMapping := map[string][]string{
"email": {"email", "이메일"},
"name": {"name", "이름"},
"position": {"position", "직급"},
"jobtitle": {"jobtitle", "직무"},
"company": {"company", "소속"},
"is_owner": {"is_owner", "구분"},
"email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"},
"name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"},
"position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"},
"jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"},
"phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"},
"company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"},
"is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"},
}
var dataRows [][]string
actualMap := make(map[string]int)
for key, aliases := range fieldMapping {
for _, alias := range aliases {
if idx, ok := colMap[alias]; ok {
actualMap[key] = idx
found := false
var headerMap map[string]int
for sheetIdx, records := range allSheetsRecords {
for i, row := range records {
if len(row) < 2 { continue }
tempMap := make(map[string]int)
for j, cell := range row {
clean := s.cleanHeader(cell)
if clean != "" { tempMap[clean] = j }
}
emailIdx := s.findBestMatch(tempMap, fieldMapping["email"])
nameIdx := s.findBestMatch(tempMap, fieldMapping["name"])
if nameIdx != -1 && emailIdx == -1 {
for j, cell := range row {
c := s.cleanHeader(cell)
if strings.Contains(c, "mail") || strings.Contains(c, "계정") || strings.Contains(c, "id") || strings.Contains(c, "사번") {
emailIdx = j; break
}
}
}
if emailIdx != -1 && nameIdx != -1 {
dataRows = records[i+1:]
headerMap = tempMap
for key, aliases := range fieldMapping {
actualMap[key] = s.findBestMatch(tempMap, aliases)
}
if actualMap["email"] == -1 { actualMap["email"] = emailIdx }
found = true
slog.Info("Found header row", "sheet", sheetIdx, "row", i)
break
}
}
if found { break }
}
if !found {
return nil, fmt.Errorf("required columns (email/name) not found. please check your headers")
}
// [MH-OrgChart-Standalone Architecture]
// Hierarchy is explicitly ordered: 부서(part) -> 그룹(gr) -> 디비전(div) -> 팀(team) -> 셀(cell)
hierarchyLevels := [][]string{
{"department", "organization", "부서", "조직", "부서명", "조직명", "소속부서", "part", "파트", "본부", "실", "국"},
{"gr", "grp", "group", "그룹"},
{"div", "division", "디비젼", "디비전"},
{"team", "팀", "teal", "팀명"},
{"cell", "셀"},
}
hierarchyIdx := make([]int, 0)
for _, aliases := range hierarchyLevels {
idx := s.findBestMatch(headerMap, aliases)
hierarchyIdx = append(hierarchyIdx, idx) // Keep order, -1 means not found
}
// Path cache for hierarchy
pathCache := make(map[string]string)
result.TotalRows = len(dataRows)
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
slog.Error("Failed to read CSV record", "error", err)
continue
}
if progressID != "" {
ImportProgressCache.Store(progressID, ProgressData{Current: 0, Total: result.TotalRows})
defer ImportProgressCache.Delete(progressID)
}
email := strings.TrimSpace(record[actualMap["email"]])
name := strings.TrimSpace(record[actualMap["name"]])
position := strings.TrimSpace(record[actualMap["position"]])
jobTitle := strings.TrimSpace(record[actualMap["jobtitle"]])
companyName := strings.TrimSpace(record[actualMap["company"]])
// Determine if owner (e.g. "팀장", "그룹장", "센터장", "실장")
isOwner := false
if idx, ok := actualMap["is_owner"]; ok {
val := record[idx]
isOwner = strings.HasSuffix(val, "장") || strings.EqualFold(val, "true") || val == "1"
if tenantID == "root" || tenantID == "" {
t, _ := s.tenantRepo.FindBySlug(ctx, "root-group")
if t == nil {
tenantID = uuid.NewString()
_ = s.tenantRepo.Create(ctx, &domain.Tenant{ID: tenantID, Name: "Root Group", Slug: "root-group", Type: domain.TenantTypeCompanyGroup, Status: domain.TenantStatusActive})
result.TenantCreated++
} else {
tenantID = t.ID
}
}
if email == "" || name == "" {
continue
}
for rowIdx, record := range dataRows {
if len(record) == 0 { continue }
email := s.getVal(record, actualMap["email"])
name := s.getVal(record, actualMap["name"])
if email == "" || name == "" { continue }
// Extract domain from email
parts := strings.Split(email, "@")
domainName := ""
if len(parts) == 2 {
domainName = parts[1]
}
position := s.getVal(record, actualMap["position"])
jobTitle := s.getVal(record, actualMap["jobtitle"])
phone := s.normalizePhone(s.getVal(record, actualMap["phone"]))
// 1. Ensure Company Tenant Exists (The Core Requirement)
companyName := s.getVal(record, actualMap["company"])
if companyName == "" { companyName = "Main" }
companySlug := s.generateCompanySlug(companyName)
companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, domainName, pathCache)
companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, email, pathCache, result)
if err != nil {
slog.Error("Failed to ensure company tenant", "company", companyName, "error", err)
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Company fail: %v", rowIdx+2, err))
continue
}
// 2. Process Hierarchy (Build path from multiple columns under the Company)
// Build orgPath following the strict order: 부서 -> 그룹 -> 디비전 -> 팀 -> 셀
var orgParts []string
for _, idx := range hierarchyIdx {
val := strings.TrimSpace(record[idx])
if val != "" && val != "-" {
orgParts = append(orgParts, val)
val := s.getVal(record, idx)
if val != "" && val != "-" {
orgParts = append(orgParts, val)
}
}
orgPath := strings.Join(orgParts, "/")
leafID := companyTenantID // Default to company tenant
if orgPath != "" {
leafID, err = s.ensureOrgPath(ctx, companyTenantID, orgPath, pathCache)
leafID := companyTenantID
if orgPath != "" && orgPath != "-" {
// [Matrix Fix] Build hierarchy under the Root Tenant (tenantID), NOT the individual company.
// This allows departments like '총괄기획실' to be shared across multiple companies without duplication.
leafID, err = s.ensureOrgPath(ctx, tenantID, orgPath, pathCache, result)
if err != nil {
slog.Error("Failed to ensure hierarchy", "path", orgPath, "error", err)
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Hierarchy fail: %v", rowIdx+2, err))
continue
}
}
// 3. Ensure User exists in Kratos (Auto-create if missing)
isOwner := false
grade := "member"
if idx := actualMap["is_owner"]; idx != -1 && idx < len(record) {
grade = strings.TrimSpace(record[idx])
isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" ||
strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") ||
strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장")
}
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
if err != nil || kratosID == "" {
slog.Info("User not found in Kratos, auto-creating...", "email", email)
if (err != nil || kratosID == "") && phone != "" {
kratosID, _ = s.kratos.FindIdentityIDByIdentifier(ctx, phone)
}
if kratosID == "" {
brokerUser := &domain.BrokerUser{
Email: email,
Name: name,
Email: email, Name: name, PhoneNumber: phone,
Attributes: map[string]interface{}{
"affiliationType": "AFFILIATE",
"companyCode": companySlug,
"department": orgPath,
"grade": "member",
"affiliationType": "AFFILIATE", "companyCode": companySlug,
"department": orgPath, "grade": grade, "position": position,
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
},
}
// Default password for bulk import
newID, createErr := s.kratos.CreateUser(ctx, brokerUser, "baron1234!@#")
if createErr != nil {
slog.Error("Failed to auto-create user in Kratos", "email", email, "error", createErr)
kratosID, err = s.kratos.CreateUser(ctx, brokerUser, "baron1234!@#")
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: User creation failed: %v", rowIdx+2, err))
continue
}
kratosID = newID
result.UserCreated++
} else {
traits := map[string]interface{}{
"name": name, "companyCode": companySlug, "department": orgPath,
"grade": grade, "position": position, "affiliationType": "AFFILIATE",
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
}
if phone != "" {
traits["phone_number"] = phone
}
_, _ = s.kratos.UpdateIdentity(ctx, kratosID, traits, "active")
result.UserUpdated++
}
// 4. Update User in Local DB (Bind to Company Tenant)
user := &domain.User{
ID: kratosID,
Email: email,
Name: name,
Position: position,
JobTitle: jobTitle,
Department: orgPath,
TenantID: &companyTenantID, // 편입: 사용자의 메인 소속은 회사(COMPANY)
CompanyCode: companySlug,
AffiliationType: "AFFILIATE",
Status: "active",
UpdatedAt: time.Now(),
err = s.userRepo.Update(ctx, &domain.User{
ID: kratosID, Email: email, Name: name, Phone: phone, Position: position,
JobTitle: jobTitle, Department: orgPath,
TenantID: &leafID, // [Matrix Fix] Local DB points to Leaf Department, while CompanyCode points to the Legal Entity
CompanyCode: companySlug, AffiliationType: "AFFILIATE", Status: "active", UpdatedAt: time.Now(), Role: domain.RoleUser,
})
if err != nil {
slog.Error("Row update failed", "row", rowIdx+2, "email", email, "error", err)
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: DB Update fail: %v", rowIdx+2, err))
}
if err := s.userRepo.Update(ctx, user); err != nil {
slog.Error("Failed to update user in local DB", "email", email, "error", err)
continue
}
// 5. Sync Membership to Keto
if s.ketoOutboxRepo != nil {
// Add as member of the Company Tenant
// 1. [Redundant Assignment] Always assign to the Legal Company Tenant
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: companyTenantID,
Relation: "members",
Subject: "User:" + kratosID,
Namespace: "Tenant",
Object: companyTenantID,
Relation: "members",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
// Add as member of the specific Department unit (if exists)
// 2. [Redundant Assignment] ALSO assign to the Logical Department Tenant (if exists)
if leafID != companyTenantID {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
Relation: "members",
Subject: "User:" + kratosID,
Namespace: "Tenant",
Object: leafID,
Relation: "members",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
}
// If owner/leader, assign owner role to the leaf unit
// 3. Assign ownership if leader
if isOwner {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
Relation: "owners",
Subject: "User:" + kratosID,
Namespace: "Tenant",
Object: leafID,
Relation: "owners",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
}
}
result.Processed++
if progressID != "" && (result.Processed%5 == 0 || result.Processed == result.TotalRows) {
ImportProgressCache.Store(progressID, ProgressData{Current: result.Processed, Total: result.TotalRows})
}
}
return nil
return result, nil
}
func (s *orgChartService) cleanHeader(val string) string {
clean := strings.ToLower(whitespaceRegex.ReplaceAllString(val, ""))
clean = nonAlphaNumRegex.ReplaceAllString(clean, "")
return strings.TrimPrefix(clean, "\ufeff")
}
func (s *orgChartService) findBestMatch(tempMap map[string]int, aliases []string) int {
for _, alias := range aliases {
ca := s.cleanHeader(alias)
if idx, ok := tempMap[ca]; ok { return idx }
}
for cleaned, idx := range tempMap {
for _, alias := range aliases {
ca := s.cleanHeader(alias)
if len(ca) >= 2 && (strings.Contains(cleaned, ca) || strings.Contains(ca, cleaned)) { return idx }
}
}
return -1
}
func (s *orgChartService) getVal(record []string, idx int) string {
if idx == -1 || idx >= len(record) { return "" }
return strings.TrimSpace(record[idx])
}
func (s *orgChartService) normalizePhone(phone string) string {
normalized := strings.ReplaceAll(phone, "-", "")
normalized = strings.ReplaceAll(normalized, " ", "")
re := regexp.MustCompile(`[^0-9+]`)
normalized = re.ReplaceAllString(normalized, "")
if len(normalized) < 8 {
return ""
}
if strings.HasPrefix(normalized, "010") {
return "+82" + normalized[1:]
}
if strings.HasPrefix(normalized, "82") {
return "+" + normalized
}
if !strings.HasPrefix(normalized, "+") && len(normalized) >= 9 {
if strings.HasPrefix(normalized, "0") {
return "+82" + normalized[1:]
}
return "+82" + normalized
}
return normalized
}
func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) {
data, err := io.ReadAll(r)
if err != nil { return nil, err }
reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))))
reader.LazyQuotes = true
reader.FieldsPerRecord = -1
return reader.ReadAll()
}
func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) {
f, err := excelize.OpenReader(r)
if err != nil { return nil, err }
defer f.Close()
var allRecords [][][]string
for _, sheet := range f.GetSheetList() {
if rows, err := f.GetRows(sheet); err == nil { allRecords = append(allRecords, rows) }
}
return allRecords, nil
}
// [New] Maps korean names to official slugs
func (s *orgChartService) generateCompanySlug(name string) string {
n := strings.ToLower(strings.TrimSpace(name))
if strings.Contains(n, "한맥") || strings.Contains(n, "hanmac") {
return "hanmac"
n := strings.ToLower(whitespaceRegex.ReplaceAllString(name, ""))
slugs := map[string]string{
"한맥": "hanmac", "삼안": "saman", "장헌": "jangheon",
"ptc": "ptc", "피티씨": "ptc", "바론": "baron", "한라": "halla",
}
if strings.Contains(n, "삼안") || strings.Contains(n, "saman") {
return "saman"
for k, v := range slugs {
if strings.Contains(n, k) || strings.Contains(n, v) { return v }
}
if strings.Contains(n, "장헌") || strings.Contains(n, "jangheon") {
return "jangheon"
}
if strings.Contains(n, "평화") || strings.Contains(n, "ptc") {
return "ptc"
}
if strings.Contains(n, "바론") || strings.Contains(n, "baron") {
return "baron"
}
if strings.Contains(n, "한라") || strings.Contains(n, "halla") {
return "halla"
}
// Fallback for unknown companies
return "comp-" + uuid.NewString()[:8]
return utils.GenerateSlug(name)
}
// [New] Ensures the COMPANY tenant exists and binds the domain
func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootTenantID, name, slug, domainName string, cache map[string]string) (string, error) {
cacheKey := "company:" + slug
if id, ok := cache[cacheKey]; ok {
return id, nil
func isAlphaNumeric(s string) bool {
for _, r := range s {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { return false }
}
return true
}
func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name, slug, email string, cache map[string]string, res *ImportResult) (string, error) {
if rootID == "root" || rootID == "" {
// Auto-provision a root group if none is provided
rootSlug := "root-group"
t, _ := s.tenantRepo.FindBySlug(ctx, rootSlug)
if t == nil {
t = &domain.Tenant{ID: uuid.NewString(), Name: "Root Group", Slug: rootSlug, Type: domain.TenantTypeCompanyGroup, Status: domain.TenantStatusActive}
_ = s.tenantRepo.Create(ctx, t)
res.TenantCreated++
}
rootID = t.ID
}
tenant, err := s.tenantRepo.FindBySlug(ctx, slug)
if err != nil && !strings.Contains(err.Error(), "record not found") {
return "", err
cacheKey := "company:" + slug
if id, ok := cache[cacheKey]; ok { return id, nil }
tenant, _ := s.tenantRepo.FindBySlug(ctx, slug)
if tenant == nil {
tenant, _ = s.tenantRepo.FindByName(ctx, name)
}
if tenant == nil {
// Auto-create missing company tenant
slog.Info("Auto-creating missing company tenant", "name", name, "slug", slug)
tenant = &domain.Tenant{
ID: uuid.NewString(),
Name: name,
Slug: slug,
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
ParentID: &rootTenantID, // Bind to the root COMPANY_GROUP
}
if err := s.tenantRepo.Create(ctx, tenant); err != nil {
return "", err
}
// Sync hierarchy to Keto (Company belongs to Group)
tenant = &domain.Tenant{ID: uuid.NewString(), Name: name, Slug: slug, Type: domain.TenantTypeCompany, Status: domain.TenantStatusActive, ParentID: &rootID}
if err := s.tenantRepo.Create(ctx, tenant); err != nil { return "", err }
if s.ketoOutboxRepo != nil {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "parents",
Subject: "Tenant:" + rootTenantID,
Action: domain.KetoOutboxActionCreate,
})
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + rootID, Action: domain.KetoOutboxActionCreate})
}
res.TenantCreated++
}
// Ensure the email domain is registered to this company
if domainName != "" {
_ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainName, true)
}
domainPart := ""
if parts := strings.Split(email, "@"); len(parts) == 2 { domainPart = parts[1] }
if domainPart != "" { _ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true) }
cache[cacheKey] = tenant.ID
return tenant.ID, nil
}
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, path string, cache map[string]string) (string, error) {
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID, path string, cache map[string]string, res *ImportResult) (string, error) {
parts := strings.Split(path, "/")
currentParentID := rootTenantID
currentPath := ""
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
if part == "" || part == "-" { continue }
if currentPath == "" { currentPath = part } else { currentPath += "/" + part }
cacheKey := rootTenantID + ":" + currentPath
if id, ok := cache[cacheKey]; ok {
currentParentID = id; continue
}
if currentPath == "" {
currentPath = part
} else {
currentPath = currentPath + "/" + part
}
if id, ok := cache[currentPath]; ok {
currentParentID = id
continue
}
// Check DB if already exists
var existingID string
if s.userGroupRepo != nil {
groups, err := s.userGroupRepo.ListByTenantID(ctx, rootTenantID)
if err == nil {
for _, g := range groups {
// Match by name and parent
if g.Name == part && ((g.ParentID == nil && currentParentID == rootTenantID) || (g.ParentID != nil && *g.ParentID == currentParentID)) {
existingID = g.ID
break
}
if groups, err := s.userGroupRepo.ListByTenantID(ctx, rootTenantID); err == nil {
for _, g := range groups {
isTopMatch := (g.ParentID == nil && currentParentID == rootTenantID)
isSubMatch := (g.ParentID != nil && *g.ParentID == currentParentID)
if g.Name == part && (isTopMatch || isSubMatch) {
existingID = g.ID; break
}
}
}
if existingID == "" {
// Create new unit
unitID := uuid.NewString()
existingID = uuid.NewString()
groupSlug := fmt.Sprintf("ug-%s", existingID[:13])
// 1. Create Tenant (Type: USER_GROUP)
newTenant := &domain.Tenant{
ID: unitID,
Type: domain.TenantTypeUserGroup,
ParentID: &currentParentID,
Name: part,
Slug: fmt.Sprintf("ug-%s", unitID[:8]),
if err := s.tenantRepo.Create(ctx, &domain.Tenant{
ID: existingID,
Type: domain.TenantTypeUserGroup,
ParentID: &currentParentID,
Name: part,
Slug: groupSlug,
Status: domain.TenantStatusActive,
}
if err := s.tenantRepo.Create(ctx, newTenant); err != nil {
}); err != nil {
return "", err
}
var ugParentID *string
if currentParentID != rootTenantID {
pid := currentParentID
ugParentID = &pid
}
// 2. Create UserGroup metadata
newUserGroup := &domain.UserGroup{
ID: unitID,
TenantID: rootTenantID,
ParentID: &currentParentID,
Name: part,
if err := s.userGroupRepo.Create(ctx, &domain.UserGroup{
ID: existingID,
TenantID: rootTenantID,
ParentID: ugParentID,
Name: part,
UnitType: s.guessUnitType(i, len(parts)),
}
if err := s.userGroupRepo.Create(ctx, newUserGroup); err != nil {
}); err != nil {
return "", err
}
// 3. Sync Hierarchy to Keto via Outbox
if s.ketoOutboxRepo != nil {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: unitID,
Relation: "parents",
Subject: "Tenant:" + currentParentID,
Action: domain.KetoOutboxActionCreate,
})
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: existingID, Relation: "parents", Subject: "Tenant:" + currentParentID, Action: domain.KetoOutboxActionCreate})
}
existingID = unitID
res.TenantCreated++
}
cache[currentPath] = existingID
cache[cacheKey] = existingID
currentParentID = existingID
}
return currentParentID, nil
}
func (s *orgChartService) guessUnitType(index, total int) string {
if total == 1 {
return "Team"
}
if index == 0 {
return "Division"
}
if index == total-1 {
return "Team"
}
return "Department"
if total == 1 { return "Team" }
if index == 0 { return "Division" }
return "Team"
}

View File

@@ -0,0 +1,239 @@
package service
import (
"bytes"
"context"
"testing"
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/xuri/excelize/v2"
)
type mockTenantRepo struct {
mock.Mock
repository.TenantRepository
}
func (m *mockTenantRepo) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
args := m.Called(ctx, slug)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *mockTenantRepo) Create(ctx context.Context, tenant *domain.Tenant) error {
args := m.Called(ctx, tenant)
return args.Error(0)
}
func (m *mockTenantRepo) AddDomain(ctx context.Context, tenantID, domainName string, isPrimary bool) error {
args := m.Called(ctx, tenantID, domainName, isPrimary)
return args.Error(0)
}
type mockUserGroupRepo struct {
mock.Mock
repository.UserGroupRepository
}
func (m *mockUserGroupRepo) ListByTenantID(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
args := m.Called(ctx, tenantID)
return args.Get(0).([]domain.UserGroup), args.Error(1)
}
func (m *mockUserGroupRepo) Create(ctx context.Context, ug *domain.UserGroup) error {
args := m.Called(ctx, ug)
return args.Error(0)
}
type mockUserRepo struct {
mock.Mock
repository.UserRepository
}
func (m *mockUserRepo) Update(ctx context.Context, user *domain.User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
type mockKetoOutboxRepo struct {
mock.Mock
repository.KetoOutboxRepository
}
func (m *mockKetoOutboxRepo) Create(ctx context.Context, outbox *domain.KetoOutbox) error {
args := m.Called(ctx, outbox)
return args.Error(0)
}
type mockKratosService struct {
mock.Mock
}
func (m *mockKratosService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) {
return nil, nil
}
func (m *mockKratosService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
args := m.Called(ctx, identifier)
return args.String(0), args.Error(1)
}
func (m *mockKratosService) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) {
return nil, nil
}
func (m *mockKratosService) UpdateIdentity(ctx context.Context, id string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
args := m.Called(ctx, id, traits, state)
return nil, args.Error(1)
}
func (m *mockKratosService) UpdateIdentityPassword(ctx context.Context, id, pw string) error {
return nil
}
func (m *mockKratosService) DeleteIdentity(ctx context.Context, id string) error {
return nil
}
func (m *mockKratosService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
args := m.Called(ctx, user, password)
return args.String(0), args.Error(1)
}
func setupMocksForImport(tenantRepo *mockTenantRepo, ugRepo *mockUserGroupRepo, userRepo *mockUserRepo, ketoRepo *mockKetoOutboxRepo, kratos *mockKratosService, ctx context.Context, companySlug, email string) {
tenantRepo.On("FindBySlug", ctx, companySlug).Return(&domain.Tenant{ID: companySlug + "-id", Slug: companySlug}, nil).Maybe()
tenantRepo.On("FindBySlug", ctx, mock.Anything).Return(&domain.Tenant{ID: "fallback-id", Slug: "fallback"}, nil).Maybe()
tenantRepo.On("AddDomain", ctx, mock.Anything, mock.Anything, true).Return(nil).Maybe()
tenantRepo.On("Create", ctx, mock.Anything).Return(nil).Maybe()
ugRepo.On("ListByTenantID", ctx, mock.Anything).Return([]domain.UserGroup{}, nil).Maybe()
ugRepo.On("Create", ctx, mock.Anything).Return(nil).Maybe()
kratos.On("FindIdentityIDByIdentifier", ctx, email).Return("user-id", nil).Maybe()
kratos.On("UpdateIdentity", ctx, "user-id", mock.Anything, "active").Return(nil, nil).Maybe()
userRepo.On("Update", ctx, mock.Anything).Return(nil).Maybe()
ketoRepo.On("Create", ctx, mock.Anything).Return(nil).Maybe()
}
func TestImportOrgChart_CSV_BOM(t *testing.T) {
tenantRepo := new(mockTenantRepo)
ugRepo := new(mockUserGroupRepo)
userRepo := new(mockUserRepo)
ketoRepo := new(mockKetoOutboxRepo)
kratos := new(mockKratosService)
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
csvData := "\xef\xbb\xbf이메일,이름,소속,직급,그룹,디비젼,팀,구분\n" +
"test@example.com,홍길동,한맥,사원,엔지니어링,구조,계획,팀원"
ctx := context.Background()
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "hanmac", "test@example.com")
res, err := svc.ImportOrgChart(ctx, "root-id", bytes.NewReader([]byte(csvData)), "test.csv", "")
assert.NoError(t, err)
assert.NotNil(t, res)
}
func TestImportOrgChart_XLSX(t *testing.T) {
tenantRepo := new(mockTenantRepo)
ugRepo := new(mockUserGroupRepo)
userRepo := new(mockUserRepo)
ketoRepo := new(mockKetoOutboxRepo)
kratos := new(mockKratosService)
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
xlsx := excelize.NewFile()
xlsx.SetCellValue("Sheet1", "A1", "이메일")
xlsx.SetCellValue("Sheet1", "B1", "이름")
xlsx.SetCellValue("Sheet1", "C1", "소속")
xlsx.SetCellValue("Sheet1", "A2", "xlsx@example.com")
xlsx.SetCellValue("Sheet1", "B2", "엑셀맨")
xlsx.SetCellValue("Sheet1", "C2", "삼안")
var buf bytes.Buffer
xlsx.Write(&buf)
ctx := context.Background()
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "saman", "xlsx@example.com")
res, err := svc.ImportOrgChart(ctx, "root-id", &buf, "test.xlsx", "")
assert.NoError(t, err)
assert.NotNil(t, res)
}
func TestImportOrgChart_MissingColumns(t *testing.T) {
svc := NewOrgChartService(nil, nil, nil, nil, nil)
ctx := context.Background()
csvData := "소속,직급\n한맥,부장"
res, err := svc.ImportOrgChart(ctx, "root", bytes.NewReader([]byte(csvData)), "test.csv", "")
assert.Error(t, err)
assert.Nil(t, res)
}
func TestImportOrgChart_RobustHeader(t *testing.T) {
tenantRepo := new(mockTenantRepo)
ugRepo := new(mockUserGroupRepo)
userRepo := new(mockUserRepo)
ketoRepo := new(mockKetoOutboxRepo)
kratos := new(mockKratosService)
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
csvData := "\n\n 이 메 일 , 이 름 , 소 속 \n" +
"robust@example.com,로버스트,바론"
ctx := context.Background()
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "baron", "robust@example.com")
res, err := svc.ImportOrgChart(ctx, "root-id", bytes.NewReader([]byte(csvData)), "test.csv", "")
assert.NoError(t, err)
assert.NotNil(t, res)
}
func TestImportOrgChart_MultiSheet_ComplexHeaders(t *testing.T) {
tenantRepo := new(mockTenantRepo)
ugRepo := new(mockUserGroupRepo)
userRepo := new(mockUserRepo)
ketoRepo := new(mockKetoOutboxRepo)
kratos := new(mockKratosService)
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
xlsx := excelize.NewFile()
xlsx.NewSheet("Sheet2")
xlsx.SetCellValue("Sheet2", "A3", " 이 메 일 ")
xlsx.SetCellValue("Sheet2", "B3", " 성 함 / 이 름 ")
xlsx.SetCellValue("Sheet2", "C3", " 소 속 회 사 ")
xlsx.SetCellValue("Sheet2", "A4", "sheet2@example.com")
xlsx.SetCellValue("Sheet2", "B4", "시트투")
xlsx.SetCellValue("Sheet2", "C4", "한맥")
var buf bytes.Buffer
xlsx.Write(&buf)
ctx := context.Background()
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "hanmac", "sheet2@example.com")
res, err := svc.ImportOrgChart(ctx, "root-id", &buf, "test.xlsx", "")
assert.NoError(t, err)
assert.NotNil(t, res)
}
func TestImportOrgChart_MessyHeader(t *testing.T) {
tenantRepo := new(mockTenantRepo)
ugRepo := new(mockUserGroupRepo)
userRepo := new(mockUserRepo)
ketoRepo := new(mockKetoOutboxRepo)
kratos := new(mockKratosService)
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
csvData := " 이메일(ID)* , 성 명 , [소속] \n" +
"messy@example.com,메시,바론"
ctx := context.Background()
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "baron", "messy@example.com")
res, err := svc.ImportOrgChart(ctx, "root-id", bytes.NewReader([]byte(csvData)), "test.csv", "")
assert.NoError(t, err)
assert.NotNil(t, res)
}

View File

@@ -20,10 +20,12 @@ type TenantService interface {
GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error)
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
IsDomainAllowed(ctx context.Context, domainName string) (bool, error)
ApproveTenant(ctx context.Context, id string) error
ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) // 추가
SetKetoService(keto KetoService) // 추가
ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
SetKetoService(keto KetoService)
DeleteTenantsBulk(ctx context.Context, ids []string) error
}
type tenantService struct {
@@ -56,8 +58,6 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
return nil, errors.New("keto service not initialized")
}
// [Keto] 'Tenant' 네임스페이스에서 'manage' 권한을 가진 모든 테넌트 ID 조회
// OPL(parents 상속 포함) 결과가 반영된 리스트를 가져옵니다.
allIDs, err := s.keto.ListObjects(ctx, "Tenant", "manage", "User:"+userID)
if err != nil {
slog.Error("Failed to list manageable tenants from Keto", "userID", userID, "error", err)
@@ -65,7 +65,6 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
}
if len(allIDs) == 0 {
// Fallback: Check direct membership if list objects didn't catch everything
directAdminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
directOwnerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
@@ -90,13 +89,42 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
return s.repo.FindByIDs(ctx, allIDs)
}
func (s *tenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
if s.keto == nil {
return nil, errors.New("keto service not initialized")
}
memberIDs, err := s.keto.ListObjects(ctx, "Tenant", "members", "User:"+userID)
if err != nil {
slog.Error("Failed to list joined tenants from Keto", "userID", userID, "error", err)
return []domain.Tenant{}, nil
}
ownerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
adminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
idMap := make(map[string]bool)
for _, id := range memberIDs { idMap[id] = true }
for _, id := range ownerIDs { idMap[id] = true }
for _, id := range adminIDs { idMap[id] = true }
allIDs := make([]string, 0, len(idMap))
for id := range idMap {
allIDs = append(allIDs, id)
}
if len(allIDs) == 0 {
return []domain.Tenant{}, nil
}
return s.repo.FindByIDs(ctx, allIDs)
}
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
// Validate Slug
if ok, msg := utils.ValidateSlug(slug); !ok {
return nil, errors.New(msg)
}
// 1. Check if slug exists
existing, err := s.repo.FindBySlug(ctx, slug)
if err == nil && existing != nil {
return nil, errors.New("tenant slug already exists")
@@ -105,7 +133,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
return nil, err
}
// 2. Create Tenant
tenant := &domain.Tenant{
Type: tenantType,
Name: name,
@@ -119,9 +146,7 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
return nil, err
}
// [Keto] Sync hierarchy and ownership via Outbox
if s.outboxRepo != nil {
// Global Super Admin access to every tenant
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
@@ -130,7 +155,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
Action: domain.KetoOutboxActionCreate,
})
// Sync hierarchy
if tenant.ParentID != nil {
if err := s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
@@ -143,10 +167,8 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
}
}
// Sync creator ownership
if creatorID != "" {
slog.Info("Creating outbox entries for tenant creator", "tenant", tenant.ID, "creator", creatorID)
// Add as owner
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
@@ -154,7 +176,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
Subject: "User:" + creatorID,
Action: domain.KetoOutboxActionCreate,
})
// Add as admin
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
@@ -162,7 +183,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
Subject: "User:" + creatorID,
Action: domain.KetoOutboxActionCreate,
})
// Add as member
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
@@ -173,7 +193,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
}
}
// 3. Add Domains (Auto-verify for manual admin registration)
for _, d := range domains {
if err := s.repo.AddDomain(ctx, tenant.ID, d, true); err != nil {
slog.Error("Failed to add domain to tenant", "tenant", slug, "domain", d, "error", err)
@@ -184,12 +203,10 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
}
func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
// Validate Slug
if ok, msg := utils.ValidateSlug(slug); !ok {
return nil, errors.New(msg)
}
// Verify that adminEmail domain matches the requested domainName
parts := strings.Split(adminEmail, "@")
if len(parts) != 2 || parts[1] != domainName {
return nil, errors.New("admin email domain must match the tenant domain")
@@ -208,7 +225,6 @@ func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, des
return nil, err
}
// [Keto] Global Super Admin access to every tenant (even pending ones)
if s.outboxRepo != nil {
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
@@ -219,7 +235,6 @@ func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, des
})
}
// Add Domain as unverified
if err := s.repo.AddDomain(ctx, tenant.ID, domainName, false); err != nil {
return nil, err
}
@@ -238,15 +253,12 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
return err
}
// [Keto] Sync relation via Outbox
if s.outboxRepo != nil {
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" {
slog.Info("Queueing tenant admin/owner sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail)
// Check if user already exists in our Read-Model
if s.userRepo != nil {
user, err := s.userRepo.FindByEmail(ctx, adminEmail)
if err == nil && user != nil {
// User exists, assign Admin, Owner, and Member roles in Keto via Outbox
slog.Info("Queueing tenant ownership/membership sync to Keto", "tenant", tenant.Slug, "userID", user.ID)
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
@@ -285,7 +297,6 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin
return nil, err
}
// Only return ACTIVE tenants for auto-assignment
if tenant.Status != domain.TenantStatusActive {
return nil, errors.New("tenant is not active")
}
@@ -298,7 +309,6 @@ func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*doma
}
func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
// Let the repository handle the query and pagination
return s.repo.List(ctx, limit, offset, parentID)
}
@@ -314,14 +324,12 @@ func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string)
}
func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
// 1. Find all COMPANY_GROUP tenants
groups, err := s.repo.ListByType(ctx, domain.TenantTypeCompanyGroup)
if err != nil {
return nil, err
}
for _, g := range groups {
// 2. Check autoProvisioning config
rawConfig, ok := g.Config["autoProvisioning"].(map[string]interface{})
if !ok {
continue
@@ -337,7 +345,6 @@ func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName
continue
}
// 3. Find rule for this domain
rule, ok := mapping[domainName].(map[string]interface{})
if !ok {
continue
@@ -350,13 +357,32 @@ func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName
continue
}
// 4. Create new sub-tenant under this group
slog.Info("[Provisioning] Found rule for domain, creating sub-tenant", "domain", domainName, "parent", g.Slug, "newTenant", slug)
// Use RegisterTenant to handle DB creation and Keto Outbox sync
// creatorID is empty as per security policy (manual delegation later)
return s.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "Automatically provisioned via group policy", []string{domainName}, &g.ID, "")
}
return nil, gorm.ErrRecordNotFound
}
func (s *tenantService) DeleteTenantsBulk(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
if err := s.repo.DeleteBulk(ctx, ids); err != nil {
return err
}
if s.outboxRepo != nil {
for _, id := range ids {
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: id,
Relation: "parents",
Action: domain.KetoOutboxActionDelete,
})
}
}
return nil
}

View File

@@ -72,6 +72,11 @@ func (m *MockTenantRepoForSvc) ListByType(ctx context.Context, tenantType string
return args.Get(0).([]domain.Tenant), args.Error(1)
}
func (m *MockTenantRepoForSvc) DeleteBulk(ctx context.Context, ids []string) error {
args := m.Called(ctx, ids)
return args.Error(0)
}
type MockKetoSvcForTenant struct {
mock.Mock
}

View File

@@ -170,6 +170,10 @@ func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, d
return nil
}
func (m *MockTenantRepository) DeleteBulk(ctx context.Context, ids []string) error {
return nil
}
func TestUserGroupService_Create(t *testing.T) {
mockRepo := new(MockUserGroupRepository)
mockTenantRepo := new(MockTenantRepository)