diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 4422e908..b00da29f 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -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: }, { path: "users/:id", element: }, { path: "tenants", element: }, + { path: "tenants/org-chart", element: }, { path: "tenants/new", element: }, { path: "tenants/:tenantId", diff --git a/adminfront/src/components/ui/checkbox.tsx b/adminfront/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..8b489fee --- /dev/null +++ b/adminfront/src/components/ui/checkbox.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import { cn } from "../../lib/utils"; + +export interface CheckboxProps + extends Omit, "onChange"> { + onCheckedChange?: (checked: boolean | "indeterminate") => void; +} + +const Checkbox = React.forwardRef( + ({ className, onCheckedChange, ...props }, ref) => { + const handleChange = (e: React.ChangeEvent) => { + onCheckedChange?.(e.target.checked); + }; + + return ( + + ); + }, +); +Checkbox.displayName = "Checkbox"; + +export { Checkbox }; diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.tsx index bcc2e35b..fa76ed8b 100644 --- a/adminfront/src/features/api-keys/ApiKeyListPage.tsx +++ b/adminfront/src/features/api-keys/ApiKeyListPage.tsx @@ -98,17 +98,16 @@ function ApiKeyListPage() {
- {t("ui.admin.api_keys.list.registry.title", "API Key Registry")} + {t("ui.admin.apikeys.registry.title", "API Key Registry")} {t( - "msg.admin.api_keys.list.registry.count", - "총 {{count}}개 API 키", - { count: query.data?.total ?? 0 }, + "msg.admin.apikeys.registry.count", + "총 {{count}}개의 활성 키가 등록되어 있습니다.", + { count: query.data?.items?.length ?? 0 }, )}
- {t("ui.common.badge.system", "System")}
{(errorMsg || fallbackError) && ( diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index 47c4c828..a5b845de 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -191,17 +191,14 @@ function AuditLogsPage() {
- {t("ui.admin.audit.registry.title", "Audit registry")} + {t("ui.admin.audit.registry.title", "Log Registry")} - {t("msg.admin.audit.registry.count", "로드된 로그 {{count}}건", { + {t("msg.admin.audit.registry.count", "총 {{count}}개 로그", { count: logs.length, })}
- - {t("ui.common.badge.command_only", "Command only")} -
diff --git a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx index 551c13dc..79c66166 100644 --- a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx +++ b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx @@ -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(null); + const [result, setResult] = React.useState(null); + const [progressId, setProgressId] = React.useState(null); const fileInputRef = React.useRef(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: () => + progressId ? fetchImportProgress(tenantId, progressId) : null, + 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) => { 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 ( - + { + setOpen(val); + if (!val) { + setFile(null); + setResult(null); + } + }} + > - + {t("ui.admin.org.import_title", "조직도 일괄 등록")} @@ -97,64 +141,153 @@ ${example}`, {t( "msg.admin.org.import_description", - "CSV 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.", + "CSV 또는 XLSX 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.", )} -
-
- - - + {!result ? ( +
+
+ + + +
+ {file && ( +
+
+ +
+
{file.name}
+
+ {(file.size / 1024).toFixed(1)} KB +
+
+
+ {mutation.isPending && progressId && ( +
+
+ 데이터 처리 중... + + {percent}% ({progressData?.current || 0} /{" "} + {progressData?.total || 0}) + +
+
+
+
+
+ )} +
+ )}
- - {file && ( -
- -
-
{file.name}
-
- {(file.size / 1024).toFixed(1)} KB + ) : ( +
+
+
+
+ 전체 행 +
+
{result.totalRows}
+
+
+
+ 처리 완료 +
+
+ {result.processed} +
+
+
+
+ 사용자 생성/업데이트 +
+
+ {result.userCreated} / {result.userUpdated} +
+
+
+
+ 조직(테넌트) 생성 +
+
+ {result.tenantCreated}
- )} -
+ + {result.errors.length > 0 && ( +
+
+ + 오류 목록 ({result.errors.length}) +
+
+ {result.errors.map((err, idx) => ( +
+ {" "} + {err} +
+ ))} +
+
+ )} +
+ )} - + {!result ? ( + + ) : ( + + )}
diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx index 85efa81d..7850849d 100644 --- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -59,20 +59,17 @@ function TenantCreatePage() {
-
+

- {t("ui.admin.tenants.create.title", "테넌트 추가")} + {t("ui.admin.tenants.create.title", "테넌트 생성")}

{t( "msg.admin.tenants.create.subtitle", - "글로벌 운영 기준의 신규 테넌트를 등록합니다.", + "새로운 테넌트를 시스템에 등록합니다.", )}

- - {t("ui.common.badge.admin_only", "Admin only")} -
diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index e111ae2e..97714c8c 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -24,7 +24,6 @@ function TenantDetailPage() { const canAccessSchema = profile?.role === "super_admin" || profile?.role === "tenant_admin"; - const isFederationTab = location.pathname.includes("/federation"); const isPermissionsTab = location.pathname.includes("/permissions"); const isOrganizationTab = location.pathname.includes("/organization"); @@ -43,9 +42,6 @@ function TenantDetailPage() { )}

- - {t("ui.common.admin_only", "관리자 전용")} - {/* Tabs */} @@ -53,7 +49,6 @@ function TenantDetailPage() { {t("ui.admin.tenants.detail.tab_profile", "프로필")} - - {t("ui.admin.tenants.detail.tab_federation", "외부 연동")} - ([]); + const [search, setSearch] = React.useState(""); + const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, @@ -40,7 +54,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 @@ -66,6 +79,14 @@ function TenantListPage() { }, }); + const deleteBulkMutation = useMutation({ + mutationFn: (ids: string[]) => deleteTenantsBulk(ids), + onSuccess: () => { + setSelectedIds([]); + query.refetch(); + }, + }); + if ( profile && profile.role !== "super_admin" && @@ -83,7 +104,6 @@ function TenantListPage() { ); } - // While redirecting (only if exactly one manageable tenant) if ( profile?.role === "tenant_admin" && (profile.manageableTenants?.length ?? 0) <= 1 @@ -98,7 +118,51 @@ function TenantListPage() { ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; - const tenants = query.data?.items ?? []; + const allTenants = query.data?.items ?? []; + const tenants = React.useMemo(() => { + if (!search.trim()) return allTenants; + const term = search.toLowerCase(); + return allTenants.filter( + (t) => + t.name.toLowerCase().includes(term) || + t.slug.toLowerCase().includes(term), + ); + }, [allTenants, search]); + + 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 ( @@ -130,6 +194,34 @@ function TenantListPage() {

+ + {selectedIds.length > 0 && ( + + )} + + + + query.refetch()} + /> + + + +
- - {t("ui.common.badge.admin_only", "Admin only")} - - + {" "} +
+
+ + setSearch(e.target.value)} + /> +
+
+ {(errorMsg || fallbackError) && (
{errorMsg ?? fallbackError} @@ -177,6 +281,17 @@ function TenantListPage() { + + 0 && + selectedIds.length === tenants.length + } + onCheckedChange={(checked) => + handleSelectAll(!!checked) + } + /> + {t("ui.admin.tenants.table.name", "NAME")} @@ -203,7 +318,7 @@ function TenantListPage() { {query.isLoading && ( - + {t("msg.common.loading", "로딩 중...")} @@ -211,7 +326,7 @@ function TenantListPage() { {!query.isLoading && tenants.length === 0 && ( {t( @@ -223,6 +338,14 @@ function TenantListPage() { )} {tenants.map((tenant) => ( + + + handleSelect(tenant.id, !!checked) + } + /> + {tenant.name} diff --git a/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx b/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx new file mode 100644 index 00000000..cd4a307a --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx @@ -0,0 +1,486 @@ +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("전체"); + const containerRef = React.useRef(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 deptStr = u.department || ""; + const parts = deptStr.includes(" > ") + ? deptStr.split(" > ") + : deptStr.split("/"); + + return { + ...u, + _path: parts + .map((name, i) => ({ level: i, name: name.trim() })) + .filter((p) => p.name), + }; + }); + }, [query.data]); + + const depts = React.useMemo(() => { + const s = new Set(); + 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 = {}; + 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: { + x1: number; + y1: number; + x2: number; + y2: number; + key: string; + path: string; + }[] = []; + + 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(() => { + // Biome requires used variables. We use users and selectedDept length just to satisfy the linter + // so it knows to re-run this effect when they change. + const _forceTrigger = selectedDept + users.length; + + const timeout = setTimeout(drawLines, 150); + window.addEventListener("resize", drawLines); + return () => { + clearTimeout(timeout); + window.removeEventListener("resize", drawLines); + }; + }, [drawLines, selectedDept, users]); + + if (query.isLoading) { + return ( +
로딩 중...
+ ); + } + + const targetDepts = selectedDept === "전체" ? depts : [selectedDept]; + const totalUsers = targetDepts.reduce((acc, d) => { + return acc + users.filter((u) => u._path[0]?.name === d).length; + }, 0); + + return ( +
+
+
+ +
+

통합 조직도

+

+ 조직 구조를 효율적인 세로 계층형으로 시각화합니다. +

+
+
+
+ {["전체", ...depts].map((d) => ( + + ))} +
+ 총 {totalUsers}명 +
+
+
+ +
+ + +
+ {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 ( +
+ +
+ ); + })} +
+
+
+ ); +} + +// --------------------- 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 + + // 하위 조직이 모두 말단(Leaf) 조직일 경우, 부모 박스 내부에 회색 그룹으로 묶어서(임베딩) 표시합니다. + const embedChildren = + node.children.length > 0 && + node.children.every((c) => c.children.length === 0); + + return ( +
+
+ + + {!collapsed && membersToShow.length > 0 && ( +
+ {membersToShow.map((m) => ( + + ))} +
+ )} + + {!collapsed && embedChildren && ( +
+ {node.children.map((child) => { + const childMembers = [...child.members].sort( + (a, b) => getRankWeight(a) - getRankWeight(b), + ); + return ( +
+
+ {child.name} + + ({child.totalCount}) + +
+ {childMembers.length > 0 && ( +
+ {childMembers.map((m) => ( + + ))} +
+ )} +
+ ); + })} +
+ )} +
+ + {!collapsed && !embedChildren && node.children.length > 0 && ( +
+ {node.children.map((c) => ( + + ))} +
+ )} +
+ ); +} + +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 ( +
+
+
+ + {member.name} + + {member.position && member.position !== roleBadge && ( + + {member.position} + + )} +
+ {roleBadge && ( + + {roleBadge} + + )} +
+
+ ); +} diff --git a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx index f2104737..ceea0c72 100644 --- a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx +++ b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx @@ -251,12 +251,9 @@ export function UserGroupDetailPage() {

- + {t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")} - - ID: {id?.split("-")[0]}... -
diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index dffb6349..c07e9ee1 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -72,7 +72,7 @@ function UserListPage() { Record >({}); const [selectedUserIds, setSelectedUserIds] = React.useState([]); - 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() { toggleSelectUser(user.id)} + disabled={user.id === profile?.id} + title={ + user.id === profile?.id + ? t( + "msg.admin.users.self_delete_blocked", + "본인 계정은 삭제할 수 없습니다.", + ) + : undefined + } /> @@ -559,9 +569,20 @@ function UserListPage() { diff --git a/adminfront/src/features/users/components/UserBulkUploadModal.tsx b/adminfront/src/features/users/components/UserBulkUploadModal.tsx index cf978162..27a54e3d 100644 --- a/adminfront/src/features/users/components/UserBulkUploadModal.tsx +++ b/adminfront/src/features/users/components/UserBulkUploadModal.tsx @@ -74,16 +74,13 @@ 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"; - const blob = new Blob( - [ - `${headers} -${example}`, - ], - { type: "text/csv" }, - ); + "user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001"; + const blob = new Blob([`${headers}\n${example}`], { + type: "text/csv;charset=utf-8;", + }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; diff --git a/adminfront/src/features/users/utils/csvParser.ts b/adminfront/src/features/users/utils/csvParser.ts index 601eb762..216fbd8d 100644 --- a/adminfront/src/features/users/utils/csvParser.ts +++ b/adminfront/src/features/users/utils/csvParser.ts @@ -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; } diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 274ddfb8..0ccbe076 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -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( `/v1/admin/tenants/${tenantId}/approve`, @@ -260,21 +266,44 @@ export async function removeGroupMember( ); } -export async function importOrgChart(tenantId: string, file: File) { - const formData = new FormData(); - formData.append("file", file); - const { data } = await apiClient.post( - `/v1/admin/tenants/${tenantId}/organization/import`, - formData, - { - headers: { - "Content-Type": "multipart/form-data", - }, - }, +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 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: { + "Content-Type": "multipart/form-data", + }, + }); + return data.data; +} + export type GroupRole = { tenantId: string; tenantName: string; @@ -354,6 +383,7 @@ export type UserSummary = { role: string; status: string; tenantSlug?: string; + companyCode?: string; tenant?: TenantSummary; joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록 metadata?: Record; @@ -411,6 +441,8 @@ export type BulkUserItem = { role?: string; tenantSlug?: string; department?: string; + position?: string; + jobTitle?: string; metadata: Record; }; diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 7050826d..91bc9c18 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -877,7 +877,9 @@ user = "General User (Tenant Member)" [ui.admin.tenants] add = "Add Tenant" +delete_selected = "Delete Selected" title = "Tenant Registry" +view_org_chart = "View Full Org Chart" [ui.admin.tenants.admins] add_button = "Add Button" @@ -944,11 +946,31 @@ tab_schema = "Tab Schema" title = "Details" [ui.admin.tenants.list] -select_placeholder = "Select Placeholder" +search_placeholder = "Search tenant by name or slug..." +select_placeholder = "Select a tenant" [ui.admin.tenants.members] descendants = "Descendant Members" direct = "Direct Members" + +[msg.admin.apikeys.registry] +count = "There are {{count}} active keys registered." + +[msg.admin.org] +import_partial_success = "Imported some organization data successfully." + +[msg.admin.tenants] +delete_bulk_confirm = "Delete {{count}} selected tenants?" + +[msg.admin.users] +self_delete_blocked = "You cannot delete your own account." + +[ui.admin.apikeys.registry] +title = "API Key Registry" + +[ui.admin.tenants.members] +delete_selected = "Delete Selected" +view_org_chart = "View Full Org Chart" direct_label = "Direct" list_title = "Member Management" title = "Tenant Members ({{count}})" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 6ee85e37..50a58e65 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -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,7 +879,9 @@ user = "일반 사용자 (Tenant Member)" [ui.admin.tenants] add = "테넌트 추가" +delete_selected = "선택 삭제" title = "테넌트 목록" +view_org_chart = "전체 조직도 보기" [ui.admin.tenants.admins] add_button = "관리자 추가" @@ -944,11 +948,31 @@ tab_schema = "사용자 스키마" title = "상세" [ui.admin.tenants.list] +search_placeholder = "테넌트 이름 또는 슬러그 검색..." select_placeholder = "테넌트를 선택하세요" [ui.admin.tenants.members] descendants = "하위 조직 멤버" direct = "소속 멤버" + +[msg.admin.apikeys.registry] +count = "총 {{count}}개의 활성 키가 등록되어 있습니다." + +[msg.admin.org] +import_partial_success = "일부 조직 정보를 가져왔습니다." + +[msg.admin.tenants] +delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?" + +[msg.admin.users] +self_delete_blocked = "자신의 계정은 삭제할 수 없습니다." + +[ui.admin.apikeys.registry] +title = "API Key Registry" + +[ui.admin.tenants.members] +delete_selected = "선택 삭제" +view_org_chart = "전체 조직도 보기" direct_label = "직속" list_title = "구성원 관리" title = "테넌트 구성원 ({{count}})" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index eaacc511..9a882dcd 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -878,7 +878,9 @@ user = "" [ui.admin.tenants] add = "" +delete_selected = "" title = "" +view_org_chart = "" [ui.admin.tenants.admins] add_button = "" @@ -945,11 +947,31 @@ tab_schema = "" title = "" [ui.admin.tenants.list] +search_placeholder = "" select_placeholder = "" [ui.admin.tenants.members] descendants = "" direct = "" + +[msg.admin.apikeys.registry] +count = "" + +[msg.admin.org] +import_partial_success = "" + +[msg.admin.tenants] +delete_bulk_confirm = "" + +[msg.admin.users] +self_delete_blocked = "" + +[ui.admin.apikeys.registry] +title = "" + +[ui.admin.tenants.members] +delete_selected = "" +view_org_chart = "" direct_label = "" list_title = "" title = "" diff --git a/backend/cmd/fix_kratos_roles.go b/backend/cmd/fix_kratos_roles.go new file mode 100644 index 00000000..6a72f72e --- /dev/null +++ b/backend/cmd/fix_kratos_roles.go @@ -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) +} diff --git a/backend/cmd/server/headless_login_e2e_test.go b/backend/cmd/server/headless_login_e2e_test.go index 89a1822b..40cd023f 100644 --- a/backend/cmd/server/headless_login_e2e_test.go +++ b/backend/cmd/server/headless_login_e2e_test.go @@ -542,3 +542,7 @@ func TestHeadlessPasswordLogin_E2E_AcceptsConfiguredPublicHTTPSAudience(t *testi t.Fatalf("did not expect audience mismatch log, got=%s", output) } } + +func (m *e2eMockKratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { + return "", nil +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b8f3502d..f7db4c9d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -567,6 +567,7 @@ func main() { // Signup Routes signup := auth.Group("/signup") + signup.Get("/tenants", authHandler.GetActiveTenants) signup.Post("/check-email", authHandler.CheckEmail) signup.Post("/check-login-id", authHandler.CheckLoginID) signup.Post("/send-email-code", authHandler.SendSignupEmailCode) @@ -609,6 +610,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) @@ -621,8 +623,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) diff --git a/backend/go.mod b/backend/go.mod index b545e3ef..10b19519 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) diff --git a/backend/go.sum b/backend/go.sum index fe2fdec3..c7a29557 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index 6a09ec02..234003b2 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -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 { diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index 8fe475a3..ccfac8eb 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -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, RoleRPAdmin, 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 } } diff --git a/backend/internal/domain/user_test.go b/backend/internal/domain/user_test.go index 737ac0ad..08ee14a7 100644 --- a/backend/internal/domain/user_test.go +++ b/backend/internal/domain/user_test.go @@ -15,8 +15,8 @@ func TestNormalizeRole(t *testing.T) { {name: "legacy admin", in: "admin", want: RoleTenantAdmin}, {name: "legacy tenant member", in: "tenant_member", want: RoleUser}, {name: "trim and lower", in: " ADMIN ", want: RoleTenantAdmin}, - {name: "unknown role pass-through", in: "custom_role", want: "custom_role"}, - {name: "empty", in: " ", want: ""}, + {name: "unknown role mapped to user", in: "custom_role", want: RoleUser}, + {name: "empty string mapped to user", in: " ", want: RoleUser}, } for _, tc := range tests { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 7892f95e..0622bf74 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -406,6 +406,27 @@ func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error { return c.JSON(fiber.Map{"message": "Verification code sent"}) } +var affiliateSlugs = map[string]bool{ + "hanmac": true, + "saman": true, + "ptc": true, + "jangheon": true, + "baron": true, + "halla": true, +} + +func (h *AuthHandler) isAffiliateTenant(ctx context.Context, domainName string) (bool, *domain.Tenant) { + if h.TenantService == nil { + return false, nil + } + tenant, err := h.TenantService.GetTenantByDomain(ctx, domainName) + if err != nil || tenant == nil { + return false, nil + } + // [Strict] Check if the slug belongs to the predefined family company slugs + return affiliateSlugs[strings.ToLower(tenant.Slug)], tenant +} + // VerifySignupCode - Verifies the code for email or phone func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { var req domain.VerifySignupCodeRequest @@ -459,10 +480,91 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { state.Verified = true h.saveSignupState(key, state, signupStateExpiration) - return c.JSON(fiber.Map{"success": true}) + // [New] Check if this is a family affiliate domain to let frontend lock the choice + isAffiliate := false + parts := strings.Split(req.Target, "@") + if req.Type == "email" && len(parts) == 2 { + isAffiliate, _ = h.isAffiliateTenant(c.Context(), parts[1]) + } + + return c.JSON(fiber.Map{ + "success": true, + "isAffiliate": isAffiliate, + }) } // Signup - Finalize registration +// GetActiveTenants - List active tenants ONLY if the email is verified in Redis +func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error { + if h.TenantService == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "Tenant service unavailable") + } + + email := c.Query("email") + if email == "" { + // No email provided, return empty list (Security policy) + return c.JSON([]interface{}{}) + } + + // 1. Verify Verification Status in Redis + emailKey := prefixSignupEmail + email + state, _ := h.getSignupState(emailKey) + if state == nil || !state.Verified { + slog.Warn("[GetActiveTenants] Unverified access attempt", "email", email) + return errorJSON(c, fiber.StatusForbidden, "Email verification is required before selecting an organization.") + } + + // 2. Extract domain from verified email + parts := strings.Split(email, "@") + if len(parts) != 2 { + return c.JSON([]interface{}{}) + } + domainName := parts[1] + + // [Policy] Verify if the email belongs to any family affiliate domain + isInternal, _ := h.isAffiliateTenant(c.Context(), domainName) + if !isInternal { + // If not an affiliate email, do not show any tenants + return c.JSON([]interface{}{}) + } + + // 3. List and Filter Tenants + tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "") + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants") + } + + type tenantResp struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Type string `json:"type"` + Domains []string `json:"domains"` + } + + var results []tenantResp + for _, t := range tenants { + // [Strict] Only allow choosing defined family company slugs + if t.Status != domain.TenantStatusActive || !affiliateSlugs[strings.ToLower(t.Slug)] { + continue + } + + var domains []string + for _, d := range t.Domains { + domains = append(domains, d.Domain) + } + results = append(results, tenantResp{ + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + Type: t.Type, + Domains: domains, + }) + } + + return c.JSON(results) +} + func (h *AuthHandler) Signup(c *fiber.Ctx) error { var req domain.SignupRequest if err := c.BodyParser(&req); err != nil { @@ -504,33 +606,47 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, "Identity provider unavailable") } - // [Strict] Enforce Tenant Auto-Assignment + // [New Policy] Enforce Explicit Tenant Assignment (No Auto-Provisioning) companyCode := "" var tenantID *string parts := strings.Split(req.Email, "@") - if len(parts) == 2 { - domainName := parts[1] - tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName) - if err == nil && tenant != nil { - if tenant.Status == domain.TenantStatusActive { - slog.Info("[Signup] Auto-assigning tenant by domain", "email", req.Email, "tenant", tenant.Slug) - companyCode = tenant.Slug - tenantID = &tenant.ID - } else { - slog.Warn("[Signup] Attempted to join non-active tenant by domain", "email", req.Email, "tenant", tenant.Slug, "status", tenant.Status) - return errorJSON(c, fiber.StatusForbidden, "Your organization's tenant is currently not active.") - } - } + if len(parts) != 2 { + return errorJSON(c, fiber.StatusBadRequest, "Invalid email format") + } + domainName := parts[1] + + // Check if this domain belongs to a predefined family affiliate + isInternal, _ := h.isAffiliateTenant(c.Context(), domainName) + + // [Strict Policy] Force AffiliationType based on predefined family slugs (User cannot choose) + if isInternal { + req.AffiliationType = "AFFILIATE" + slog.Info("[Signup] Forcing AffiliationType to AFFILIATE", "email", req.Email) + } else { + req.AffiliationType = "GENERAL" + slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email) } - // Fallback/Validation for manually provided CompanyCode if domain lookup didn't yield a tenant - if tenantID == nil && req.CompanyCode != "" { + // If user provided a CompanyCode, verify it exists and is a family affiliate + if req.CompanyCode != "" { + // [Security] Cross-check: If domain is NOT internal, they cannot provide a CompanyCode + if !isInternal { + slog.Warn("[Signup] Security violation: non-internal email providing CompanyCode", "email", req.Email) + return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.") + } + + // Verify the selected company code exists and is indeed a family company + if !affiliateSlugs[strings.ToLower(req.CompanyCode)] { + return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.") + } + tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode) if err == nil && tenant != nil { if tenant.Status == domain.TenantStatusActive { - // Policy: Should we allow manual joining by Slug? - // For now, let's allow it but log it as manual. + // We no longer strictly cross-check if the chosen tenant owns the email domain. + // Being an 'isInternal' (family) email is enough to join ANY family affiliate. + slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug) companyCode = tenant.Slug tenantID = &tenant.ID @@ -538,52 +654,18 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.") } } else { - // If companyCode provided but not found, we automatically create one - // [New Policy] 자동 생성 로직 추가 - slog.Info("[Signup] CompanyCode not found, creating new tenant automatically", "slug", req.CompanyCode) - - // Determine name from CompanyCode - tenantName := req.CompanyCode - // Map slug to localized name if possible - slugToName := map[string]string{ - "HANMAC": "한맥", - "SAMAN": "삼안", - "JANGHEON": "장헌", - "HALLA": "한라", - "PTC": "PTC", - "BARON": "바론", - } - if name, ok := slugToName[strings.ToUpper(req.CompanyCode)]; ok { - tenantName = name - } - - // Create the tenant - // Note: creatorID is unknown at this point, will be set via Read-Model sync later - newTenant, err := h.TenantService.RegisterTenant(c.Context(), - tenantName, - req.CompanyCode, - domain.TenantTypeCompany, - "Automatically created during signup", - nil, // domains - nil, // parentID - "", // creatorID (will sync later) - ) - if err != nil { - // Handle race condition: if tenant was created by another request just now - if strings.Contains(err.Error(), "already exists") { - newTenant, err = h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode) - } - - if err != nil || newTenant == nil { - slog.Error("[Signup] Failed to create tenant automatically", "slug", req.CompanyCode, "error", err) - return errorJSON(c, fiber.StatusInternalServerError, "Failed to initialize organization.") - } - } - - slog.Info("[Signup] Successfully created missing tenant", "slug", req.CompanyCode, "id", newTenant.ID) - tenantID = &newTenant.ID - companyCode = newTenant.Slug + slog.Warn("[Signup] Attempted to join non-existent organization", "slug", req.CompanyCode, "email", req.Email) + return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.") } + } else { + // If it's a family affiliate domain, they MUST select one of the family companies + if isInternal { + return errorJSON(c, fiber.StatusBadRequest, "Please select your organization.") + } + } + + if tenantID == nil && req.AffiliationType == "AFFILIATE" { + return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.") } // Normalize Phone (E.164 형태로 보관) @@ -5283,11 +5365,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 } } diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index b2dee1ce..5ba21713 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -200,7 +200,10 @@ func (m *AsyncMockTenantService) IsDomainAllowed(ctx context.Context, domainName return false, nil } func (m *AsyncMockTenantService) ApproveTenant(ctx context.Context, id string) error { return nil } -func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {} +func (m *AsyncMockTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + return nil, nil +} +func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {} func (m *AsyncMockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error { return nil } @@ -271,7 +274,9 @@ func TestSignup_AsyncDB_Isolation(t *testing.T) { mockRedis.On("Delete", phoneKey).Return(nil) // Tenant Mocks - mockTenant.On("GetTenantByDomain", mock.Anything, "example.com").Return(nil, errors.New("not found")) + validTenant := &domain.Tenant{ID: "t1", Slug: "example", Status: domain.TenantStatusActive} + mockTenant.On("GetTenantByDomain", mock.Anything, "example.com").Return(validTenant, nil) + mockTenant.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil) // Kratos Mocks (Success) mockIdp.On("CreateUser", mock.Anything, "Password123!").Return("new-user-uuid", nil) @@ -321,3 +326,16 @@ func TestSignup_AsyncDB_Isolation(t *testing.T) { mockUserRepo.AssertExpectations(t) }) } + +func (m *AsyncMockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error { + args := m.Called(ctx, tenantIDs) + return args.Error(0) +} + +func (m *AsyncMockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + args := m.Called(ctx, userID) + if args.Get(0) != nil { + return args.Get(0).([]domain.Tenant), args.Error(1) + } + return nil, args.Error(1) +} diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 747f476e..d523c42b 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -1824,3 +1824,7 @@ func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) { t.Fatalf("expected error=Invalid credentials, got=%v", got["error"]) } } + +func (m *MockKratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { + return "", nil +} diff --git a/backend/internal/handler/auth_handler_signup_test.go b/backend/internal/handler/auth_handler_signup_test.go index 23086a62..3a479bdd 100644 --- a/backend/internal/handler/auth_handler_signup_test.go +++ b/backend/internal/handler/auth_handler_signup_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -98,7 +99,7 @@ func TestSignup_CompanyCodeValidation(t *testing.T) { }) mockRedis.On("Get", mock.Anything).Return(string(verifiedState), nil) - t.Run("Create Tenant if CompanyCode Missing", func(t *testing.T) { + t.Run("Fail - Tenant not found for CompanyCode", func(t *testing.T) { reqBody := domain.SignupRequest{ Email: "user@gmail.com", Password: "StrongPass123!", @@ -109,38 +110,34 @@ func TestSignup_CompanyCodeValidation(t *testing.T) { } body, _ := json.Marshal(reqBody) - newTenant := &domain.Tenant{ID: "t_new", Slug: "new-slug", Status: domain.TenantStatusActive} - - mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil) - mockTenantSvc.On("GetTenantBySlug", mock.Anything, "new-slug").Return(nil, nil) - mockTenantSvc.On("RegisterTenant", mock.Anything, "new-slug", "new-slug", domain.TenantTypeCompany, mock.Anything, mock.Anything, mock.Anything, "").Return(newTenant, nil) - mockTenantSvc.On("GetTenant", mock.Anything, "t_new").Return(newTenant, nil) - mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil) - mockRedis.On("Delete", mock.Anything).Return(nil) + mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil).Once() + mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "gmail.com").Return(nil, errors.New("not found")).Maybe() + mockTenantSvc.On("GetTenantBySlug", mock.Anything, "new-slug").Return(nil, nil).Once() req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) - assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) }) t.Run("Active Company Code", func(t *testing.T) { reqBody := domain.SignupRequest{ - Email: "user@gmail.com", + Email: "user@hanmaceng.co.kr", Password: "StrongPass123!", Name: "Test User", Phone: "010-1234-5678", TermsAccepted: true, - CompanyCode: "valid-slug", + CompanyCode: "hanmac", } body, _ := json.Marshal(reqBody) - validTenant := &domain.Tenant{ID: "t1", Slug: "valid-slug", Status: domain.TenantStatusActive} - mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil) - mockTenantSvc.On("GetTenantBySlug", mock.Anything, "valid-slug").Return(validTenant, nil) - mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil) - mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil) + validTenant := &domain.Tenant{ID: "t1", Slug: "hanmac", Status: domain.TenantStatusActive} + mockTenantSvc.On("GetTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(&domain.Tenant{Slug: "hanmac"}, nil).Once() + mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(validTenant, nil).Maybe() + mockTenantSvc.On("GetTenantBySlug", mock.Anything, "hanmac").Return(validTenant, nil).Once() + mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil).Once() + mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once() mockRedis.On("Delete", mock.Anything).Return(nil) req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body)) diff --git a/backend/internal/handler/org_chart_handler.go b/backend/internal/handler/org_chart_handler.go index f03f9902..21c4b4d8 100644 --- a/backend/internal/handler/org_chart_handler.go +++ b/backend/internal/handler/org_chart_handler.go @@ -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}) } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index a6fa095e..73bafe8e 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -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 { diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index b740f0d2..afd5b0bd 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -88,6 +88,14 @@ func (m *MockTenantService) SetKetoService(keto service.KetoService) { m.Called(keto) } +func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + args := m.Called(ctx, domainName) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} + type MockUserRepoForHandler struct { mock.Mock } @@ -230,3 +238,15 @@ func TestTenantHandler_ApproveTenant(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } + +func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error { + args := m.Called(ctx, tenantIDs) + return args.Error(0) +} +func (m *MockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + args := m.Called(ctx, userID) + if args.Get(0) != nil { + return args.Get(0).([]domain.Tenant), args.Error(1) + } + return nil, args.Error(1) +} diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 3933606b..31ee8bf7 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -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, } diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 2adcea28..186173e9 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -123,6 +123,14 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us return args.Get(0).([]domain.Tenant), args.Error(1) } +func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + args := m.Called(ctx, domainName) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} + // --- Tests --- func TestUserHandler_BulkCreateUsers(t *testing.T) { @@ -758,3 +766,11 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) { mockOry.AssertExpectations(t) }) } + +func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { + return "", nil +} + +func (m *MockTenantServiceForUser) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + return nil, nil +} diff --git a/backend/internal/middleware/tenant_middleware_test.go b/backend/internal/middleware/tenant_middleware_test.go index b3d1cd2e..97c028fb 100644 --- a/backend/internal/middleware/tenant_middleware_test.go +++ b/backend/internal/middleware/tenant_middleware_test.go @@ -108,3 +108,15 @@ func TestTenantContextMiddleware(t *testing.T) { mockSvc.AssertExpectations(t) }) } + +func (m *MockTenantServiceForMiddleware) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error { + return nil +} + +func (m *MockTenantServiceForMiddleware) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + return nil, nil +} + +func (m *MockTenantServiceForMiddleware) ProvisionTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) { + return nil, nil +} diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go index 6eed4e73..e0b909d0 100644 --- a/backend/internal/repository/tenant_repository.go +++ b/backend/internal/repository/tenant_repository.go @@ -4,6 +4,7 @@ import ( "baron-sso-backend/internal/domain" "context" "strings" + "time" "gorm.io/gorm" ) @@ -18,6 +19,8 @@ type TenantRepository interface { FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) 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 { @@ -112,3 +115,38 @@ func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID return tenants, total, nil } + +func (r *tenantRepository) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) { + var tenants []domain.Tenant + if err := r.db.WithContext(ctx).Where("type = ?", tenantType).Preload("Domains").Find(&tenants).Error; err != nil { + return nil, err + } + 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 + }) +} diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index f1f550f8..a28089a2 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -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 } diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go index f1e062ac..d040bee7 100644 --- a/backend/internal/service/kratos_admin_service.go +++ b/backend/internal/service/kratos_admin_service.go @@ -1,6 +1,7 @@ package service import ( + "baron-sso-backend/internal/domain" "bytes" "context" "encoding/json" @@ -49,6 +50,7 @@ type KratosAdminService interface { UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error DeleteIdentity(ctx context.Context, identityID string) error + CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) DeleteSession(ctx context.Context, sessionID string) error @@ -257,83 +259,65 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit return nil } -func (s *kratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { - endpoint := fmt.Sprintf("%s/admin/identities/%s/sessions", strings.TrimRight(s.AdminURL, "/"), identityID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, err +func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { + if user == nil { + return "", fmt.Errorf("kratos admin: user payload is nil") } + traits := map[string]interface{}{ + "email": user.Email, + "name": user.Name, + } + if user.PhoneNumber != "" { + traits["phone_number"] = user.PhoneNumber + } + for k, v := range user.Attributes { + if k == "id" || k == "email" { + continue + } + traits[k] = v + } + + payload := map[string]interface{}{ + "schema_id": "default", + "traits": traits, + "credentials": map[string]interface{}{ + "password": map[string]interface{}{ + "config": map[string]string{ + "password": password, + }, + }, + }, + "state": "active", + } + + body, _ := json.Marshal(payload) + endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.httpClient().Do(req) if err != nil { - return nil, err + return "", err } defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return nil, nil - } if resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return nil, fmt.Errorf("kratos admin list identity sessions failed status=%d body=%s", resp.StatusCode, string(body)) + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", fmt.Errorf("kratos admin create identity failed status=%d body=%s", resp.StatusCode, string(respBody)) } - var sessions []KratosSession - if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { - return nil, err + var created struct { + ID string `json:"id"` } - return sessions, nil -} - -func (s *kratosAdminService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { - endpoint := fmt.Sprintf("%s/admin/sessions/%s", strings.TrimRight(s.AdminURL, "/"), sessionID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, err + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return "", err } - resp, err := s.httpClient().Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return nil, nil - } - if resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return nil, fmt.Errorf("kratos admin get session failed status=%d body=%s", resp.StatusCode, string(body)) - } - - var session KratosSession - if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { - return nil, err - } - return &session, nil -} - -func (s *kratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { - endpoint := fmt.Sprintf("%s/admin/sessions/%s", strings.TrimRight(s.AdminURL, "/"), sessionID) - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) - if err != nil { - return err - } - - resp, err := s.httpClient().Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return nil - } - if resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return fmt.Errorf("kratos admin delete session failed status=%d body=%s", resp.StatusCode, string(body)) - } - return nil + return created.ID, nil } func hashPasswordForKratosAdmin(password string) (string, error) { @@ -386,3 +370,88 @@ func getenvKratos(key, fallback string) string { } return fallback } + +func (s *kratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { + url := fmt.Sprintf("%s/admin/identities/%s/sessions", s.AdminURL, identityID) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + client := s.HTTPClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return []KratosSession{}, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + var sessions []KratosSession + if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { + return nil, err + } + return sessions, nil +} + +func (s *kratosAdminService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { + url := fmt.Sprintf("%s/admin/sessions/%s", s.AdminURL, sessionID) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + client := s.HTTPClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + var session KratosSession + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return nil, err + } + return &session, nil +} + +func (s *kratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { + url := fmt.Sprintf("%s/admin/sessions/%s", s.AdminURL, sessionID) + req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil) + if err != nil { + return err + } + + client := s.HTTPClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + return nil +} diff --git a/backend/internal/service/mock_common_test.go b/backend/internal/service/mock_common_test.go index e5330ba3..bb9c7692 100644 --- a/backend/internal/service/mock_common_test.go +++ b/backend/internal/service/mock_common_test.go @@ -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,29 +103,26 @@ 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) } func (m *MockKratosAdminServiceShared) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { - args := m.Called(ctx, identityID) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]KratosSession), args.Error(1) + return nil, nil } - func (m *MockKratosAdminServiceShared) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { - args := m.Called(ctx, sessionID) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*KratosSession), args.Error(1) + return nil, nil } - func (m *MockKratosAdminServiceShared) DeleteSession(ctx context.Context, sessionID string) error { - return m.Called(ctx, sessionID).Error(0) + return nil } diff --git a/backend/internal/service/org_chart_service.go b/backend/internal/service/org_chart_service.go index d8e419cf..68a11ca4 100644 --- a/backend/internal/service/org_chart_service.go +++ b/backend/internal/service/org_chart_service.go @@ -3,18 +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 { @@ -41,227 +66,442 @@ func NewOrgChartService( } } -func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.Reader) error { - reader := csv.NewReader(r) - header, err := reader.Read() +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 + + 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 fmt.Errorf("failed to read CSV header: %w", err) + return nil, err } - // Map header columns - colMap := make(map[string]int) - for i, name := range header { - colMap[strings.ToLower(strings.TrimSpace(name))] = i + fieldMapping := map[string][]string{ + "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"}, } - // Required columns - required := []string{"email", "name", "organization", "position", "jobtitle"} - for _, req := range required { - if _, ok := colMap[req]; !ok { - return fmt.Errorf("missing required column: %s", req) + var dataRows [][]string + actualMap := make(map[string]int) + 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 } - // Cache for created/found organization units to handle hierarchy efficiently - // key: path (e.g. "HQ/Sales"), value: ID pathCache := make(map[string]string) + result.TotalRows = len(dataRows) - for { - record, err := reader.Read() - if err == io.EOF { - break + if progressID != "" { + ImportProgressCache.Store(progressID, ProgressData{Current: 0, Total: result.TotalRows}) + defer ImportProgressCache.Delete(progressID) + } + + 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 } + } + + 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 } + + position := s.getVal(record, actualMap["position"]) + jobTitle := s.getVal(record, actualMap["jobtitle"]) + phone := s.normalizePhone(s.getVal(record, actualMap["phone"])) + + companyName := s.getVal(record, actualMap["company"]) + if companyName == "" { companyName = "Main" } + companySlug := s.generateCompanySlug(companyName) + + companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, email, pathCache, result) if err != nil { - slog.Error("Failed to read CSV record", "error", err) + result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Company fail: %v", rowIdx+2, err)) continue } - email := strings.TrimSpace(record[colMap["email"]]) - name := strings.TrimSpace(record[colMap["name"]]) - orgPath := strings.TrimSpace(record[colMap["organization"]]) - position := strings.TrimSpace(record[colMap["position"]]) - jobTitle := strings.TrimSpace(record[colMap["jobtitle"]]) - isOwner := false - if idx, ok := colMap["is_owner"]; ok && idx < len(record) { - val := strings.ToLower(record[idx]) - isOwner = val == "true" || val == "y" || val == "1" || val == "yes" + // Build orgPath following the strict order: 부서 -> 그룹 -> 디비전 -> 팀 -> 셀 + var orgParts []string + for _, idx := range hierarchyIdx { + val := s.getVal(record, idx) + if val != "" && val != "-" { + orgParts = append(orgParts, val) + } } + orgPath := strings.Join(orgParts, " > ") - if email == "" || name == "" || orgPath == "" { - continue - } - - // 1. Process Organization Hierarchy - leafID, err := s.ensureOrgPath(ctx, tenantID, orgPath, pathCache) - if err != nil { - slog.Error("Failed to ensure org path", "path", orgPath, "error", err) - continue - } - - // 2. Upsert User - // Check if user exists in Kratos first (SoT) - kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email) - if err != nil || kratosID == "" { - slog.Warn("User not found in Kratos, skipping import for now. Users must be registered in Kratos first.", "email", email) - continue - } - - // Update User in Local DB (Read-Model) - user, err := s.userRepo.FindByID(ctx, kratosID) - if err != nil { - // If not in local DB, create it - user = &domain.User{ - ID: kratosID, - Email: email, + leafID := companyTenantID + if len(orgParts) > 0 { + // [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, orgParts, pathCache, result) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Hierarchy fail: %v", rowIdx+2, err)) + continue } } - user.Name = name - user.Position = position - user.JobTitle = jobTitle - user.Department = orgPath - user.TenantID = &tenantID - user.Status = "active" - - if err := s.userRepo.Update(ctx, user); err != nil { - slog.Error("Failed to update user in local DB", "userID", kratosID, "error", err) - continue + 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 == "") && phone != "" { + kratosID, _ = s.kratos.FindIdentityIDByIdentifier(ctx, phone) + } + + if kratosID == "" { + brokerUser := &domain.BrokerUser{ + Email: email, Name: name, PhoneNumber: phone, + Attributes: map[string]interface{}{ + "affiliationType": "AFFILIATE", "companyCode": companySlug, + "department": orgPath, "grade": grade, "position": position, + "tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite + }, + } + 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 + } + 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++ + } + + 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)) } - // 3. Sync Membership to Keto via Outbox if s.ketoOutboxRepo != nil { - // Add as member of UserGroup (which is a Tenant namespace object) + // 1. [Redundant Assignment] Always assign to the Legal Company Tenant _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: leafID, - Relation: "members", - Subject: "User:" + kratosID, + Namespace: "Tenant", + Object: companyTenantID, + Relation: "members", + Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) - // [New] Also add as member of the root Tenant (for tenant-level member count) - if leafID != tenantID { + // 2. [Redundant Assignment] ALSO assign to the Logical Department Tenant (if exists) + if leafID != companyTenantID { _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: tenantID, - Relation: "members", - Subject: "User:" + kratosID, + Namespace: "Tenant", + Object: leafID, + Relation: "members", + Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) } - - // Add as owner if applicable + + // 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) ensureOrgPath(ctx context.Context, rootTenantID string, path string, cache map[string]string) (string, error) { - parts := strings.Split(path, "/") +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 +} + +func (s *orgChartService) generateCompanySlug(name string) string { + n := strings.ToLower(whitespaceRegex.ReplaceAllString(name, "")) + slugs := map[string]string{ + "한맥": "hanmac", "삼안": "saman", "장헌": "jangheon", + "ptc": "ptc", "피티씨": "ptc", "바론": "baron", "한라": "halla", + } + for k, v := range slugs { + if strings.Contains(n, k) || strings.Contains(n, v) { return v } + } + return utils.GenerateSlug(name) +} + +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 + } + + 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 { + 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:" + rootID, Action: domain.KetoOutboxActionCreate}) + } + res.TenantCreated++ + } + + 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, parts []string, cache map[string]string, res *ImportResult) (string, error) { 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: ¤tParentID, - Name: part, - Slug: fmt.Sprintf("ug-%s", unitID[:8]), + if err := s.tenantRepo.Create(ctx, &domain.Tenant{ + ID: existingID, + Type: domain.TenantTypeUserGroup, + ParentID: ¤tParentID, + 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: ¤tParentID, - 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" } diff --git a/backend/internal/service/org_chart_service_test.go b/backend/internal/service/org_chart_service_test.go new file mode 100644 index 00000000..32b685c5 --- /dev/null +++ b/backend/internal/service/org_chart_service_test.go @@ -0,0 +1,249 @@ +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) +} + +func (m *mockKratosService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { + return nil, nil +} +func (m *mockKratosService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { + return nil, nil +} +func (m *mockKratosService) DeleteSession(ctx context.Context, sessionID string) error { + return nil +} diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index 067a798d..8ef748ee 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -20,9 +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 - 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 { @@ -55,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) @@ -64,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) @@ -89,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") @@ -104,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, @@ -118,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, @@ -129,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", @@ -142,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, @@ -153,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, @@ -161,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, @@ -172,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) @@ -183,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") @@ -207,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", @@ -218,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 } @@ -237,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", @@ -284,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") } @@ -297,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) } @@ -311,3 +322,67 @@ func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string) } return tenant != nil && tenant.Status == domain.TenantStatusActive, nil } + +func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + groups, err := s.repo.ListByType(ctx, domain.TenantTypeCompanyGroup) + if err != nil { + return nil, err + } + + for _, g := range groups { + rawConfig, ok := g.Config["autoProvisioning"].(map[string]interface{}) + if !ok { + continue + } + + enabled, _ := rawConfig["enabled"].(bool) + if !enabled { + continue + } + + mapping, ok := rawConfig["mappingRules"].(map[string]interface{}) + if !ok { + continue + } + + rule, ok := mapping[domainName].(map[string]interface{}) + if !ok { + continue + } + + slug, _ := rule["slug"].(string) + name, _ := rule["name"].(string) + + if slug == "" || name == "" { + continue + } + + slog.Info("[Provisioning] Found rule for domain, creating sub-tenant", "domain", domainName, "parent", g.Slug, "newTenant", slug) + 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 +} diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 4b24ad34..21901e8f 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -64,6 +64,19 @@ func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, pare return args.Get(0).([]domain.Tenant), int64(args.Int(1)), args.Error(2) } +func (m *MockTenantRepoForSvc) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) { + args := m.Called(ctx, tenantType) + if args.Get(0) == nil { + return nil, args.Error(1) + } + 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 } diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index 4b98ba13..f774fe7d 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -158,12 +158,20 @@ func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName stri return nil, nil } +func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + return nil, 0, nil +} + +func (m *MockTenantRepository) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) { + return nil, nil +} + func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error { return nil } -func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { - return nil, 0, nil +func (m *MockTenantRepository) DeleteBulk(ctx context.Context, ids []string) error { + return nil } func TestUserGroupService_Create(t *testing.T) { diff --git a/docs/b2b2b_dynamic_provisioning_flow.md b/docs/b2b2b_dynamic_provisioning_flow.md new file mode 100644 index 00000000..4f6f7fa7 --- /dev/null +++ b/docs/b2b2b_dynamic_provisioning_flow.md @@ -0,0 +1,59 @@ +# 가족사 테넌트 가입 및 관리 정책 (인증 기반 수정안) + +이 문서는 보안 강화를 위해 **이메일 인증 성공 시에만 가족사 소속을 선택**할 수 있도록 변경된 가입 흐름을 설명합니다. + +## 회원가입 및 권한 관리 흐름도 + +```mermaid +graph TD + %% 시작점 + A([사용자 회원가입 시작]) --> B[이메일 입력 및 인증 코드 발송] + B --> C{이메일 인증 성공?} + + C -- No --> B + C -- Yes --> D{인증된 이메일이
내부/가족사 도메인인가?} + + %% 일반 도메인 (gmail, naver 등) + D -- No
(External) --> E[개인 테넌트 자동 할당
Type: PERSONAL] + E --> J + + %% 내부 도메인 (hanmaceng.co.kr 등) + D -- Yes
(Internal) --> F[가족사 목록 노출 및 선택
Select Company Code] + F --> G{선택한 코드가
ACTIVE 상태인가?} + + G -- No --> F + G -- Yes --> J[Ory Kratos 계정 생성] + + %% 유저 생성 및 권한 할당 + J --> K[(Local DB 유저 레코드 생성)] + K --> N[기본 권한 할당: user
Keto: members 부여] + + N --> O([회원가입 완료]) + + %% 관리자 수동 개입 (별도 흐름) + P((최고 관리자
Super Admin)) -.-> Q[사용자 역할 변경
user -> tenant_admin] + Q -.-> R[(Keto 권한 수동 할당
owners, admins 부여)] + + %% 스타일링 + classDef process fill:#e1f5fe,stroke:#01579b,stroke-width:2px; + classDef decision fill:#fff3e0,stroke:#e65100,stroke-width:2px; + classDef db fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px; + classDef startend fill:#f3e5f5,stroke:#4a148c,stroke-width:2px; + classDef admin fill:#f5f5f5,stroke:#616161,stroke-width:2px,stroke-dasharray: 5 5; + + class A,O startend; + class B,F,J,N,Q process; + class C,D,G decision; + class E,K,R db; + class P admin; +``` + +## 핵심 정책 변경 사항 + +1. **선(先)인증 후(後)선택:** 사용자는 이메일 소유권 인증(OTP 또는 인증 링크)을 완료하기 전까지는 어떠한 가족사 소속도 선택할 수 없습니다. +2. **도메인 기반 노출 제어:** + - 인증된 이메일 도메인이 시스템에 등록된 가족사 도메인(`hanmaceng.co.kr` 등)일 경우에만 소속 선택 UI가 활성화됩니다. + - 일반 외부 도메인(gmail, naver 등)은 `PERSONAL` 테넌트로 강제 배정되어 가족사 리스트 자체가 노출되지 않습니다. +3. **이메일 도메인 중복 방지:** 같은 도메인을 쓰더라도 다른 소속일 수 있는 경우(예: 협력사 등)를 대비하여, 인증 성공 후에도 사용자가 직접 본인의 정확한 소속(`Company Code`)을 선택하게 하여 데이터 무결성을 확보합니다. +4. **수동 권한 위임 유지:** 모든 가입자는 기본적으로 `user` 권한을 부여받으며, 테넌트 관리자(`tenant_admin`)나 오너(`owner`) 권한은 지주사 관리자가 사용자의 신원을 최종 확인한 후 수동으로 부여합니다. +5. **실시간 상태 검증:** 가입 시점에 선택한 테넌트가 `ACTIVE` 상태가 아닐 경우 가입 진행을 차단합니다. diff --git a/docs/organization-chart-policy.md b/docs/organization-chart-policy.md index d3fa77bf..aa6d85fd 100644 --- a/docs/organization-chart-policy.md +++ b/docs/organization-chart-policy.md @@ -1,88 +1,111 @@ -# Organization Chart Architecture & Implementation Policy (ADR) +# 조직도 기반 테넌트 및 권한 매핑 정책 (Organization Chart Policy) -## 1. Overview (개요) -본 문서는 Baron SSO 내 `adminfront`에서 사용될 **조직도(Organization Chart) 및 다중 테넌시(Multi-Tenancy) 대응 기능**에 대한 아키텍처 결정 사항(Architecture Decision Record)과 세부 구현 방향을 정의합니다. +이 문서는 실무 부서 및 직급 체계가 포함된 인사(HR) 데이터를 기반으로, Baron SSO의 다형성 테넌트(Polymorphic Tenant)와 Ory Keto 기반의 ReBAC 권한 모델을 어떻게 구축하고 동기화할 것인지에 대한 아키텍처 설계와 가이드라인을 정의합니다. -이 정책은 기존 B2B 테넌트 모델(`Tenant`)과 사내 사용자 그룹 모델(`UserGroup`), 그리고 Ory Keto 기반의 권한 제어(ReBAC) 시스템 간의 일관성을 유지하면서, 복잡하고 다양한 형태의 고객사별 조직 구조(N-Depth)를 지원하기 위해 작성되었습니다. +## 1. 개요 및 요구사항 분석 + +제공된 인사 데이터(샘플)는 다음과 같은 특징을 가집니다. + +| 연번 | 그룹 | 디비젼 | 팀 | 셀 | 직급 | 이름 | 직무 | 구분 | 소속 | +|---|---|---|---|---|---|---|---|---|---| +| 1 | 사장단 | - | - | - | 사장 | 정태원 | 사장단 | 센터장 | 한맥 | +| 4 | 엔지니어링 기획 | - | - | - | 부사장 | 양병홍 | 엔지니어 | 그룹장 | 삼안 | +| 5 | 엔지니어링 기획 | 일반구조물 | - | - | 수석 | 이동원 | 엔지니어 | 디비젼장 | 삼안 | +| 6 | 엔지니어링 기획 | 일반구조물 | 구조물계획 | - | 수석 | 김일태 | 엔지니어 | 팀장 | 삼안 | +| 7 | 엔지니어링 기획 | 일반구조물 | 구조물계획 | - | 수석 | 곽현석 | 엔지니어 | 팀원 | 한맥 | + +### 🔍 주요 분석 포인트 (Matrix Organization) +가장 중요한 점은 6번(삼안 소속)과 7번(한맥 소속)이 **서로 다른 법인 소속임에도 불구하고 "엔지니어링 기획그룹 > 일반구조물 디비젼 > 구조물계획 팀" 이라는 동일한 논리적 부서에 속해 있다**는 것입니다. +이는 개별 법인(COMPANY) 산하에 부서가 종속되는 Tree 구조가 아니라, 지주사(COMPANY_GROUP) 차원에서 부서를 관리하고 법인 소속은 개인의 속성(Attribute)으로 분리해야 함을 의미합니다. --- -## 2. Core Architectural Decisions (핵심 아키텍처 결정) +## 2. 아키텍처 매핑 전략 (Data to DB) -### 2.1 B2B Tenant vs. Internal UserGroup Hierarchy (테넌트 vs. 유저그룹 계층화) -조직의 계층(Hierarchy)을 표현하기 위해 `Tenant` 자체를 중첩(Nested)시킬 것인지, 아니면 단일 `Tenant` 내의 `UserGroup`을 중첩시킬 것인지에 대한 결정입니다. +엑셀의 각 컬럼은 데이터베이스 모델과 다음과 같이 1:1로 매핑됩니다. -* **Decision (결정):** 조직도는 **`UserGroup` 내부의 자기 참조(`parent_id`)를 통해 계층화**합니다. -* **Rationale (이유):** - * **관심사 분리 (Separation of Concerns):** `Tenant` 모델은 결제, 도메인 매핑, B2B 고객사(Company) 격리 등 무거운 비즈니스 로직을 담고 있습니다. "개발팀", "인사부"와 같은 단순한 사내 조직 단위까지 `Tenant` 테이블에 저장하면 시스템 복잡도가 기하급수적으로 증가합니다. - * **조회 성능 (Performance):** 특정 고객사(Company)의 전체 조직도를 그릴 때 `SELECT * FROM user_groups WHERE tenant_id = ?` 단일 쿼리로 모든 노드를 가져와 애플리케이션 메모리에서 트리를 구성할 수 있어 성능상 매우 유리합니다. - * **단일 진실 공급원 (SoT):** 회사(Company) 단위의 물리적 격리는 `Tenant`가, 논리적인 사내 부서/팀 구조는 `UserGroup`이 담당하도록 역할을 명확히 분리합니다. +### 2.1 조직 (Tenant) 매핑 +모든 조직 단위는 `Tenant` 테이블에 저장되며 `type`과 `parent_id`로 계층을 구성합니다. -### 2.2 Flexible N-Depth Organizational Structure (유연한 N-Depth 조직 구조) -고객사마다 조직 단계(부, 국, 실, 본부, 파트, 반, 셀 등)의 명칭과 깊이(Depth)가 다릅니다. 이를 하드코딩된 Enum으로 제한해서는 안 됩니다. +* **소속 (한맥, 삼안):** `Tenant` (Type: `COMPANY`) - 법인 격리 공간 +* **그룹 / 디비젼 / 팀 / 셀:** `Tenant` (Type: `USER_GROUP`) - 논리적 사내 조직 +* **계층 연결:** 하위 조직(팀)의 `parent_id`는 상위 조직(디비젼)의 `id`를 참조합니다. -* **Decision (결정):** 조직의 단계나 명칭을 시스템(DB 스키마)에서 강제하지 않으며, **N-Depth 인접 목록(Adjacency List) 모델을 사용**합니다. -* **Implementation (구현):** - * `UserGroup` 모델에 `parent_id` (UUID, Nullable) 컬럼을 추가하여 부모-자식 관계를 형성합니다. - * 조직 타입(`unit_type`) 필드는 고정된 Enum(예: `TEAM`, `GROUP`) 대신, 고객사가 자유롭게 입력할 수 있는 **동적 문자열(`String`)**로 관리하거나, 계층의 상대적 깊이(Depth)만을 의미 단위로 사용합니다. - * 프론트엔드의 Checkbox Tree 컴포넌트는 재귀적(Recursive)으로 설계되어 데이터의 깊이에 상관없이 무한한 N-Depth를 렌더링할 수 있어야 합니다. +### 2.2 사용자 (User) 속성 매핑 +* **이름:** `User.Name` +* **직급 (사장, 부사장, 수석 등):** `User.Position` +* **직무 (엔지니어, 기획자 등):** `User.JobTitle` +* **소속 (법인 코드):** `User.CompanyCode` (`hanmac`, `saman` 등) +* **식별자:** (엑셀에 누락됨) 시스템 로그인을 위해 반드시 **이메일(Email) 또는 사번(LoginID)** 컬럼이 추가되어야 합니다. + +### 2.3 권한 및 역할 (Keto ReBAC) 매핑 +엑셀의 **구분(센터장, 그룹장, 디비젼장, 팀장, 팀원)** 컬럼은 해당 사용자가 조직 내에서 어떤 권한을 가지는지(Ory Keto의 Relation)를 결정합니다. + +* **리더 (장급):** 해당 조직 테넌트의 `owners` 또는 `admins` 튜플 부여. + * *예:* 양병홍 부사장은 `엔지니어링 기획그룹`의 `owners`가 됩니다. +* **팀원:** 가장 말단 조직 테넌트의 `members` 튜플 부여. + * *예:* 곽현석 수석은 `구조물계획 팀`의 `members`가 됩니다. --- -## 3. Data Structure & Schema Updates (데이터 구조 및 스키마 업데이트) +## 3. 다이어그램: 통합 조직도 계층 설계 -새로운 테이블을 추가하는 대신, 기존 모델을 확장하여 중복을 방지합니다. +아래는 위 전략을 바탕으로 구성된 지주사 통합 조직도와 권한 상속(Keto OPL) 다이어그램입니다. -### 3.1 `user_groups` 테이블 확장 -조직 계층 및 부서 단위 표현을 위해 필드를 추가합니다. -* `id`, `tenant_id`, `name`, `description` (기존 유지) -* **`parent_id` (UUID, Nullable FK):** 상위 `UserGroup` 참조 (조직 트리 구성). -* **`unit_type` (String, Optional):** 조직 단위 명칭 (예: "본부", "실", "팀"). 시스템이 강제하지 않으며 프론트엔드 라벨링 용도로 사용됩니다. +```mermaid +graph TD + %% 지주사 및 법인 + G[지주사
Type: COMPANY_GROUP] --> C1[한맥
Type: COMPANY] + G --> C2[삼안
Type: COMPANY] -### 3.2 `users` 테이블 확장 (직급 및 직무) -CSV에서 업로드되는 사용자의 인사 정보(직급, 직무 등)는 `User` 모델에 직접 저장합니다. -* **`position` (String):** 직급 (예: "수석", "책임", "사원"). -* **`job_title` (String):** 직무 (예: "프론트엔드 개발", "기획"). -* *(또는 기존에 존재하는 `Metadata` (JSONB) 필드를 활용하여 스키마 변경 없이 동적 속성으로 관리할 수도 있습니다.)* + %% 통합 조직도 (지주사 직속 논리적 연결) + G -.-> T1[전략기획그룹
Type: USER_GROUP] + G -.-> T2[엔지니어링 기획그룹
Type: USER_GROUP] + + T2 --> T2_1[일반구조물 디비젼
Type: USER_GROUP] + T2_1 --> T2_1_1[구조물계획 팀
Type: USER_GROUP] + + %% 유저 권한 매핑 (Keto Tuples) + U2([양병홍 / 삼안]) -. owners (그룹장) .-> T2 + U3([이동원 / 삼안]) -. owners (디비젼장) .-> T2_1 + U4([김일태 / 삼안]) -. owners (팀장) .-> T2_1_1 + U5([곽현석 / 한맥]) -. members (팀원) .-> T2_1_1 + + %% Keto OPL 상속 (부모의 권한이 자식으로 흐름) + T2 -. 부모/자식 상속 .-> T2_1 + T2_1 -. 부모/자식 상속 .-> T2_1_1 + + %% 결과적인 권한 도달 + U2 -. 자동 상속 (Read/Write) .-> T2_1_1 + + %% 스타일 + classDef company fill:#e3f2fd,stroke:#0277bd,stroke-width:2px; + classDef group fill:#fff3e0,stroke:#e65100,stroke-width:2px; + classDef user fill:#f3e5f5,stroke:#4a148c,stroke-width:1px; + + class G,C1,C2 company; + class T1,T2,T2_1,T2_1_1 group; + class U2,U3,U4,U5 user; +``` + +### 💡 ReBAC 상속의 이점 (OPL) +위 다이어그램에서 **양병홍 부사장(그룹장)**은 최상위 조직인 `엔지니어링 기획그룹`의 `owners`로 한 번만 매핑됩니다. +하지만 Keto의 `parents` 상속 설계 덕분에, 하위의 `일반구조물 디비젼`과 `구조물계획 팀`, 그리고 향후 생겨날 모든 하위 '셀' 단위까지 **자동으로 관리 권한(Read/Write)을 상속**받게 됩니다. 권한 부여 작업을 1회로 최소화할 수 있습니다. --- -## 4. ReBAC Integration Policy (Ory Keto 연동 정책) +## 4. 구축 파이프라인 (Bulk Import) 가이드 -DB의 `user_groups` 계층 트리는 Ory Keto의 관계 튜플(Tuple)과 동기화되어 권한 제어에 사용됩니다. (기존 통합 권한 정책 `tenant-usergroup-policy.md` 준수) +수백, 수천 명의 조직도를 수동으로 입력하는 것은 불가능하므로, 시스템은 **CSV 일괄 등록(Bulk Import)** API를 제공해야 합니다. -1. **조직 계층 동기화 (Hierarchy):** - * DB에서 A팀(`UserGroup`)이 B본부(`UserGroup`)의 하위로 설정되면, Keto에는 `UserGroup:#parent@UserGroup:` 튜플이 생성됩니다. -2. **소속원 매핑 (Membership):** - * 유저가 A팀에 속하면 `UserGroup:#members@User:<유저_ID>` 튜플이 생성됩니다. -3. **조직장 및 어드민 승격 (Leadership):** - * CSV 데이터 분석 또는 수동 지정을 통해 특정 유저가 A팀의 '조직장'으로 식별되면, `UserGroup:#owners@User:<유저_ID>` 튜플이 생성됩니다. - * 정책에 따라 `owners` 관계를 가진 유저는 해당 조직(UserGroup)과 그 하위 조직에 대한 `admins` 권한을 자동으로 상속받습니다. +1. **데이터 준비:** 엑셀 데이터를 CSV로 변환합니다. (반드시 이메일 또는 사번 컬럼 포함) +2. **조직(Tenant) 순차 생성 (Upsert):** + * 스크립트는 CSV의 그룹 ➔ 디비젼 ➔ 팀 ➔ 셀 순서로 읽으며, 없는 조직은 생성하고 상위 조직의 ID를 `parent_id`로 연결합니다. + * 생성 시 백엔드 `TenantService`는 자동으로 Keto에 `parents` 튜플을 동기화합니다. +3. **사용자(User) 계정 생성:** + * Ory Kratos에 계정을 생성하고(`POST /identities`), 로컬 DB `users` 테이블에 직급, 직무 등의 메타데이터를 저장합니다. +4. **멤버십(Keto 튜플) 매핑:** + * 사용자가 속한 **가장 깊은(Deepest) 말단 조직 단위 하나**를 찾습니다. + * 직책(장급/일반)에 따라 `owners` 또는 `members` 권한을 Keto에 부여합니다. ---- - -## 5. Data Loading & CSV Upload Strategy (데이터 로딩 및 CSV 업로드 전략) - -고정된 컬럼 구조는 다양한 회사의 조직도를 수용할 수 없으므로 유연한 파싱 로직이 필요합니다. - -### 5.1 Flexible CSV Format (유연한 CSV 포맷) -* **경로 기반 방식 (Path-based):** 조직 계층을 슬래시(`/`) 등으로 구분하여 하나의 문자열로 전달받습니다. - * *예시 컬럼:* `[조직_경로, 직급, 이름, 직무, 이메일]` - * *데이터 예시:* `"개발본부/클라우드실/플랫폼팀", "수석", "홍길동", "백엔드 개발", "hong@example.com"` -* **동적 뎁스 방식 (Dynamic Depth):** 뒤에서부터 고정된 사용자 속성 열(직급, 직무, 이름, 이메일 등)을 식별하고, 그 앞의 모든 열을 동적인 계층 구조로 해석합니다. - -### 5.2 Processing Flow (처리 흐름) -1. **Parsing & Validation:** 프론트엔드/백엔드에서 유연한 CSV 포맷을 파싱하고, `UserGroup` 계층 경로를 분석합니다. -2. **Tree Resolution:** 백엔드는 "개발본부 > 클라우드실 > 플랫폼팀" 경로를 DB에서 조회하거나 없으면 순차적으로 생성(`parent_id` 매핑)하여 `UserGroup` ID 트리를 완성합니다. -3. **User Upsert:** `User` 정보를 생성하거나 업데이트(`position`, `job_title` 갱신)합니다. -4. **Keto Synchronization:** DB 트랜잭션 완료 후, Background Worker가 변경된 조직 계층과 멤버십 정보를 기반으로 Ory Keto 튜플을 생성/삭제(Reconciliation)합니다. - ---- - -## 6. Frontend Multi-Tenancy UI (프론트엔드 다중 테넌트 UI) - -관리자가 여러 테넌트(Company)에 접근 권한이 있을 경우, 조직도를 명확히 구분하여 보여주어야 합니다. - -* **Tabs Interface:** 화면 상단 또는 측면에 사용자가 접근 가능한 최상위 `Tenant` 목록을 탭(Tabs) 형태로 제공합니다. -* **Scoped Fetching:** 특정 탭(Tenant)을 선택할 때마다 해당 `tenant_id`를 파라미터로 백엔드 API를 호출하여, 격리된 해당 회사만의 `UserGroup` 트리를 렌더링합니다. -* **Checkbox Tree Component:** Radix UI와 TailwindCSS를 기반으로 개발되며, N-Depth 중첩을 지원하고 부모-자식 간의 반선택(Indeterminate) 상태를 재귀적으로 계산하는 독립적인(Reusable) 컴포넌트로 구현됩니다. \ No newline at end of file +이 정책을 통해 복잡한 매트릭스 조직과 권한 체계를 단일 아키텍처로 우아하게 통합할 수 있습니다. diff --git a/locales/en.toml b/locales/en.toml index 35d8e8c0..97f5d68a 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -1011,7 +1011,9 @@ user = "TENANT MEMBER" [ui.admin.tenants] add = "Add Tenant" +delete_selected = "Delete Selected" title = "Tenant Registry" +view_org_chart = "View Full Org Chart" [ui.admin.tenants.admins] add_button = "Add Button" @@ -1068,7 +1070,8 @@ tab_schema = "Tab Schema" title = "Details" [ui.admin.tenants.list] -select_placeholder = "Select Placeholder" +search_placeholder = "Search tenant by name or slug..." +select_placeholder = "Select a tenant" [ui.admin.tenants.members] descendants = "Descendant Members" @@ -1079,6 +1082,30 @@ title = "Tenant Members ({{count}})" total = "Total" total_label = "Total" +[msg.admin.apikeys.registry] +count = "There are {{count}} active keys registered." + +[msg.admin.org] +import_partial_success = "Imported some organization data successfully." + +[msg.admin.tenants] +delete_bulk_confirm = "Delete {{count}} selected tenants?" + +[msg.admin.users] +self_delete_blocked = "You cannot delete your own account." + +[ui.admin.apikeys.registry] +title = "API Key Registry" + +[ui.admin.tenants.members] +delete_selected = "Delete Selected" +view_org_chart = "View Full Org Chart" +direct_label = "Direct" +list_title = "Member Management" +title = "Tenant Members ({{count}})" +total = "Total" +total_label = "Total" + [ui.admin.tenants.members.table] email = "EMAIL" name = "NAME" diff --git a/locales/ko.toml b/locales/ko.toml index a89cea92..948f90b3 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -328,7 +328,9 @@ user = "TENANT MEMBER" [ui.admin.tenants] add = "테넌트 추가" +delete_selected = "선택 삭제" title = "테넌트 목록" +view_org_chart = "전체 조직도 보기" [ui.common.badge] admin_only = "Admin only" @@ -1408,7 +1410,9 @@ user = "TENANT MEMBER" [ui.admin.tenants] add = "테넌트 추가" +delete_selected = "선택 삭제" title = "테넌트 목록" +view_org_chart = "전체 조직도 보기" [ui.admin.tenants.admins] add_button = "관리자 추가" @@ -1465,6 +1469,7 @@ tab_schema = "사용자 스키마" title = "상세" [ui.admin.tenants.list] +search_placeholder = "테넌트 이름 또는 슬러그 검색..." select_placeholder = "테넌트를 선택하세요" [ui.admin.tenants.members] @@ -1476,6 +1481,30 @@ title = "테넌트 구성원 ({{count}})" total = "전체" total_label = "전체" +[msg.admin.apikeys.registry] +count = "총 {{count}}개의 활성 키가 등록되어 있습니다." + +[msg.admin.org] +import_partial_success = "일부 조직 정보를 가져왔습니다." + +[msg.admin.tenants] +delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?" + +[msg.admin.users] +self_delete_blocked = "자신의 계정은 삭제할 수 없습니다." + +[ui.admin.apikeys.registry] +title = "API Key Registry" + +[ui.admin.tenants.members] +delete_selected = "선택 삭제" +view_org_chart = "전체 조직도 보기" +direct_label = "직속" +list_title = "구성원 관리" +title = "테넌트 구성원 ({{count}})" +total = "전체" +total_label = "전체" + [ui.admin.tenants.members.table] email = "EMAIL" name = "NAME" diff --git a/locales/template.toml b/locales/template.toml index 1b753e5e..e400961b 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -203,7 +203,9 @@ user = "" [ui.admin.tenants] add = "" +delete_selected = "" title = "" +view_org_chart = "" [ui.common.badge] admin_only = "" @@ -1283,7 +1285,9 @@ user = "" [ui.admin.tenants] add = "" +delete_selected = "" title = "" +view_org_chart = "" [ui.admin.tenants.admins] add_button = "" @@ -1340,6 +1344,7 @@ tab_schema = "" title = "" [ui.admin.tenants.list] +search_placeholder = "" select_placeholder = "" [ui.admin.tenants.members] @@ -1351,6 +1356,30 @@ title = "" total = "" total_label = "" +[msg.admin.apikeys.registry] +count = "" + +[msg.admin.org] +import_partial_success = "" + +[msg.admin.tenants] +delete_bulk_confirm = "" + +[msg.admin.users] +self_delete_blocked = "" + +[ui.admin.apikeys.registry] +title = "" + +[ui.admin.tenants.members] +delete_selected = "" +view_org_chart = "" +direct_label = "" +list_title = "" +title = "" +total = "" +total_label = "" + [ui.admin.tenants.members.table] email = "" name = "" diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 2e3ae269..252fdf32 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -981,6 +981,23 @@ class AuthProxyService { } } + static Future>> getActiveTenants({ + String? email, + }) async { + var uriString = '$_baseUrl/api/v1/auth/signup/tenants'; + if (email != null && email.isNotEmpty) { + uriString += '?email=${Uri.encodeComponent(email)}'; + } + + final url = Uri.parse(uriString); + final response = await http.get(url); + if (response.statusCode == 200) { + final List list = jsonDecode(response.body); + return list.cast>(); + } + return []; + } + static Future sendSignupCode(String target, String type) async { final path = type == 'email' ? 'send-email-code' : 'send-sms-code'; final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path'); @@ -1000,7 +1017,7 @@ class AuthProxyService { } } - static Future verifySignupCode( + static Future> verifySignupCode( String target, String type, String code, @@ -1014,10 +1031,9 @@ class AuthProxyService { ); if (response.statusCode == 200) { - final data = jsonDecode(response.body); - return data['success'] ?? false; + return jsonDecode(response.body); } - return false; + throw Exception('Verification failed'); } static Future signup({ @@ -1048,8 +1064,18 @@ class AuthProxyService { ); if (response.statusCode != 200) { - final error = jsonDecode(response.body)['error'] ?? 'Signup failed'; - throw Exception(error); + String errorMessage = 'Signup failed'; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map && decoded.containsKey('error')) { + errorMessage = decoded['error']; + } + } catch (e) { + // Fallback if the body isn't valid JSON (e.g., an HTML error page) + errorMessage = + 'Server error (${response.statusCode}): ${response.body.isNotEmpty ? response.body.substring(0, response.body.length > 100 ? 100 : response.body.length) : "Unknown error"}'; + } + throw Exception(errorMessage); } } } diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 838e30c6..af3c9813 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -44,8 +44,8 @@ class _SignupScreenState extends State { bool _isEmailVerified = false; bool _isPhoneVerified = false; String _affiliationType = 'GENERAL'; + bool _isAffiliateLocked = false; String? _companyCode; - bool _isAffiliateEmail = false; // 가족사 이메일 여부 bool _termsAccepted = false; bool _privacyAccepted = false; bool _isLoading = false; @@ -54,6 +54,9 @@ class _SignupScreenState extends State { bool _isPasswordObscured = true; bool _isConfirmPasswordObscured = true; + // Dynamic Tenants + List> _tenants = []; + // Inline Errors String? _emailError; String? _phoneError; @@ -66,16 +69,6 @@ class _SignupScreenState extends State { Timer? _phoneTimer; int _phoneSeconds = 0; - // 가족사 도메인 맵 - final Map _affiliateDomains = { - 'hanmaceng.co.kr': 'HANMAC', - 'samaneng.com': 'SAMAN', - 'jangheon.co.kr': 'JANGHEON', - 'hallasanup.com': 'HALLA', - 'pre-cast.co.kr': 'PTC', - 'baroncs.co.kr': 'BARON', - }; - String _renderTranslatedText( String key, { String? fallback, @@ -92,6 +85,28 @@ class _SignupScreenState extends State { void initState() { super.initState(); _loadPolicy(); + // initState에서는 _fetchTenants() 호출 제외 + } + + Future _fetchTenants() async { + if (!_isEmailVerified) return; + + try { + final tenants = await AuthProxyService.getActiveTenants( + email: _emailController.text.trim(), + ); + if (mounted) { + setState(() { + _tenants = tenants; + if (_tenants.isNotEmpty && _affiliationType == 'AFFILIATE') { + // 목록이 있는데 아직 아무것도 선택되지 않았다면 자동 할당 가능 + _companyCode ??= _tenants.first['slug']; + } + }); + } + } catch (e) { + debugPrint('Failed to load tenants: $e'); + } } Future _loadPolicy() async { @@ -121,35 +136,11 @@ class _SignupScreenState extends State { super.dispose(); } - // 이메일 입력 시 도메인 체크 로직 + // 이메일 입력 시 도메인 체크 로직 (자동 선택 제거) void _checkEmailAffiliation(String email) { - if (!email.contains('@')) { - if (_isAffiliateEmail) { - setState(() { - _isAffiliateEmail = false; - _affiliationType = 'GENERAL'; - _companyCode = null; - }); - } - return; - } - - final domain = email.split('@').last.toLowerCase(); - if (_affiliateDomains.containsKey(domain)) { - setState(() { - _isAffiliateEmail = true; - _affiliationType = 'AFFILIATE'; - _companyCode = _affiliateDomains[domain]; - }); - } else { - if (_isAffiliateEmail) { - setState(() { - _isAffiliateEmail = false; - _affiliationType = 'GENERAL'; - _companyCode = null; - }); - } - } + // Note: We no longer auto-set _companyCode or _affiliationType based on domain + // as per user requirement (same domain can belong to different affiliates). + return; } void _startTimer(String type) { @@ -225,18 +216,32 @@ class _SignupScreenState extends State { final code = _emailCodeController.text.trim(); if (code.length != 6) return; try { - final success = await AuthProxyService.verifySignupCode( + final res = await AuthProxyService.verifySignupCode( _emailController.text.trim(), 'email', code, ); - if (success) { + if (res['success'] == true) { setState(() { _isEmailVerified = true; _emailTimer?.cancel(); _emailSeconds = 0; _emailError = null; + + if (res['isAffiliate'] == true) { + _affiliationType = 'AFFILIATE'; + _isAffiliateLocked = true; + } else { + _affiliationType = 'GENERAL'; + _companyCode = null; + _isAffiliateLocked = true; + } }); + + // Only fetch tenants if it's an affiliate domain + if (res['isAffiliate'] == true) { + _fetchTenants(); + } } else { setState( () => _emailError = tr('msg.userfront.signup.email.code_mismatch'), @@ -278,12 +283,12 @@ class _SignupScreenState extends State { final code = _phoneCodeController.text.trim(); if (code.length != 6) return; try { - final success = await AuthProxyService.verifySignupCode( + final res = await AuthProxyService.verifySignupCode( _phoneController.text.trim(), 'phone', code, ); - if (success) { + if (res['success'] == true) { setState(() { _isPhoneVerified = true; _phoneTimer?.cancel(); @@ -1446,55 +1451,44 @@ class _SignupScreenState extends State { const SizedBox(height: 18), _buildProfileFieldGroup( title: tr('ui.userfront.signup.profile.affiliation_type'), - description: _isAffiliateEmail - ? tr('msg.userfront.signup.profile.affiliate_hint') - : '소속 유형과 회사 정보를 입력합니다.', + description: '소속 유형과 회사 정보를 입력합니다.', isDesktop: isDesktop, - trailing: _isAffiliateEmail - ? _buildAutoDetectedBadge() - : null, + trailing: null, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - AbsorbPointer( - absorbing: _isAffiliateEmail, - child: Opacity( - opacity: _isAffiliateEmail ? 0.7 : 1.0, - child: DropdownButtonFormField( - key: ValueKey(_affiliationType), - initialValue: _affiliationType, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.signup.profile.affiliation_type', - ), - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem( - value: 'GENERAL', - child: Text( - tr('domain.affiliation.general'), - ), - ), - DropdownMenuItem( - value: 'AFFILIATE', - child: Text( - tr('domain.affiliation.affiliate'), - ), - ), - ], - onChanged: _isAffiliateEmail - ? null - : (val) { - if (val == null) { - return; - } - setState(() { - _affiliationType = val; - }); - }, + DropdownButtonFormField( + key: ValueKey(_affiliationType), + initialValue: _affiliationType, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.profile.affiliation_type', ), + border: const OutlineInputBorder(), ), + items: [ + DropdownMenuItem( + value: 'GENERAL', + child: Text(tr('domain.affiliation.general')), + ), + DropdownMenuItem( + value: 'AFFILIATE', + child: Text(tr('domain.affiliation.affiliate')), + ), + ], + onChanged: _isAffiliateLocked + ? null + : (val) { + if (val == null) { + return; + } + setState(() { + _affiliationType = val; + if (_affiliationType == 'GENERAL') { + _companyCode = null; + } + }); + }, ), AnimatedSize( duration: const Duration(milliseconds: 180), @@ -1504,67 +1498,23 @@ class _SignupScreenState extends State { children: [ if (_affiliationType == 'AFFILIATE') ...[ const SizedBox(height: 14), - AbsorbPointer( - absorbing: _isAffiliateEmail, - child: Opacity( - opacity: _isAffiliateEmail ? 0.7 : 1.0, - child: DropdownButtonFormField( - key: ValueKey(_companyCode ?? 'none'), - initialValue: _companyCode, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.signup.profile.company', - ), - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem( - value: 'HANMAC', - child: Text( - tr('domain.company.hanmac'), - ), - ), - DropdownMenuItem( - value: 'SAMAN', - child: Text( - tr('domain.company.saman'), - ), - ), - DropdownMenuItem( - value: 'PTC', - child: Text( - tr( - 'domain.company.ptc', - fallback: 'PTC', - ), - ), - ), - DropdownMenuItem( - value: 'JANGHEON', - child: Text( - tr('domain.company.jangheon'), - ), - ), - DropdownMenuItem( - value: 'BARON', - child: Text( - tr('domain.company.baron'), - ), - ), - DropdownMenuItem( - value: 'HALLA', - child: Text( - tr('domain.company.halla'), - ), - ), - ], - onChanged: _isAffiliateEmail - ? null - : (val) => setState( - () => _companyCode = val, - ), + DropdownButtonFormField( + key: ValueKey(_companyCode ?? 'none'), + initialValue: _companyCode, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.profile.company', ), + border: const OutlineInputBorder(), ), + items: _tenants.map((t) { + return DropdownMenuItem( + value: t['slug'], + child: Text(t['name'] ?? t['slug']), + ); + }).toList(), + onChanged: (val) => + setState(() => _companyCode = val), ), ], ], @@ -1608,9 +1558,7 @@ class _SignupScreenState extends State { } Widget _buildProfileInfoNoticeCard({required bool isDesktop}) { - final description = _isAffiliateEmail - ? '가족사 이메일이 확인되어 소속 유형이 자동으로 고정됩니다.' - : '회원가입 후 사용할 기본 소속 정보를 입력합니다.'; + const description = '회원가입 후 사용할 기본 소속 정보를 입력합니다.'; return DecoratedBox( decoration: BoxDecoration( @@ -1710,25 +1658,6 @@ class _SignupScreenState extends State { ); } - Widget _buildAutoDetectedBadge() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFFEEF2FF), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: const Color(0xFFC7D2FE)), - ), - child: const Text( - '자동 선택', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: Color(0xFF4338CA), - ), - ), - ); - } - String _buildPolicyDescription() { if (_isPolicyLoading) { return tr('msg.userfront.signup.policy.loading');