1
0
forked from baron/baron-sso

org chart 연동기능 추가

This commit is contained in:
2026-04-29 21:00:51 +09:00
parent 438f844f2b
commit 01e7b15c46
25 changed files with 5141 additions and 2940 deletions

View File

@@ -1,295 +0,0 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
AlertCircle,
CheckCircle2,
Download,
FileText,
Loader2,
Upload,
} from "lucide-react";
import * as React from "react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { toast } from "../../../components/ui/use-toast";
import {
type ImportResult,
fetchImportProgress,
importOrgChart,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
interface OrgChartUploadModalProps {
tenantId: string;
onSuccess?: () => void;
}
export function OrgChartUploadModal({
tenantId,
onSuccess,
}: OrgChartUploadModalProps) {
const [open, setOpen] = React.useState(false);
const [file, setFile] = React.useState<File | null>(null);
const [result, setResult] = React.useState<ImportResult | null>(null);
const [progressId, setProgressId] = React.useState<string | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const mutation = useMutation({
mutationFn: ({ file, 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<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
setResult(null);
}
};
const handleUpload = () => {
if (file) {
const pid = Math.random().toString(36).substring(2, 15);
setProgressId(pid);
mutation.mutate({ file, pid });
}
};
const downloadTemplate = () => {
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;
a.download = "org_user_import_template.csv";
a.click();
URL.revokeObjectURL(url);
};
return (
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
setFile(null);
setResult(null);
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Upload size={14} />
{t("ui.admin.org.import_btn", "조직/사용자 통합 임포트")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.org.import_title", "조직/사용자 통합 일괄 등록")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.org.import_description",
"CSV 또는 XLSX 파일을 업로드하여 조직 테넌트와 사용자를 함께 생성/업데이트하고 멤버십을 매핑합니다.",
)}
</DialogDescription>
</DialogHeader>
{!result ? (
<div className="space-y-4 py-4">
<div className="flex justify-between items-center">
<Button
variant="ghost"
size="sm"
onClick={downloadTemplate}
className="gap-2"
disabled={mutation.isPending}
>
<Download size={14} />
{t("ui.admin.org.download_template", "템플릿 다운로드")}
</Button>
<input
type="file"
accept=".csv, .xlsx, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
disabled={mutation.isPending}
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
size="sm"
disabled={mutation.isPending}
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button>
</div>
{file && (
<div className="rounded-lg border p-4 bg-muted/20 flex flex-col gap-3">
<div className="flex items-center gap-3">
<FileText className="text-primary" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{file.name}</div>
<div className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(1)} KB
</div>
</div>
</div>
{mutation.isPending && progressId && (
<div className="w-full mt-2 animate-in fade-in slide-in-from-bottom-2 duration-500">
<div className="flex justify-between text-xs mb-1 font-medium text-muted-foreground">
<span> ...</span>
<span>
{percent}% ({progressData?.current || 0} /{" "}
{progressData?.total || 0})
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden relative">
<div
className="bg-primary h-full rounded-full transition-all duration-300 ease-out absolute top-0 left-0"
style={{ width: `${Math.max(5, percent)}%` }}
/>
</div>
</div>
)}
</div>
)}
</div>
) : (
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-primary/5 border border-primary/10">
<div className="text-sm font-medium text-muted-foreground">
</div>
<div className="text-2xl font-bold">{result.totalRows}</div>
</div>
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/10">
<div className="text-sm font-medium text-muted-foreground">
</div>
<div className="text-2xl font-bold text-green-600">
{result.processed}
</div>
</div>
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/10">
<div className="text-sm font-medium text-muted-foreground">
/
</div>
<div className="text-xl font-bold text-blue-600">
{result.userCreated} / {result.userUpdated}
</div>
</div>
<div className="p-4 rounded-lg bg-orange-500/5 border border-orange-500/10">
<div className="text-sm font-medium text-muted-foreground">
()
</div>
<div className="text-2xl font-bold text-orange-600">
{result.tenantCreated}
</div>
</div>
</div>
{result.errors.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
<AlertCircle size={16} />
({result.errors.length})
</div>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 bg-destructive/5 text-xs font-mono space-y-1">
{result.errors.map((err, idx) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: Errors are a static list returned from the server.
key={idx}
className="text-destructive border-b border-destructive/10 pb-1 last:border-0"
>
{" "}
{err}
</div>
))}
</div>
</div>
)}
</div>
)}
<DialogFooter>
{!result ? (
<Button
onClick={handleUpload}
disabled={!file || mutation.isPending}
className="w-full sm:w-auto relative"
>
{mutation.isPending ? (
<>
<Loader2 size={16} className="mr-2 animate-spin" />
({percent}%)
</>
) : (
t("ui.admin.org.start_import", "임포트 시작")
)}
</Button>
) : (
<Button onClick={() => setOpen(false)} className="w-full sm:w-auto">
{t("ui.common.close", "닫기")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -47,7 +47,6 @@ import {
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
type UserGroupNode = GroupSummary & {
children: UserGroupNode[];
@@ -445,10 +444,6 @@ function TenantGroupsPage() {
</CardDescription>
</div>
<div className="flex items-center gap-2">
<OrgChartUploadModal
tenantId={tenantId}
onSuccess={() => groupsQuery.refetch()}
/>
<Button
variant="ghost"
size="sm"

View File

@@ -1,12 +1,14 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
CornerDownRight,
Download,
FileSpreadsheet,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
Upload,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
@@ -21,6 +23,14 @@ import {
CardTitle,
} from "../../../components/ui/card";
import { Checkbox } from "../../../components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
@@ -34,16 +44,35 @@ import {
type TenantSummary,
deleteTenant,
deleteTenantsBulk,
exportTenantsCSV,
fetchMe,
fetchTenants,
importTenantsCSV,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
import {
type TenantImportPreviewRow,
buildTenantImportPreview,
parseTenantCSV,
serializeTenantImportCSV,
} from "../utils/tenantCsvImport";
const tenantCSVTemplate =
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n";
function TenantListPage() {
const navigate = useNavigate();
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [search, setSearch] = React.useState("");
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [importMessage, setImportMessage] = React.useState("");
const [previewRows, setPreviewRows] = React.useState<
TenantImportPreviewRow[]
>([]);
const [selectedMatches, setSelectedMatches] = React.useState<
Record<number, string>
>({});
const [previewOpen, setPreviewOpen] = React.useState(false);
const { data: profile } = useQuery({
queryKey: ["me"],
@@ -87,6 +116,50 @@ function TenantListPage() {
},
});
const exportMutation = useMutation({
mutationFn: exportTenantsCSV,
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
});
const importMutation = useMutation({
mutationFn: (file: File) => importTenantsCSV(file),
onSuccess: (result) => {
setImportMessage(
t(
"msg.admin.tenants.import_result",
"생성 {{created}}, 갱신 {{updated}}, 실패 {{failed}}",
{
created: result.created,
updated: result.updated,
failed: result.failed,
},
),
);
setPreviewOpen(false);
setPreviewRows([]);
setSelectedMatches({});
query.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
setImportMessage(
error.response?.data?.error ??
t(
"msg.admin.tenants.import_error",
"테넌트 가져오기에 실패했습니다.",
),
);
},
});
if (
profile &&
profile.role !== "super_admin" &&
@@ -161,8 +234,50 @@ function TenantListPage() {
deleteBulkMutation.mutate(selectedIds);
};
const rootTenant =
tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
const handleTemplateDownload = () => {
const blob = new Blob([tenantCSVTemplate], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "tenant-import-template.csv";
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
};
const handleImportFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setImportMessage("");
const text = await file.text();
const rows = parseTenantCSV(text);
if (rows.length === 0) {
setImportMessage(
t("msg.admin.tenants.import_empty", "가져올 테넌트 행이 없습니다."),
);
return;
}
const preview = buildTenantImportPreview(rows, allTenants);
setPreviewRows(preview);
setSelectedMatches(
Object.fromEntries(
preview
.filter((row) => row.defaultTenantId)
.map((row) => [row.row.rowNumber, row.defaultTenantId]),
),
);
setPreviewOpen(true);
};
const handleImportConfirm = () => {
const csv = serializeTenantImportCSV(previewRows, selectedMatches);
const file = new File([csv], "tenants.csv", { type: "text/csv" });
importMutation.mutate(file);
};
const handleDelete = (tenantId: string, tenantName: string) => {
if (
@@ -210,10 +325,40 @@ function TenantListPage() {
</RoleGuard>
<RoleGuard roles={["super_admin"]}>
<OrgChartUploadModal
tenantId={rootTenant?.id || "root"}
onSuccess={() => query.refetch()}
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
data-testid="tenant-import-input"
onChange={handleImportFile}
/>
<Button
variant="outline"
onClick={handleTemplateDownload}
data-testid="tenant-template-btn"
>
<FileSpreadsheet size={16} />
{t("ui.admin.tenants.csv_template", "템플릿")}
</Button>
<Button
variant="outline"
onClick={() => exportMutation.mutate()}
disabled={exportMutation.isPending}
data-testid="tenant-export-btn"
>
<Download size={16} />
{t("ui.admin.tenants.export", "내보내기")}
</Button>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
data-testid="tenant-import-btn"
>
<Upload size={16} />
{t("ui.admin.tenants.import", "가져오기")}
</Button>
</RoleGuard>
<Button
@@ -233,6 +378,14 @@ function TenantListPage() {
</Button>
</RoleGuard>
</div>
{importMessage && (
<div
className="basis-full rounded-md border border-border bg-secondary px-3 py-2 text-sm"
data-testid="tenant-import-result"
>
{importMessage}
</div>
)}
</header>
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -247,7 +400,7 @@ function TenantListPage() {
})}
</CardDescription>
</div>
</CardHeader>{" "}
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="mb-6 flex items-center gap-4 flex-shrink-0">
<div className="relative flex-1 min-w-[240px] max-w-sm">
@@ -410,6 +563,139 @@ function TenantListPage() {
</div>
</CardContent>
</Card>
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.import_preview.title", "CSV 가져오기 확인")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.import_preview.description",
"tenant_id가 없는 행은 기존 테넌트 후보와 비교한 뒤 신규 생성 또는 기존 테넌트 갱신으로 처리합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-auto rounded-md border">
<Table>
<TableHeader className="sticky top-0 bg-secondary">
<TableRow>
<TableHead className="w-[72px]">
{t("ui.common.row", "행")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.import_preview.match", "매칭")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.import_preview.candidates", "후보")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{previewRows.map((preview) => (
<TableRow
key={preview.row.rowNumber}
data-testid={`tenant-import-preview-row-${preview.row.rowNumber}`}
>
<TableCell className="font-mono text-xs">
{preview.row.rowNumber}
</TableCell>
<TableCell className="font-medium">
{preview.row.name}
</TableCell>
<TableCell className="font-mono text-xs">
{preview.row.slug}
</TableCell>
<TableCell>
{preview.row.tenantId ? (
<Badge variant="outline">
{t(
"ui.admin.tenants.import_preview.fixed_id",
"ID 지정됨",
)}
</Badge>
) : (
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={selectedMatches[preview.row.rowNumber] ?? ""}
data-testid={`tenant-import-match-select-${preview.row.rowNumber}`}
onChange={(event) =>
setSelectedMatches((prev) => ({
...prev,
[preview.row.rowNumber]: event.target.value,
}))
}
>
<option value="">
{t(
"ui.admin.tenants.import_preview.create_new",
"신규 생성",
)}
</option>
{preview.candidates.map((candidate) => (
<option
key={candidate.tenantId}
value={candidate.tenantId}
>
{candidate.name} ({candidate.slug})
</option>
))}
</select>
)}
</TableCell>
<TableCell>
{preview.candidates.length > 0 ? (
<div className="flex flex-wrap gap-1">
{preview.candidates.map((candidate) => (
<Badge
key={candidate.tenantId}
variant={
candidate.score >= 0.95 ? "default" : "outline"
}
data-testid="tenant-import-candidate"
>
{candidate.name}{" "}
{Math.round(candidate.score * 100)}%
</Badge>
))}
</div>
) : (
<span className="text-sm text-muted-foreground">
{t(
"ui.admin.tenants.import_preview.no_candidates",
"후보 없음",
)}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewOpen(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={handleImportConfirm}
disabled={importMutation.isPending}
data-testid="tenant-import-confirm-btn"
>
{t("ui.admin.tenants.import_preview.confirm", "가져오기 실행")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
buildTenantImportPreview,
parseTenantCSV,
serializeTenantImportCSV,
} from "./tenantCsvImport";
const tenants: TenantSummary[] = [
{
id: "tenant-1",
type: "COMPANY",
name: "Hanmac Technology",
slug: "hanmac",
description: "",
status: "active",
domains: ["hanmac.example.com"],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "tenant-2",
type: "COMPANY",
name: "Saman Engineering",
slug: "saman",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
describe("tenantCsvImport", () => {
it("parses tenant CSV rows with the supported import columns", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n",
);
expect(rows).toEqual([
{
rowNumber: 2,
tenantId: "",
name: "Hanmac Tech",
type: "COMPANY",
parentTenantId: "",
slug: "hanmac-tech",
memo: "Memo",
emailDomain: "hanmac-tech.example.com",
},
]);
});
it("puts tenant_id-less rows with exact or similar matches first", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,New Tenant,COMPANY,,new-tenant,,\n,Hanmac Tech,COMPANY,,hanmac-tech,,\n,Saman Engineering,COMPANY,,saman-copy,,\n",
);
const preview = buildTenantImportPreview(rows, tenants);
expect(preview.map((row) => row.row.name)).toEqual([
"Saman Engineering",
"Hanmac Tech",
"New Tenant",
]);
expect(preview[0].candidates[0]).toMatchObject({
tenantId: "tenant-2",
reason: "exact_name",
});
expect(preview[1].candidates[0]).toMatchObject({
tenantId: "tenant-1",
reason: "similar_name",
});
expect(preview[2].candidates).toEqual([]);
});
it("serializes selected matches by filling tenant_id before upload", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n",
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: "tenant-1",
});
expect(csv).toContain(
"tenant-1,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com",
);
});
});

View File

@@ -0,0 +1,264 @@
import type { TenantSummary } from "../../../lib/adminApi";
export type TenantCSVRow = {
rowNumber: number;
tenantId: string;
name: string;
type: string;
parentTenantId: string;
slug: string;
memo: string;
emailDomain: string;
};
export type TenantImportCandidate = {
tenantId: string;
name: string;
slug: string;
score: number;
reason: "exact_name" | "exact_slug" | "similar_name";
};
export type TenantImportPreviewRow = {
row: TenantCSVRow;
candidates: TenantImportCandidate[];
defaultTenantId: string;
};
const importHeaders = [
"tenant_id",
"name",
"type",
"parent_tenant_id",
"slug",
"memo",
"email_domain",
];
const headerAliases: Record<string, keyof TenantCSVRow> = {
id: "tenantId",
tenantid: "tenantId",
tenant_id: "tenantId",
name: "name",
type: "type",
parentid: "parentTenantId",
parent_id: "parentTenantId",
parenttenantid: "parentTenantId",
parent_tenant_id: "parentTenantId",
slug: "slug",
memo: "memo",
description: "memo",
"email-domain": "emailDomain",
emaildomain: "emailDomain",
email_domain: "emailDomain",
domain: "emailDomain",
domains: "emailDomain",
};
export function parseTenantCSV(text: string): TenantCSVRow[] {
const records = parseCSV(text.replace(/^\uFEFF/, ""));
if (records.length === 0) return [];
const header = new Map<keyof TenantCSVRow, number>();
records[0].forEach((column, index) => {
const normalized = normalizeHeader(column);
const key = headerAliases[normalized];
if (key) header.set(key, index);
});
return records.slice(1).flatMap((record, index) => {
if (record.every((value) => value.trim() === "")) return [];
const value = (key: keyof TenantCSVRow) => {
const columnIndex = header.get(key);
if (columnIndex === undefined) return "";
return (record[columnIndex] ?? "").trim();
};
return {
rowNumber: index + 2,
tenantId: value("tenantId"),
name: value("name"),
type: value("type"),
parentTenantId: value("parentTenantId"),
slug: value("slug"),
memo: value("memo"),
emailDomain: value("emailDomain"),
};
});
}
export function buildTenantImportPreview(
rows: TenantCSVRow[],
tenants: TenantSummary[],
): TenantImportPreviewRow[] {
return rows
.map((row) => {
const candidates = row.tenantId ? [] : findTenantCandidates(row, tenants);
return {
row,
candidates,
defaultTenantId:
candidates[0] && candidates[0].score >= 0.95
? candidates[0].tenantId
: "",
};
})
.sort((a, b) => {
const aScore = a.candidates[0]?.score ?? 0;
const bScore = b.candidates[0]?.score ?? 0;
if (bScore !== aScore) return bScore - aScore;
return a.row.rowNumber - b.row.rowNumber;
});
}
export function serializeTenantImportCSV(
previewRows: TenantImportPreviewRow[],
selectedTenantIds: Record<number, string>,
) {
const lines = [importHeaders];
for (const preview of [...previewRows].sort(
(a, b) => a.row.rowNumber - b.row.rowNumber,
)) {
const selectedTenantId = selectedTenantIds[preview.row.rowNumber] ?? "";
lines.push([
preview.row.tenantId || selectedTenantId,
preview.row.name,
preview.row.type,
preview.row.parentTenantId,
preview.row.slug,
preview.row.memo,
preview.row.emailDomain,
]);
}
return lines.map(formatCSVRecord).join("\n") + "\n";
}
function findTenantCandidates(
row: TenantCSVRow,
tenants: TenantSummary[],
): TenantImportCandidate[] {
return tenants
.map((tenant) => {
const nameScore = similarity(row.name, tenant.name);
const slugScore =
normalizeToken(row.slug) &&
normalizeToken(row.slug) === normalizeToken(tenant.slug)
? 0.98
: 0;
const exactName =
normalizeToken(row.name) === normalizeToken(tenant.name);
const score = exactName ? 1 : Math.max(slugScore, nameScore);
const reason: TenantImportCandidate["reason"] = exactName
? "exact_name"
: slugScore >= 0.98
? "exact_slug"
: "similar_name";
return {
tenantId: tenant.id,
name: tenant.name,
slug: tenant.slug,
score,
reason,
};
})
.filter((candidate) => candidate.score >= 0.45)
.sort((a, b) => b.score - a.score)
.slice(0, 3);
}
function parseCSV(text: string): string[][] {
const rows: string[][] = [];
let current = "";
let row: string[] = [];
let quoted = false;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
const next = text[i + 1];
if (char === '"' && quoted && next === '"') {
current += '"';
i += 1;
continue;
}
if (char === '"') {
quoted = !quoted;
continue;
}
if (char === "," && !quoted) {
row.push(current);
current = "";
continue;
}
if ((char === "\n" || char === "\r") && !quoted) {
if (char === "\r" && next === "\n") i += 1;
row.push(current);
rows.push(row);
row = [];
current = "";
continue;
}
current += char;
}
if (current !== "" || row.length > 0) {
row.push(current);
rows.push(row);
}
return rows;
}
function formatCSVRecord(record: string[]) {
return record
.map((value) => {
if (!/[",\r\n]/.test(value)) return value;
return `"${value.replaceAll('"', '""')}"`;
})
.join(",");
}
function normalizeHeader(value: string) {
return value.trim().toLowerCase().replaceAll(" ", "_");
}
function normalizeToken(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "")
.replace(/[^\p{L}\p{N}]/gu, "");
}
function similarity(left: string, right: string) {
const a = normalizeToken(left);
const b = normalizeToken(right);
if (!a || !b) return 0;
if (a === b) return 1;
if (a.includes(b) || b.includes(a)) {
return Math.min(a.length, b.length) / Math.max(a.length, b.length);
}
const distance = levenshtein(a, b);
return 1 - distance / Math.max(a.length, b.length);
}
function levenshtein(left: string, right: string) {
const previous = Array.from({ length: right.length + 1 }, (_, i) => i);
const current = Array.from({ length: right.length + 1 }, () => 0);
for (let i = 1; i <= left.length; i += 1) {
current[0] = i;
for (let j = 1; j <= right.length; j += 1) {
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
current[j] = Math.min(
current[j - 1] + 1,
previous[j] + 1,
previous[j - 1] + cost,
);
}
previous.splice(0, previous.length, ...current);
}
return previous[right.length];
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,6 @@ import {
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -33,6 +32,7 @@ import {
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Switch } from "../../components/ui/switch";
import {
Table,
TableBody,
@@ -46,14 +46,14 @@ import {
bulkDeleteUsers,
bulkUpdateUsers,
deleteUser,
exportUsersCSVUrl,
exportUsersCSV,
fetchMe,
fetchTenant,
fetchTenants,
fetchUsers,
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
type UserSchemaField = {
@@ -144,6 +144,41 @@ function UserListPage() {
},
});
const exportMutation = useMutation({
mutationFn: () => exportUsersCSV(search, selectedCompany),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: () => {
toast.error(
t(
"msg.admin.users.export_error",
"사용자 내보내기에 실패했습니다.",
),
);
},
});
const statusMutation = useMutation({
mutationFn: ({ userId, status }: { userId: string; status: string }) =>
updateUser(userId, { status }),
onSuccess: () => {
query.refetch();
},
onError: () => {
toast.error(
t("msg.admin.users.status_error", "사용자 상태 변경에 실패했습니다."),
);
},
});
const handleSearch = () => {
setSearch(searchDraft);
setPage(1);
@@ -156,8 +191,7 @@ function UserListPage() {
};
const handleExport = () => {
const url = exportUsersCSVUrl(search, selectedCompany);
window.open(url, "_blank");
exportMutation.mutate();
};
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
@@ -275,7 +309,12 @@ function UserListPage() {
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button variant="outline" onClick={handleExport} className="gap-2">
<Button
variant="outline"
onClick={handleExport}
className="gap-2"
disabled={exportMutation.isPending}
>
<FileDown size={16} />
{t("ui.common.export", "내보내기")}
</Button>
@@ -428,21 +467,12 @@ function UserListPage() {
<TableHead className="min-w-[200px]">
{t(
"ui.admin.users.list.table.name_email",
"NAME / EMAIL",
"이름 / 이메일 / 전화번호",
)}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.status", "STATUS")}
</TableHead>
<TableHead>
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
)}
</TableHead>
{/* Dynamic Columns from Schema */}
{userSchema.map(
(field) =>
@@ -464,7 +494,7 @@ function UserListPage() {
{query.isLoading && (
<TableRow>
<TableCell
colSpan={7 + userSchema.length}
colSpan={5 + userSchema.length}
className="h-24 text-center"
>
{t("msg.common.loading", "로딩 중...")}
@@ -474,7 +504,7 @@ function UserListPage() {
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell
colSpan={7 + userSchema.length}
colSpan={5 + userSchema.length}
className="h-24 text-center"
>
{t(
@@ -513,35 +543,47 @@ function UserListPage() {
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<User size={16} />
</div>
<div className="flex flex-col">
<div
className="truncate text-sm"
data-testid={`user-contact-${user.id}`}
>
<span className="font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground">
{" "}
{user.email}
</span>
{user.phone && (
<span className="text-muted-foreground">
{" "}
{user.phone}
</span>
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{t(`ui.admin.role.${user.role}`, user.role)}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active" ? "default" : "secondary"
}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span className="font-medium text-blue-600">
{user.tenant?.name || user.tenantSlug || "-"}
</span>
<span className="text-xs text-muted-foreground">
{user.department || "-"}
<div className="flex items-center gap-2">
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) =>
statusMutation.mutate({
userId: user.id,
status: checked ? "active" : "inactive",
})
}
disabled={
statusMutation.isPending ||
user.id === profile?.id
}
aria-label={t(
"ui.admin.users.list.toggle_status",
"{{name}} 활성 상태",
{ name: user.name },
)}
data-testid={`user-status-toggle-${user.id}`}
/>
<span className="text-sm text-muted-foreground">
{t(`ui.common.status.${user.status}`, user.status)}
</span>
</div>
</TableCell>
@@ -625,16 +667,6 @@ function UserListPage() {
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
<UserBulkMoveGroupModal
userIds={selectedUserIds}
selectedUsers={items.filter((u) =>
selectedUserIds.includes(u.id),
)}
onSuccess={() => {
query.refetch();
setSelectedUserIds([]);
}}
/>
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
buildOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
describe("orgChartPicker", () => {
it("builds the tenant picker embed URL from VITE_ORGCHART_URL", () => {
expect(buildOrgChartTenantPickerUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600",
);
});
it("adds tenantId to the tenant picker URL when Hanmac family scope is known", () => {
expect(
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
tenantId: "hanmac-family-id",
}),
).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600&tenantId=hanmac-family-id",
);
});
it("wraps the picker URL with the org-chart auto login entry", () => {
expect(
buildAuthenticatedOrgChartTenantPickerUrl(
"https://orgchart.example.com",
{
tenantId: "hanmac-family-id",
},
),
).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id",
);
});
it("parses the first tenant id and name from orgfront confirm messages", () => {
expect(
parseOrgChartTenantSelection({
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [
{
type: "tenant",
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
},
],
},
}),
).toEqual({
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
});
});
it("ignores non-tenant or malformed picker messages", () => {
expect(
parseOrgChartTenantSelection({
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [{ type: "user", id: "u-1", name: "User" }],
},
}),
).toBeNull();
expect(parseOrgChartTenantSelection({ type: "other" })).toBeNull();
});
it("filters Hanmac family subtree and system tenants from non-family tenant choices", () => {
const visibleTenants = filterNonHanmacFamilyTenants(
[
{
id: "system-id",
slug: "system",
name: "System",
type: "SYSTEM",
parentId: undefined,
},
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
type: "USER_GROUP",
parentId: "hanmac-company-id",
},
],
"hanmac-family-id",
);
expect(visibleTenants.map((tenant) => tenant.slug)).toEqual(["external"]);
});
});

View File

@@ -0,0 +1,142 @@
export type OrgChartTenantSelection = {
id: string;
name: string;
};
export type TenantFilterTarget = {
id?: string;
slug?: string;
type?: string;
parentId?: string | null;
};
type OrgChartPickerMessage = {
type?: unknown;
payload?: {
selections?: Array<{
type?: unknown;
id?: unknown;
name?: unknown;
}>;
};
};
type OrgChartTenantPickerOptions = {
tenantId?: string;
};
function isSystemTenant(tenant: TenantFilterTarget) {
const slug = tenant.slug?.trim().toLowerCase();
const type = tenant.type?.trim().toUpperCase();
return (
!tenant.id?.trim() ||
!tenant.slug?.trim() ||
type === "SYSTEM" ||
slug === "system" ||
slug === "global"
);
}
function isInTenantSubtree<T extends TenantFilterTarget>(
tenant: T,
rootTenantId: string,
tenantById: Map<string, T>,
) {
if (!rootTenantId) {
return false;
}
if (tenant.id === rootTenantId) {
return true;
}
const visitedTenantIds = new Set<string>();
let parentId = tenant.parentId ?? "";
while (parentId) {
if (parentId === rootTenantId) {
return true;
}
if (visitedTenantIds.has(parentId)) {
return false;
}
visitedTenantIds.add(parentId);
parentId = tenantById.get(parentId)?.parentId ?? "";
}
return false;
}
export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const rootTenantId = hanmacFamilyTenantId?.trim() ?? "";
const tenantById = new Map(
tenants
.filter((tenant) => tenant.id?.trim())
.map((tenant) => [tenant.id as string, tenant]),
);
return tenants.filter(
(tenant) =>
!isSystemTenant(tenant) &&
!isInTenantSubtree(tenant, rootTenantId, tenantById),
);
}
export function buildOrgChartTenantPickerUrl(
baseUrl?: string,
options: OrgChartTenantPickerOptions = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const params = new URLSearchParams({
mode: "single",
select: "tenant",
width: "400",
height: "600",
});
const tenantId = options.tenantId?.trim();
if (tenantId) {
params.set("tenantId", tenantId);
}
return `${normalizedBase}/embed/picker?${params.toString()}`;
}
export function buildAuthenticatedOrgChartTenantPickerUrl(
baseUrl?: string,
options: OrgChartTenantPickerOptions = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const pickerUrl = buildOrgChartTenantPickerUrl("", options);
const params = new URLSearchParams({
auto: "1",
returnTo: pickerUrl,
});
return `${normalizedBase}/login?${params.toString()}`;
}
export function parseOrgChartTenantSelection(
message: unknown,
): OrgChartTenantSelection | null {
const data = message as OrgChartPickerMessage;
if (data?.type !== "orgfront:picker:confirm") {
return null;
}
const selection = data.payload?.selections?.[0];
if (
selection?.type !== "tenant" ||
typeof selection.id !== "string" ||
typeof selection.name !== "string" ||
selection.id.trim() === ""
) {
return null;
}
return {
id: selection.id,
name: selection.name,
};
}

View File

@@ -63,6 +63,13 @@ export type TenantUpdateRequest = {
config?: Record<string, unknown>;
};
export type TenantImportResult = {
created: number;
updated: number;
failed: number;
errors: string[];
};
export type ApiKeySummary = {
id: string;
name: string;
@@ -145,6 +152,36 @@ export async function deleteTenantsBulk(ids: string[]) {
});
}
export async function exportTenantsCSV() {
const response = await apiClient.get<Blob>("/v1/admin/tenants/export", {
responseType: "blob",
});
const dispositionHeader = response.headers["content-disposition"];
const disposition = Array.isArray(dispositionHeader)
? dispositionHeader[0]
: String(dispositionHeader ?? "");
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/i);
return {
blob: response.data,
filename: filenameMatch?.[1] ?? "tenants.csv",
};
}
export async function importTenantsCSV(file: File) {
const formData = new FormData();
formData.append("file", file);
const { data } = await apiClient.post<TenantImportResult>(
"/v1/admin/tenants/import",
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
return data;
}
export async function approveTenant(tenantId: string) {
const { data } = await apiClient.post<TenantSummary>(
`/v1/admin/tenants/${tenantId}/approve`,
@@ -266,44 +303,6 @@ export async function removeGroupMember(
);
}
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;
@@ -412,6 +411,10 @@ export type UserCreateRequest = {
department?: string;
position?: string;
jobTitle?: string;
primaryTenantId?: string;
primaryTenantName?: string;
primaryTenantIsOwner?: boolean;
additionalAppointments?: UserAppointment[];
metadata?: Record<string, unknown>;
};
@@ -430,9 +433,22 @@ export type UserUpdateRequest = {
department?: string;
position?: string;
jobTitle?: string;
primaryTenantId?: string;
primaryTenantName?: string;
primaryTenantIsOwner?: boolean;
additionalAppointments?: UserAppointment[];
metadata?: Record<string, unknown>;
};
export type UserAppointment = {
tenantId: string;
tenantSlug?: string;
tenantName: string;
isOwner: boolean;
jobTitle?: string;
position?: string;
};
export type BulkUserItem = {
email: string;
loginId?: string;
@@ -492,19 +508,20 @@ export async function createUser(payload: UserCreateRequest) {
return data;
}
export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
const params = new URLSearchParams();
if (search) params.append("search", search);
if (tenantSlug) params.append("tenantSlug", tenantSlug);
// Get mock role from storage if exists for dev environment
const isMockRoleEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRole = window.localStorage.getItem("X-Mock-Role");
if (isMockRoleEnabled && mockRole) params.append("x-test-role", mockRole);
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
return `${baseUrl}/admin/users/export?${params.toString()}`;
export async function exportUsersCSV(search?: string, tenantSlug?: string) {
const response = await apiClient.get<Blob>("/v1/admin/users/export", {
params: { search, tenantSlug },
responseType: "blob",
});
const dispositionHeader = response.headers["content-disposition"];
const disposition = Array.isArray(dispositionHeader)
? dispositionHeader[0]
: String(dispositionHeader ?? "");
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/i);
return {
blob: response.data,
filename: filenameMatch?.[1] ?? "users.csv",
};
}
export async function bulkCreateUsers(users: BulkUserItem[]) {

View File

@@ -114,24 +114,100 @@ test.describe("Tenants Management", () => {
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
});
test("should clarify organization import creates org tenants and users together", async ({
test("should export and import tenant CSV without organization/user combined import", async ({
page,
}) => {
let exportRequested = false;
let importRequested = false;
let importBody = "";
await page.route("**/api/v1/admin/tenants**", async (route) => {
const url = route.request().url();
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/export")) {
exportRequested = true;
return route.fulfill({
body: "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n",
contentType: "text/csv",
headers: {
...headers,
"Content-Disposition": 'attachment; filename="tenants.csv"',
},
});
}
if (url.includes("/import")) {
importRequested = true;
importBody = route.request().postData() ?? "";
return route.fulfill({
json: { created: 0, updated: 1, failed: 0, errors: [] },
headers,
});
}
if (method === "GET") {
return route.fulfill({
json: {
items: [
{
id: "tenant-alpha-id",
name: "Tenant Alpha",
slug: "tenant-alpha",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 1,
limit: 1000,
offset: 0,
},
headers,
});
}
return route.continue();
});
await page.goto("/tenants");
await expect(page.locator("h2").last()).toContainText(
/테넌트 목록|Tenants/i,
{ timeout: 20000 },
);
await page
.locator("button")
.filter({ hasText: /임포트|Import/i })
.first()
.click();
await expect(page.getByText(/조직\/사용자 통합/)).toHaveCount(0);
await expect(page.getByTestId("tenant-template-btn")).toBeVisible();
await expect(page.getByTestId("tenant-export-btn")).toBeVisible();
await expect(page.getByTestId("tenant-import-btn")).toBeVisible();
await expect(page.getByRole("dialog")).toContainText(
/조직\/사용자 통합 일괄 등록|organization and user batch registration/i,
const download = page.waitForEvent("download");
await page.getByTestId("tenant-export-btn").click();
await download;
expect(exportRequested).toBe(true);
await page.getByTestId("tenant-import-input").setInputFiles({
name: "tenants.csv",
mimeType: "text/csv",
buffer: Buffer.from(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Tenant Alpha,COMPANY,,tenant-alpha-copy,Imported memo,imported.example.com\n",
),
});
await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인");
await expect(page.getByTestId("tenant-import-candidate")).toContainText(
"Tenant Alpha",
);
await page.getByTestId("tenant-import-confirm-btn").click();
await expect(page.getByTestId("tenant-import-result")).toContainText(
/갱신 1|Updated 1/i,
);
expect(importRequested).toBe(true);
expect(importBody).toContain("tenant-alpha-id");
});
test("should show validation error on empty name", async ({ page }) => {

View File

@@ -0,0 +1,119 @@
import { expect, test } from "@playwright/test";
const liveE2E = process.env.LIVE_BACKEND_E2E === "1";
const oidcAuthority = "https://sso.hmac.kr/oidc";
const clientId = "adminfront";
test.describe("Tenants CSV live E2E", () => {
test.skip(!liveE2E, "Set LIVE_BACKEND_E2E=1 to run against a live backend.");
test.beforeEach(async ({ page, baseURL }) => {
await page.addInitScript(
({ authority, client_id }) => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
window.localStorage.setItem("X-Mock-Role", "super_admin");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "live-e2e-placeholder-token",
token_type: "Bearer",
profile: {
sub: "live-e2e-admin",
name: "Live E2E Admin",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
);
},
{ authority: oidcAuthority, client_id: clientId },
);
await page.route("**/api/v1/**", async (route) => {
const requestUrl = new URL(route.request().url());
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
const headers = { ...route.request().headers() };
delete headers.authorization;
headers["x-test-role"] = "super_admin";
const response = await route.fetch({ url: liveUrl, headers });
await route.fulfill({ response });
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({
json: {
issuer: oidcAuthority,
authorization_endpoint: `${oidcAuthority}/auth`,
token_endpoint: `${oidcAuthority}/token`,
jwks_uri: `${oidcAuthority}/jwks`,
userinfo_endpoint: `${oidcAuthority}/userinfo`,
end_session_endpoint: `${oidcAuthority}/session/end`,
},
});
});
});
test("exports and imports tenant CSV through the admin UI", async ({
page,
baseURL,
}) => {
const slug = `csv-live-${Date.now()}`;
let tenantId = "";
await page.goto("/tenants");
await expect(page.locator("h2").last()).toContainText(
/테넌트 목록|Tenants/i,
);
await expect(page.getByTestId("tenant-export-btn")).toBeVisible();
await expect(page.getByTestId("tenant-import-btn")).toBeVisible();
const download = page.waitForEvent("download");
await page.getByTestId("tenant-export-btn").click();
const exported = await download;
expect(exported.suggestedFilename()).toContain("tenants");
await page.getByTestId("tenant-import-input").setInputFiles({
name: "tenants.csv",
mimeType: "text/csv",
buffer: Buffer.from(
`tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Live CSV Tenant,COMPANY,,${slug},Live E2E import,${slug}.example.com\n`,
),
});
await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인");
await page.getByTestId("tenant-import-confirm-btn").click();
await expect(page.getByTestId("tenant-import-result")).toContainText(
/생성 1|Created 1/i,
);
const listResponse = await page.request.get(
`${baseURL}/api/v1/admin/tenants?limit=1000&offset=0`,
{ headers: { "X-Test-Role": "super_admin" } },
);
expect(listResponse.ok()).toBeTruthy();
const list = await listResponse.json();
const imported = list.items.find(
(tenant: { slug: string }) => tenant.slug === slug,
);
expect(imported).toMatchObject({
name: "Live CSV Tenant",
slug,
description: "Live E2E import",
domains: [`${slug}.example.com`],
});
tenantId = imported.id;
const deleteResponse = await page.request.delete(
`${baseURL}/api/v1/admin/tenants/${tenantId}`,
{ headers: { "X-Test-Role": "super_admin" } },
);
expect(deleteResponse.status()).toBe(204);
});
});

View File

@@ -79,14 +79,73 @@ test.describe("User Management", () => {
],
},
},
{
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
slug: "tech-planning",
name: "기술기획",
type: "USER_GROUP",
parentId: "hanmac-company-id",
config: {},
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
config: {},
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
config: {},
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
type: "USER_GROUP",
parentId: "hanmac-company-id",
config: {},
},
{
id: "system-id",
slug: "system",
name: "System Tenant",
type: "SYSTEM",
config: {},
},
{
id: "external-tenant-id",
slug: "external-tenant",
name: "External Tenant",
type: "COMPANY",
config: {},
},
],
total: 1,
total: 7,
limit: 100,
offset: 0,
},
});
}
if (url.match(/\/admin\/tenants$/) && method === "POST") {
const postData = route.request().postDataJSON();
return route.fulfill({
status: 201,
json: {
id: "personal-tenant-id",
slug: postData?.slug || "personal",
name: postData?.name || "Personal",
type: postData?.type || "PERSONAL",
config: {},
},
});
}
if (url.match(/\/admin\/tenants\/t-1$/) && method === "GET") {
return route.fulfill({
json: {
@@ -107,6 +166,21 @@ test.describe("User Management", () => {
});
}
if (
url.match(/\/admin\/tenants\/03dbe16b-e47b-4f72-927b-782807d67a35$/) &&
method === "GET"
) {
return route.fulfill({
json: {
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
slug: "tech-planning",
name: "기술기획",
type: "USER_GROUP",
config: {},
},
});
}
if (url.match(/\/admin\/users\/u-1$/) && method === "GET") {
return route.fulfill({
json: {
@@ -185,6 +259,7 @@ test.describe("User Management", () => {
name: "John Doe Updated",
email: "john@test.com",
loginId: "johndoe_updated",
status: "inactive",
},
});
}
@@ -197,9 +272,11 @@ test.describe("User Management", () => {
id: "u-1",
name: "John Doe",
email: "john@test.com",
phone: "010-1111-2222",
loginId: "johndoe",
role: "user",
status: "active",
createdAt: "2026-04-01T00:00:00Z",
},
],
total: 1,
@@ -285,8 +362,26 @@ test.describe("User Management", () => {
// Ensure the page title is loaded
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
const userTypeTabs = page.getByRole("tab");
await expect(userTypeTabs).toHaveText([
"한맥가족 구성원",
"외부 기업 회원",
"개인 회원",
]);
await expect(page.getByRole("tab", { name: /외부 기업 회원/i })).toBeVisible();
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toBeVisible();
await expect(page.getByRole("tab", { name: /개인 회원/i })).toBeVisible();
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toHaveAttribute("data-state", "active");
await expect(
page.getByLabel(/한맥 가족 구성원으로 등록/i),
).toHaveCount(0);
// Select Tenant first (important for schema fields to show up)
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
await page.selectOption("select#tenantSlug", "test-tenant");
// Fill required fields
@@ -305,6 +400,303 @@ test.describe("User Management", () => {
await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 });
});
test("should export users through the authenticated API client", async ({
page,
}) => {
let authorizationHeader: string | undefined;
await page.route(/\/admin\/users\/export(\?.*)?$/, async (route) => {
authorizationHeader = route.request().headers().authorization;
return route.fulfill({
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": 'attachment; filename="users.csv"',
},
body: "email,name\njohn@test.com,John Doe\n",
});
});
await page.goto("/users");
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: /내보내기|Export/i }).click(),
]);
expect(download.suggestedFilename()).toBe("users.csv");
expect(authorizationHeader).toBe("Bearer fake-token");
});
test("should show contact info in one row, hide roles, and toggle user status", async ({
page,
}) => {
let updatePayload: Record<string, unknown> | undefined;
await page.route(/\/admin\/users\/u-1$/, async (route) => {
if (route.request().method() === "PUT") {
updatePayload = route.request().postDataJSON();
return route.fulfill({
json: {
id: "u-1",
name: "John Doe",
email: "john@test.com",
phone: "010-1111-2222",
loginId: "johndoe",
status: "inactive",
createdAt: "2026-04-01T00:00:00Z",
},
});
}
return route.fallback();
});
await page.goto("/users");
const table = page.locator("table");
await expect(
table.getByRole("columnheader", { name: /ROLE|역할/i }),
).toHaveCount(0);
await expect(page.getByTestId("user-contact-u-1")).toContainText(
"John Doe john@test.com 010-1111-2222",
);
await page.getByTestId("user-status-toggle-u-1").click();
await expect
.poll(() => updatePayload)
.toMatchObject({ status: "inactive" });
});
test("should create a Hanmac family user with tenant appointments and no representative affiliation", async ({
page,
}) => {
let createPayload: Record<string, unknown> | undefined;
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() === "POST") {
createPayload = route.request().postDataJSON();
return route.fulfill({
status: 201,
json: {
id: "new-user-id",
name: "Family User",
email: "family@test.com",
},
});
}
return route.fallback();
});
await page.goto("/users/new");
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toHaveAttribute("data-state", "active");
await expect(
page.getByLabel(/한맥 가족 구성원으로 등록/i),
).toHaveCount(0);
await expect(page.locator("select#role")).toHaveCount(0);
await expect(page.locator("input#department")).toHaveCount(0);
await expect(page.getByText(/대표 소속/i)).toHaveCount(0);
await page.getByRole("button", { name: /^추가$/i }).click();
await expect(page.getByTestId("appointment-row-0")).toBeVisible();
await expect(
page.getByTestId("appointment-tenant-owner-line-0"),
).toBeVisible();
await expect(page.getByTestId("appointment-position-line-0")).toBeVisible();
await page.getByRole("button", { name: /테넌트 선택/i }).click();
await expect(page.getByTitle(/테넌트 선택/i)).toHaveAttribute(
"src",
/\/login\?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id$/,
);
await page.evaluate(() => {
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [
{
type: "tenant",
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
},
],
},
},
}),
);
});
await expect(page.getByText("기술기획")).toBeVisible();
await page.getByLabel(/조직장/i).check();
await page.getByLabel(/^직무$/i).fill("플랫폼 운영");
await page.getByLabel(/^직급$/i).fill("책임");
await page.locator('input[name="name"]').fill("Family User");
await page.locator('input[name="email"]').fill("family@test.com");
await page.getByRole("button", { name: /생성/i }).click();
await expect
.poll(() => createPayload)
.toMatchObject({
metadata: {
hanmacFamily: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
tenantSlug: "tech-planning",
tenantName: "기술기획",
isOwner: true,
jobTitle: "플랫폼 운영",
position: "책임",
},
],
},
});
expect(createPayload).not.toHaveProperty("role");
expect(createPayload).not.toHaveProperty("department");
expect(createPayload).not.toHaveProperty("tenantSlug");
expect(createPayload).not.toHaveProperty("companyCode");
expect(createPayload).not.toHaveProperty("primaryTenantId");
});
test("should hide Hanmac family subtree and system tenants when creating a non-family user", async ({
page,
}) => {
await page.goto("/users/new");
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
const tenantOptionValues = await page
.locator("select#tenantSlug option")
.evaluateAll((options) =>
options.map((option) => (option as HTMLOptionElement).value),
);
expect(tenantOptionValues).toContain("test-tenant");
expect(tenantOptionValues).toContain("external-tenant");
expect(tenantOptionValues).not.toContain("");
expect(tenantOptionValues).not.toContain("system");
expect(tenantOptionValues).not.toContain("hanmac-family");
expect(tenantOptionValues).not.toContain("hanmac-company");
expect(tenantOptionValues).not.toContain("hanmac-team");
expect(tenantOptionValues).not.toContain("tech-planning");
});
test("should create a personal user and provision Personal tenant when missing", async ({
page,
}) => {
let tenantPayload: Record<string, unknown> | undefined;
let createPayload: Record<string, unknown> | undefined;
await page.route(/\/admin\/tenants$/, async (route) => {
if (route.request().method() === "POST") {
tenantPayload = route.request().postDataJSON();
return route.fulfill({
status: 201,
json: {
id: "personal-tenant-id",
slug: "personal",
name: "Personal",
type: "PERSONAL",
config: {},
},
});
}
return route.fallback();
});
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() === "POST") {
createPayload = route.request().postDataJSON();
return route.fulfill({
status: 201,
json: {
id: "personal-user-id",
name: "Personal User",
email: "personal@test.com",
},
});
}
return route.fallback();
});
await page.goto("/users/new");
await page.getByRole("tab", { name: /개인 회원/i }).click();
await expect(page.getByTestId("personal-tenant-summary")).toContainText(
/Personal/i,
);
await page.locator('input[name="name"]').fill("Personal User");
await page.locator('input[name="email"]').fill("personal@test.com");
await page.getByRole("button", { name: /생성/i }).click();
await expect
.poll(() => tenantPayload)
.toMatchObject({ name: "Personal", slug: "personal", type: "PERSONAL" });
await expect
.poll(() => createPayload)
.toMatchObject({
tenantSlug: "personal",
metadata: { userType: "personal", hanmacFamily: false },
});
});
test("should show Hanmac family appointments layout on user detail", async ({
page,
}) => {
await page.route(/\/admin\/users\/u-1$/, async (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
json: {
id: "u-1",
name: "Family User",
email: "family@test.com",
phone: "010-1111-2222",
loginId: "familyuser",
role: "user",
status: "active",
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
metadata: {
hanmacFamily: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
tenantSlug: "tech-planning",
tenantName: "기술기획",
isOwner: true,
jobTitle: "플랫폼 운영",
position: "책임",
},
],
},
},
});
}
return route.fallback();
});
await page.goto("/users/u-1");
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toHaveAttribute("data-state", "active");
await expect(
page.getByLabel(/한맥 가족 구성원으로 등록/i),
).toHaveCount(0);
await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible();
await expect(
page.getByTestId("detail-appointment-tenant-owner-line-0"),
).toContainText(/기술기획|조직장/);
await expect(
page.getByTestId("detail-appointment-position-line-0"),
).toBeVisible();
});
test("should show conflict error when creating with an existing Login ID", async ({
page,
}) => {
@@ -312,6 +704,8 @@ test.describe("User Management", () => {
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
// Select Tenant first (important for schema fields to show up)
await page.selectOption("select#tenantSlug", "test-tenant");

View File

@@ -0,0 +1,82 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
const liveE2E = process.env.LIVE_BACKEND_E2E === "1";
const oidcAuthority = "https://sso.hmac.kr/oidc";
const clientId = "adminfront";
test.describe("Users CSV live E2E", () => {
test.skip(!liveE2E, "Set LIVE_BACKEND_E2E=1 to run against a live backend.");
test.beforeEach(async ({ page, baseURL }) => {
await page.addInitScript(
({ authority, client_id }) => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
window.localStorage.setItem("X-Mock-Role", "super_admin");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "live-e2e-placeholder-token",
token_type: "Bearer",
profile: {
sub: "live-e2e-admin",
name: "Live E2E Admin",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
);
},
{ authority: oidcAuthority, client_id: clientId },
);
await page.route("**/api/v1/**", async (route) => {
const requestUrl = new URL(route.request().url());
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
const headers = { ...route.request().headers() };
delete headers.authorization;
headers["x-test-role"] = "super_admin";
const response = await route.fetch({ url: liveUrl, headers });
await route.fulfill({ response });
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({
json: {
issuer: oidcAuthority,
authorization_endpoint: `${oidcAuthority}/auth`,
token_endpoint: `${oidcAuthority}/token`,
jwks_uri: `${oidcAuthority}/jwks`,
userinfo_endpoint: `${oidcAuthority}/userinfo`,
end_session_endpoint: `${oidcAuthority}/session/end`,
},
});
});
});
test("exports user CSV through the authenticated admin UI path", async ({
page,
}) => {
await page.goto("/users");
await expect(page.getByTestId("page-title")).toContainText(/사용자|Users/i);
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: /내보내기|Export/i }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain("users");
const path = await download.path();
expect(path).toBeTruthy();
const csv = fs.readFileSync(path as string, "utf8");
expect(csv).toContain("ID,Email,Name,Phone,Status,Tenant,Position,JobTitle,CreatedAt");
expect(csv).not.toContain("Role");
expect(csv).not.toContain("Department");
});
});

View File

@@ -300,9 +300,6 @@ func main() {
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
apiKeyHandler := handler.NewApiKeyHandler(db)
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
orgChartHandler := handler.NewOrgChartHandler(orgChartService)
// 3. Initialize Fiber
appEnv := getEnv("APP_ENV", "dev")
clientLogDebugFlag := getEnv("CLIENT_LOG_DEBUG", "")
@@ -622,6 +619,8 @@ func main() {
// Tenant Management (Mixed roles, handler filters results)
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
admin.Get("/tenants/export", requireSuperAdmin, tenantHandler.ExportTenantsCSV)
admin.Post("/tenants/import", requireSuperAdmin, tenantHandler.ImportTenantsCSV)
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
// [New] Shared Link Management
@@ -643,9 +642,6 @@ func main() {
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
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)
@@ -688,7 +684,7 @@ func main() {
// Admin User Management
admin.Get("/users", requireAnyUser, userHandler.ListUsers)
admin.Get("/users/export", userHandler.ExportUsersCSV) // Removed requireAdmin to handle mock role in query param
admin.Get("/users/export", requireAdmin, userHandler.ExportUsersCSV)
admin.Post("/users", requireAdmin, userHandler.CreateUser)
admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers)
admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers)

View File

@@ -19,7 +19,6 @@ require (
github.com/stretchr/testify v1.11.1
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
@@ -105,8 +104,6 @@ require (
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

View File

@@ -235,12 +235,6 @@ 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=

View File

@@ -1,61 +0,0 @@
package handler
import (
"baron-sso-backend/internal/service"
"log/slog"
"github.com/gofiber/fiber/v2"
)
type OrgChartHandler struct {
Service service.OrgChartService
}
func NewOrgChartHandler(s service.OrgChartService) *OrgChartHandler {
return &OrgChartHandler{Service: s}
}
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"})
}
file, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "failed to get file from form"})
}
f, err := file.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to open file"})
}
defer f.Close()
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",
"data": result,
})
}
func (h *OrgChartHandler) GetImportProgress(c *fiber.Ctx) error {
pid := c.Params("progressId")
if val, ok := service.ImportProgressCache.Load(pid); ok {
return c.JSON(val)
}
return c.JSON(fiber.Map{"current": 0, "total": 0})
}

View File

@@ -5,7 +5,11 @@ import (
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"bytes"
"encoding/csv"
"errors"
"fmt"
"io"
"strings"
"time"
@@ -57,6 +61,23 @@ type tenantListResponse struct {
Total int64 `json:"total"`
}
type tenantImportResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Failed int `json:"failed"`
Errors []string `json:"errors"`
}
type tenantCSVRecord struct {
TenantID string
Name string
Type string
ParentTenantID *string
Slug string
Memo string
Domains []string
}
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
var req struct {
Name string `json:"name"`
@@ -229,6 +250,361 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
}
func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "slug", "memo", "email_domain"}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
for _, tenant := range tenants {
parentID := ""
if tenant.ParentID != nil {
parentID = *tenant.ParentID
}
domains := make([]string, 0, len(tenant.Domains))
for _, domainName := range tenant.Domains {
domainName := strings.TrimSpace(domainName.Domain)
if domainName != "" {
domains = append(domains, domainName)
}
}
if err := writer.Write([]string{
tenant.ID,
tenant.Name,
tenant.Type,
parentID,
tenant.Slug,
tenant.Description,
strings.Join(domains, ";"),
}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
c.Set(fiber.HeaderContentType, "text/csv")
c.Set(fiber.HeaderContentDisposition, `attachment; filename="tenants.csv"`)
return c.Send(buf.Bytes())
}
func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
reader, err := tenantCSVReaderFromRequest(c)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
records, err := parseTenantCSVRecords(reader)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
creatorID = profile.ID
}
result := tenantImportResult{Errors: make([]string, 0)}
for i, record := range records {
rowNumber := i + 2
if record.TenantID != "" || (h.DB != nil && record.Slug != "") {
updated, err := h.upsertTenantCSVRecord(c, record)
if err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
continue
}
if updated {
result.Updated++
continue
}
}
if err := h.createTenantCSVRecord(c, record, creatorID); err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
continue
}
result.Created++
}
return c.JSON(result)
}
func tenantCSVReaderFromRequest(c *fiber.Ctx) (io.Reader, error) {
file, err := c.FormFile("file")
if err == nil && file != nil {
opened, err := file.Open()
if err != nil {
return nil, errors.New("failed to open uploaded file")
}
defer opened.Close()
data, err := io.ReadAll(opened)
if err != nil {
return nil, errors.New("failed to read uploaded file")
}
return bytes.NewReader(data), nil
}
body := c.Body()
if len(bytes.TrimSpace(body)) == 0 {
return nil, errors.New("csv file is required")
}
return bytes.NewReader(body), nil
}
func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, errors.New("failed to read csv")
}
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
reader := csv.NewReader(bytes.NewReader(data))
reader.FieldsPerRecord = -1
rows, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("invalid csv: %w", err)
}
if len(rows) == 0 {
return nil, errors.New("csv is empty")
}
header := tenantCSVHeaderIndex(rows[0])
required := []string{"name", "type", "slug"}
for _, key := range required {
if _, ok := header[key]; !ok {
return nil, fmt.Errorf("missing required column: %s", key)
}
}
records := make([]tenantCSVRecord, 0, len(rows)-1)
for i, row := range rows[1:] {
if tenantCSVRowIsEmpty(row) {
continue
}
name := tenantCSVValue(row, header, "name")
if name == "" {
return nil, fmt.Errorf("row %d: name is required", i+2)
}
tenantType := normalizeTenantType(tenantCSVValue(row, header, "type"))
if tenantType == "" {
return nil, fmt.Errorf("row %d: invalid tenant type", i+2)
}
slug := utils.GenerateSlug(tenantCSVValue(row, header, "slug"))
if slug == "" {
return nil, fmt.Errorf("row %d: slug is required", i+2)
}
parentValue := tenantCSVValue(row, header, "parent_tenant_id")
var parentID *string
if parentValue != "" {
parentID = &parentValue
}
records = append(records, tenantCSVRecord{
TenantID: tenantCSVValue(row, header, "tenant_id"),
Name: name,
Type: tenantType,
ParentTenantID: parentID,
Slug: slug,
Memo: tenantCSVValue(row, header, "memo"),
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
})
}
return records, nil
}
func tenantCSVHeaderIndex(header []string) map[string]int {
index := make(map[string]int, len(header))
aliases := map[string]string{
"id": "tenant_id",
"tenantid": "tenant_id",
"tenant_id": "tenant_id",
"name": "name",
"type": "type",
"parentid": "parent_tenant_id",
"parent_id": "parent_tenant_id",
"parenttenantid": "parent_tenant_id",
"parent_tenant_id": "parent_tenant_id",
"slug": "slug",
"memo": "memo",
"description": "memo",
"email-domain": "email_domain",
"emaildomain": "email_domain",
"email_domain": "email_domain",
"domain": "email_domain",
"domains": "email_domain",
}
for i, column := range header {
key := strings.ToLower(strings.TrimSpace(column))
key = strings.ReplaceAll(key, " ", "_")
if canonical, ok := aliases[key]; ok {
index[canonical] = i
}
}
return index
}
func tenantCSVValue(row []string, header map[string]int, key string) string {
idx, ok := header[key]
if !ok || idx >= len(row) {
return ""
}
return strings.TrimSpace(row[idx])
}
func tenantCSVRowIsEmpty(row []string) bool {
for _, value := range row {
if strings.TrimSpace(value) != "" {
return false
}
}
return true
}
func splitTenantCSVDomains(value string) []string {
value = strings.ReplaceAll(value, "\n", ";")
value = strings.ReplaceAll(value, ",", ";")
parts := strings.Split(value, ";")
domains := make([]string, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
domainName := strings.ToLower(strings.TrimSpace(part))
if domainName == "" || seen[domainName] {
continue
}
seen[domainName] = true
domains = append(domains, domainName)
}
return domains
}
func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord) (bool, error) {
if h.DB == nil {
if record.TenantID != "" {
return false, errors.New("database not available for tenant update")
}
return false, nil
}
var tenant domain.Tenant
query := h.DB.Preload("Domains")
var err error
if record.TenantID != "" {
err = query.First(&tenant, "id = ?", record.TenantID).Error
} else {
err = query.First(&tenant, "slug = ?", record.Slug).Error
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
tenant.Name = record.Name
tenant.Type = record.Type
tenant.ParentID = record.ParentTenantID
tenant.Slug = record.Slug
tenant.Description = record.Memo
if tenant.Status == "" {
tenant.Status = domain.TenantStatusActive
}
if err := h.DB.Save(&tenant).Error; err != nil {
return false, err
}
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
return false, err
}
repo := repository.NewTenantRepository(h.DB)
for _, domainName := range record.Domains {
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
return false, err
}
}
return true, nil
}
func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord, creatorID string) error {
if h.DB != nil && record.TenantID != "" {
var exists int64
if err := h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", record.Slug).Count(&exists).Error; err != nil {
return err
}
if exists > 0 {
return errors.New("tenant slug already exists")
}
tenant := domain.Tenant{
ID: record.TenantID,
Type: record.Type,
ParentID: record.ParentTenantID,
Name: record.Name,
Slug: record.Slug,
Description: record.Memo,
Status: domain.TenantStatusActive,
}
if err := h.DB.Create(&tenant).Error; err != nil {
return err
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "admins",
Subject: "System:global#super_admins",
Action: domain.KetoOutboxActionCreate,
})
if tenant.ParentID != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "parents",
Subject: "Tenant:" + *tenant.ParentID,
Action: domain.KetoOutboxActionCreate,
})
}
if creatorID != "" {
for _, relation := range []string{"owners", "admins", "members"} {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: relation,
Subject: "User:" + creatorID,
Action: domain.KetoOutboxActionCreate,
})
}
}
}
repo := repository.NewTenantRepository(h.DB)
for _, domainName := range record.Domains {
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
return err
}
}
return nil
}
_, err := h.Service.RegisterTenant(c.Context(), record.Name, record.Slug, record.Type, record.Memo, record.Domains, record.ParentTenantID, creatorID)
return err
}
func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")

View File

@@ -6,8 +6,11 @@ import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
@@ -120,6 +123,12 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri
}
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
for _, call := range m.ExpectedCalls {
if call.Method == "List" {
args := m.Called(ctx, offset, limit, search, companyCode)
return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2)
}
}
return nil, 0, nil
}
@@ -244,6 +253,84 @@ func TestTenantHandler_ListTenants(t *testing.T) {
}
}
func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
app.Get("/tenants/export", h.ExportTenantsCSV)
parentID := "parent-1"
tenants := []domain.Tenant{
{
ID: "t1",
Name: "Tenant A",
Type: domain.TenantTypeCompany,
ParentID: &parentID,
Slug: "tenant-a",
Description: "Primary tenant",
Domains: []domain.TenantDomain{
{Domain: "tenant-a.example.com"},
{Domain: "login.tenant-a.example.com"},
},
},
}
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(1), nil)
req := httptest.NewRequest("GET", "/tenants/export", nil)
resp, _ := app.Test(req)
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv")
assert.Equal(t, "text/csv", strings.Split(resp.Header.Get("Content-Type"), ";")[0])
assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain")
assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com")
}
func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
app.Post("/tenants/import", h.ImportTenantsCSV)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "tenants.csv")
assert.NoError(t, err)
_, err = part.Write([]byte("tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Imported Tenant,COMPANY,parent-1,imported-tenant,Imported memo,imported.example.com;login.imported.example.com\n"))
assert.NoError(t, err)
assert.NoError(t, writer.Close())
mockSvc.On(
"RegisterTenant",
mock.Anything,
"Imported Tenant",
"imported-tenant",
domain.TenantTypeCompany,
"Imported memo",
[]string{"imported.example.com", "login.imported.example.com"},
mock.MatchedBy(func(parentID *string) bool {
return parentID != nil && *parentID == "parent-1"
}),
"",
).Return(&domain.Tenant{ID: "imported-1", Name: "Imported Tenant", Slug: "imported-tenant"}, nil)
req := httptest.NewRequest("POST", "/tenants/import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]interface{}
json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, float64(1), got["created"])
assert.Equal(t, float64(0), got["updated"])
assert.Equal(t, float64(0), got["failed"])
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ApproveTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)

View File

@@ -787,6 +787,9 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
search := strings.TrimSpace(c.Query("search"))
companyCode := strings.TrimSpace(c.Query("companyCode"))
if companyCode == "" {
companyCode = strings.TrimSpace(c.Query("tenantSlug"))
}
var requesterRole string
var manageableSlugs []string
@@ -867,7 +870,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
defer writer.Flush()
// Header row
header := []string{"ID", "Email", "Name", "Role", "Status", "Tenant", "Department", "Position", "JobTitle", "CreatedAt"}
header := []string{"ID", "Email", "Name", "Phone", "Status", "Tenant", "Position", "JobTitle", "CreatedAt"}
// Collect all possible metadata keys for dynamic columns
metaKeysMap := make(map[string]bool)
@@ -892,10 +895,9 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
u.ID,
u.Email,
u.Name,
u.Role,
u.Phone,
u.Status,
u.CompanyCode,
u.Department,
u.Position,
u.JobTitle,
u.CreatedAt.Format(time.RFC3339),

View File

@@ -7,9 +7,12 @@ import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
@@ -133,6 +136,51 @@ func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context,
// --- Tests ---
func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T) {
app := fiber.New()
mockRepo := new(MockUserRepoForHandler)
h := &UserHandler{UserRepo: mockRepo}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users/export", h.ExportUsersCSV)
createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
mockRepo.On("List", mock.Anything, 0, 10000, "", "test-tenant").
Return([]domain.User{
{
ID: "u-1",
Email: "user@test.com",
Name: "Test User",
Phone: "010-1111-2222",
Role: domain.RoleSuperAdmin,
Status: "active",
CompanyCode: "test-tenant",
Department: "Legacy Department",
Position: "책임",
JobTitle: "플랫폼 운영",
CreatedAt: createdAt,
},
}, int64(1), nil).Once()
req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
bodyBytes, _ := io.ReadAll(resp.Body)
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
assert.Contains(t, body, "ID,Email,Name,Phone,Status,Tenant,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,test-tenant")
assert.NotContains(t, body, "Role")
assert.NotContains(t, body, "Department")
mockRepo.AssertExpectations(t)
}
func TestUserHandler_BulkCreateUsers(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)

View File

@@ -1,560 +0,0 @@
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+`)
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 {
ImportOrgChart(ctx context.Context, tenantID string, r io.Reader, filename string, progressID string) (*ImportResult, error)
}
type orgChartService struct {
tenantRepo repository.TenantRepository
userGroupRepo repository.UserGroupRepository
userRepo repository.UserRepository
ketoOutboxRepo repository.KetoOutboxRepository
kratos KratosAdminService
}
func NewOrgChartService(
tenantRepo repository.TenantRepository,
userGroupRepo repository.UserGroupRepository,
userRepo repository.UserRepository,
ketoOutbox repository.KetoOutboxRepository,
kratos KratosAdminService,
) OrgChartService {
return &orgChartService{
tenantRepo: tenantRepo,
userGroupRepo: userGroupRepo,
userRepo: userRepo,
ketoOutboxRepo: ketoOutbox,
kratos: kratos,
}
}
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 nil, err
}
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"},
}
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
}
pathCache := make(map[string]string)
result.TotalRows = len(dataRows)
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 {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Company fail: %v", rowIdx+2, err))
continue
}
// 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, " > ")
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
}
}
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))
}
if s.ketoOutboxRepo != nil {
// 1. [Redundant Assignment] Always assign to the Legal Company Tenant
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: companyTenantID,
Relation: "members",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
// 2. [Redundant Assignment] ALSO assign to the Logical Department Tenant (if exists)
if leafID != companyTenantID {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
Relation: "members",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
}
// 3. Assign ownership if leader
if isOwner {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
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 result, nil
}
func (s *orgChartService) cleanHeader(val string) string {
clean := strings.ToLower(whitespaceRegex.ReplaceAllString(val, ""))
clean = nonAlphaNumRegex.ReplaceAllString(clean, "")
return strings.TrimPrefix(clean, "\ufeff")
}
func (s *orgChartService) findBestMatch(tempMap map[string]int, aliases []string) int {
for _, alias := range aliases {
ca := s.cleanHeader(alias)
if idx, ok := tempMap[ca]; ok {
return idx
}
}
for cleaned, idx := range tempMap {
for _, alias := range aliases {
ca := s.cleanHeader(alias)
if len(ca) >= 2 && (strings.Contains(cleaned, ca) || strings.Contains(ca, cleaned)) {
return idx
}
}
}
return -1
}
func (s *orgChartService) getVal(record []string, idx int) string {
if idx == -1 || idx >= len(record) {
return ""
}
return strings.TrimSpace(record[idx])
}
func (s *orgChartService) normalizePhone(phone string) string {
normalized := strings.ReplaceAll(phone, "-", "")
normalized = strings.ReplaceAll(normalized, " ", "")
re := regexp.MustCompile(`[^0-9+]`)
normalized = re.ReplaceAllString(normalized, "")
if len(normalized) < 8 {
return ""
}
if strings.HasPrefix(normalized, "010") {
return "+82" + normalized[1:]
}
if strings.HasPrefix(normalized, "82") {
return "+" + normalized
}
if !strings.HasPrefix(normalized, "+") && len(normalized) >= 9 {
if strings.HasPrefix(normalized, "0") {
return "+82" + normalized[1:]
}
return "+82" + normalized
}
return normalized
}
func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))))
reader.LazyQuotes = true
reader.FieldsPerRecord = -1
return reader.ReadAll()
}
func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) {
f, err := excelize.OpenReader(r)
if err != nil {
return nil, err
}
defer f.Close()
var allRecords [][][]string
for _, sheet := range f.GetSheetList() {
if rows, err := f.GetRows(sheet); err == nil {
allRecords = append(allRecords, rows)
}
}
return allRecords, nil
}
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 == "" || part == "-" {
continue
}
if currentPath == "" {
currentPath = part
} else {
currentPath += "/" + part
}
cacheKey := rootTenantID + ":" + currentPath
if id, ok := cache[cacheKey]; ok {
currentParentID = id
continue
}
var existingID string
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 == "" {
existingID = uuid.NewString()
groupSlug := fmt.Sprintf("ug-%s", existingID[:13])
if err := s.tenantRepo.Create(ctx, &domain.Tenant{
ID: existingID,
Type: domain.TenantTypeUserGroup,
ParentID: &currentParentID,
Name: part,
Slug: groupSlug,
Status: domain.TenantStatusActive,
}); err != nil {
return "", err
}
var ugParentID *string
if currentParentID != rootTenantID {
pid := currentParentID
ugParentID = &pid
}
if err := s.userGroupRepo.Create(ctx, &domain.UserGroup{
ID: existingID,
TenantID: rootTenantID,
ParentID: ugParentID,
Name: part,
UnitType: s.guessUnitType(i, len(parts)),
}); err != nil {
return "", err
}
if s.ketoOutboxRepo != nil {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: existingID, Relation: "parents", Subject: "Tenant:" + currentParentID, Action: domain.KetoOutboxActionCreate})
}
res.TenantCreated++
}
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"
}
return "Team"
}

View File

@@ -1,251 +0,0 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"bytes"
"context"
"testing"
"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
}