forked from baron/baron-sso
org chart 연동기능 추가
This commit is contained in:
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -47,7 +47,6 @@ import {
|
|||||||
removeGroupMember,
|
removeGroupMember,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
|
||||||
|
|
||||||
type UserGroupNode = GroupSummary & {
|
type UserGroupNode = GroupSummary & {
|
||||||
children: UserGroupNode[];
|
children: UserGroupNode[];
|
||||||
@@ -445,10 +444,6 @@ function TenantGroupsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<OrgChartUploadModal
|
|
||||||
tenantId={tenantId}
|
|
||||||
onSuccess={() => groupsQuery.refetch()}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
CornerDownRight,
|
Download,
|
||||||
|
FileSpreadsheet,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
@@ -21,6 +23,14 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../../components/ui/card";
|
} from "../../../components/ui/card";
|
||||||
import { Checkbox } from "../../../components/ui/checkbox";
|
import { Checkbox } from "../../../components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../../../components/ui/dialog";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -34,16 +44,35 @@ import {
|
|||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
deleteTenant,
|
deleteTenant,
|
||||||
deleteTenantsBulk,
|
deleteTenantsBulk,
|
||||||
|
exportTenantsCSV,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
|
importTenantsCSV,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
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() {
|
function TenantListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||||
const [search, setSearch] = React.useState("");
|
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({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ["me"],
|
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 (
|
if (
|
||||||
profile &&
|
profile &&
|
||||||
profile.role !== "super_admin" &&
|
profile.role !== "super_admin" &&
|
||||||
@@ -161,8 +234,50 @@ function TenantListPage() {
|
|||||||
deleteBulkMutation.mutate(selectedIds);
|
deleteBulkMutation.mutate(selectedIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rootTenant =
|
const handleTemplateDownload = () => {
|
||||||
tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
|
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) => {
|
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||||
if (
|
if (
|
||||||
@@ -210,10 +325,40 @@ function TenantListPage() {
|
|||||||
</RoleGuard>
|
</RoleGuard>
|
||||||
|
|
||||||
<RoleGuard roles={["super_admin"]}>
|
<RoleGuard roles={["super_admin"]}>
|
||||||
<OrgChartUploadModal
|
<input
|
||||||
tenantId={rootTenant?.id || "root"}
|
ref={fileInputRef}
|
||||||
onSuccess={() => query.refetch()}
|
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>
|
</RoleGuard>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -233,6 +378,14 @@ function TenantListPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</RoleGuard>
|
</RoleGuard>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
@@ -247,7 +400,7 @@ function TenantListPage() {
|
|||||||
})}
|
})}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>{" "}
|
</CardHeader>
|
||||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
<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="mb-6 flex items-center gap-4 flex-shrink-0">
|
||||||
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
||||||
@@ -410,6 +563,139 @@ function TenantListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
264
adminfront/src/features/tenants/utils/tenantCsvImport.ts
Normal file
264
adminfront/src/features/tenants/utils/tenantCsvImport.ts
Normal 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
@@ -14,7 +14,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { Badge } from "../../components/ui/badge";
|
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -33,6 +32,7 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Switch } from "../../components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -46,14 +46,14 @@ import {
|
|||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
bulkUpdateUsers,
|
bulkUpdateUsers,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
exportUsersCSVUrl,
|
exportUsersCSV,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
|
updateUser,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
|
|
||||||
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
||||||
|
|
||||||
type UserSchemaField = {
|
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 = () => {
|
const handleSearch = () => {
|
||||||
setSearch(searchDraft);
|
setSearch(searchDraft);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -156,8 +191,7 @@ function UserListPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
const url = exportUsersCSVUrl(search, selectedCompany);
|
exportMutation.mutate();
|
||||||
window.open(url, "_blank");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
@@ -275,7 +309,12 @@ function UserListPage() {
|
|||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
{t("ui.common.refresh", "새로고침")}
|
{t("ui.common.refresh", "새로고침")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={handleExport} className="gap-2">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleExport}
|
||||||
|
className="gap-2"
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
>
|
||||||
<FileDown size={16} />
|
<FileDown size={16} />
|
||||||
{t("ui.common.export", "내보내기")}
|
{t("ui.common.export", "내보내기")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -428,21 +467,12 @@ function UserListPage() {
|
|||||||
<TableHead className="min-w-[200px]">
|
<TableHead className="min-w-[200px]">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.list.table.name_email",
|
"ui.admin.users.list.table.name_email",
|
||||||
"NAME / EMAIL",
|
"이름 / 이메일 / 전화번호",
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
|
||||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.users.list.table.status", "STATUS")}
|
{t("ui.admin.users.list.table.status", "STATUS")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
|
||||||
{t(
|
|
||||||
"ui.admin.users.list.table.tenant_dept",
|
|
||||||
"TENANT / DEPT",
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
{/* Dynamic Columns from Schema */}
|
{/* Dynamic Columns from Schema */}
|
||||||
{userSchema.map(
|
{userSchema.map(
|
||||||
(field) =>
|
(field) =>
|
||||||
@@ -464,7 +494,7 @@ function UserListPage() {
|
|||||||
{query.isLoading && (
|
{query.isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={7 + userSchema.length}
|
colSpan={5 + userSchema.length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
{t("msg.common.loading", "로딩 중...")}
|
{t("msg.common.loading", "로딩 중...")}
|
||||||
@@ -474,7 +504,7 @@ function UserListPage() {
|
|||||||
{!query.isLoading && items.length === 0 && (
|
{!query.isLoading && items.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={7 + userSchema.length}
|
colSpan={5 + userSchema.length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
{t(
|
{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">
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||||
<User size={16} />
|
<User size={16} />
|
||||||
</div>
|
</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="font-medium">{user.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
{user.email}
|
{user.email}
|
||||||
</span>
|
</span>
|
||||||
|
{user.phone && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
{user.phone}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">
|
<div className="flex items-center gap-2">
|
||||||
{t(`ui.admin.role.${user.role}`, user.role)}
|
<Switch
|
||||||
</Badge>
|
checked={user.status === "active"}
|
||||||
</TableCell>
|
onCheckedChange={(checked) =>
|
||||||
<TableCell>
|
statusMutation.mutate({
|
||||||
<Badge
|
userId: user.id,
|
||||||
variant={
|
status: checked ? "active" : "inactive",
|
||||||
user.status === "active" ? "default" : "secondary"
|
})
|
||||||
}
|
}
|
||||||
>
|
disabled={
|
||||||
{t(`ui.common.status.${user.status}`, user.status)}
|
statusMutation.isPending ||
|
||||||
</Badge>
|
user.id === profile?.id
|
||||||
</TableCell>
|
}
|
||||||
<TableCell>
|
aria-label={t(
|
||||||
<div className="flex flex-col text-sm">
|
"ui.admin.users.list.toggle_status",
|
||||||
<span className="font-medium text-blue-600">
|
"{{name}} 활성 상태",
|
||||||
{user.tenant?.name || user.tenantSlug || "-"}
|
{ name: user.name },
|
||||||
</span>
|
)}
|
||||||
<span className="text-xs text-muted-foreground">
|
data-testid={`user-status-toggle-${user.id}`}
|
||||||
{user.department || "-"}
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t(`ui.common.status.${user.status}`, user.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -625,16 +667,6 @@ function UserListPage() {
|
|||||||
>
|
>
|
||||||
{t("ui.common.status.inactive", "비활성화")}
|
{t("ui.common.status.inactive", "비활성화")}
|
||||||
</Button>
|
</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" />
|
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
118
adminfront/src/features/users/orgChartPicker.test.ts
Normal file
118
adminfront/src/features/users/orgChartPicker.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
142
adminfront/src/features/users/orgChartPicker.ts
Normal file
142
adminfront/src/features/users/orgChartPicker.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -63,6 +63,13 @@ export type TenantUpdateRequest = {
|
|||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TenantImportResult = {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
failed: number;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ApiKeySummary = {
|
export type ApiKeySummary = {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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) {
|
export async function approveTenant(tenantId: string) {
|
||||||
const { data } = await apiClient.post<TenantSummary>(
|
const { data } = await apiClient.post<TenantSummary>(
|
||||||
`/v1/admin/tenants/${tenantId}/approve`,
|
`/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 = {
|
export type GroupRole = {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
tenantName: string;
|
tenantName: string;
|
||||||
@@ -412,6 +411,10 @@ export type UserCreateRequest = {
|
|||||||
department?: string;
|
department?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
|
primaryTenantId?: string;
|
||||||
|
primaryTenantName?: string;
|
||||||
|
primaryTenantIsOwner?: boolean;
|
||||||
|
additionalAppointments?: UserAppointment[];
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -430,9 +433,22 @@ export type UserUpdateRequest = {
|
|||||||
department?: string;
|
department?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
|
primaryTenantId?: string;
|
||||||
|
primaryTenantName?: string;
|
||||||
|
primaryTenantIsOwner?: boolean;
|
||||||
|
additionalAppointments?: UserAppointment[];
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserAppointment = {
|
||||||
|
tenantId: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
tenantName: string;
|
||||||
|
isOwner: boolean;
|
||||||
|
jobTitle?: string;
|
||||||
|
position?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type BulkUserItem = {
|
export type BulkUserItem = {
|
||||||
email: string;
|
email: string;
|
||||||
loginId?: string;
|
loginId?: string;
|
||||||
@@ -492,19 +508,20 @@ export async function createUser(payload: UserCreateRequest) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
|
export async function exportUsersCSV(search?: string, tenantSlug?: string) {
|
||||||
const params = new URLSearchParams();
|
const response = await apiClient.get<Blob>("/v1/admin/users/export", {
|
||||||
if (search) params.append("search", search);
|
params: { search, tenantSlug },
|
||||||
if (tenantSlug) params.append("tenantSlug", tenantSlug);
|
responseType: "blob",
|
||||||
|
});
|
||||||
// Get mock role from storage if exists for dev environment
|
const dispositionHeader = response.headers["content-disposition"];
|
||||||
const isMockRoleEnabled =
|
const disposition = Array.isArray(dispositionHeader)
|
||||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
? dispositionHeader[0]
|
||||||
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
: String(dispositionHeader ?? "");
|
||||||
if (isMockRoleEnabled && mockRole) params.append("x-test-role", mockRole);
|
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/i);
|
||||||
|
return {
|
||||||
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
|
blob: response.data,
|
||||||
return `${baseUrl}/admin/users/export?${params.toString()}`;
|
filename: filenameMatch?.[1] ?? "users.csv",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkCreateUsers(users: BulkUserItem[]) {
|
export async function bulkCreateUsers(users: BulkUserItem[]) {
|
||||||
|
|||||||
@@ -114,24 +114,100 @@ test.describe("Tenants Management", () => {
|
|||||||
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
|
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,
|
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 page.goto("/tenants");
|
||||||
await expect(page.locator("h2").last()).toContainText(
|
await expect(page.locator("h2").last()).toContainText(
|
||||||
/테넌트 목록|Tenants/i,
|
/테넌트 목록|Tenants/i,
|
||||||
{ timeout: 20000 },
|
{ timeout: 20000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await page
|
await expect(page.getByText(/조직\/사용자 통합/)).toHaveCount(0);
|
||||||
.locator("button")
|
await expect(page.getByTestId("tenant-template-btn")).toBeVisible();
|
||||||
.filter({ hasText: /임포트|Import/i })
|
await expect(page.getByTestId("tenant-export-btn")).toBeVisible();
|
||||||
.first()
|
await expect(page.getByTestId("tenant-import-btn")).toBeVisible();
|
||||||
.click();
|
|
||||||
|
|
||||||
await expect(page.getByRole("dialog")).toContainText(
|
const download = page.waitForEvent("download");
|
||||||
/조직\/사용자 통합 일괄 등록|organization and user batch registration/i,
|
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 }) => {
|
test("should show validation error on empty name", async ({ page }) => {
|
||||||
|
|||||||
119
adminfront/tests/tenants_live.spec.ts
Normal file
119
adminfront/tests/tenants_live.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
limit: 100,
|
||||||
offset: 0,
|
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") {
|
if (url.match(/\/admin\/tenants\/t-1$/) && method === "GET") {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
json: {
|
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") {
|
if (url.match(/\/admin\/users\/u-1$/) && method === "GET") {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
json: {
|
json: {
|
||||||
@@ -185,6 +259,7 @@ test.describe("User Management", () => {
|
|||||||
name: "John Doe Updated",
|
name: "John Doe Updated",
|
||||||
email: "john@test.com",
|
email: "john@test.com",
|
||||||
loginId: "johndoe_updated",
|
loginId: "johndoe_updated",
|
||||||
|
status: "inactive",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -197,9 +272,11 @@ test.describe("User Management", () => {
|
|||||||
id: "u-1",
|
id: "u-1",
|
||||||
name: "John Doe",
|
name: "John Doe",
|
||||||
email: "john@test.com",
|
email: "john@test.com",
|
||||||
|
phone: "010-1111-2222",
|
||||||
loginId: "johndoe",
|
loginId: "johndoe",
|
||||||
role: "user",
|
role: "user",
|
||||||
status: "active",
|
status: "active",
|
||||||
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
total: 1,
|
total: 1,
|
||||||
@@ -285,8 +362,26 @@ test.describe("User Management", () => {
|
|||||||
|
|
||||||
// Ensure the page title is loaded
|
// Ensure the page title is loaded
|
||||||
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
|
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)
|
// Select Tenant first (important for schema fields to show up)
|
||||||
|
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
|
||||||
await page.selectOption("select#tenantSlug", "test-tenant");
|
await page.selectOption("select#tenantSlug", "test-tenant");
|
||||||
|
|
||||||
// Fill required fields
|
// Fill required fields
|
||||||
@@ -305,6 +400,303 @@ test.describe("User Management", () => {
|
|||||||
await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 });
|
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 ({
|
test("should show conflict error when creating with an existing Login ID", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -312,6 +704,8 @@ test.describe("User Management", () => {
|
|||||||
|
|
||||||
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
|
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
|
||||||
|
|
||||||
// Select Tenant first (important for schema fields to show up)
|
// Select Tenant first (important for schema fields to show up)
|
||||||
await page.selectOption("select#tenantSlug", "test-tenant");
|
await page.selectOption("select#tenantSlug", "test-tenant");
|
||||||
|
|
||||||
|
|||||||
82
adminfront/tests/users_live.spec.ts
Normal file
82
adminfront/tests/users_live.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -300,9 +300,6 @@ func main() {
|
|||||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
||||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||||
|
|
||||||
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
|
|
||||||
orgChartHandler := handler.NewOrgChartHandler(orgChartService)
|
|
||||||
|
|
||||||
// 3. Initialize Fiber
|
// 3. Initialize Fiber
|
||||||
appEnv := getEnv("APP_ENV", "dev")
|
appEnv := getEnv("APP_ENV", "dev")
|
||||||
clientLogDebugFlag := getEnv("CLIENT_LOG_DEBUG", "")
|
clientLogDebugFlag := getEnv("CLIENT_LOG_DEBUG", "")
|
||||||
@@ -622,6 +619,8 @@ func main() {
|
|||||||
|
|
||||||
// Tenant Management (Mixed roles, handler filters results)
|
// Tenant Management (Mixed roles, handler filters results)
|
||||||
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
|
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)
|
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
|
||||||
|
|
||||||
// [New] Shared Link Management
|
// [New] Shared Link Management
|
||||||
@@ -643,9 +642,6 @@ func main() {
|
|||||||
|
|
||||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||||
org := admin.Group("/tenants/:tenantId/organization")
|
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.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.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
|
||||||
org.Get("/:id", userGroupHandler.Get)
|
org.Get("/:id", userGroupHandler.Get)
|
||||||
@@ -688,7 +684,7 @@ func main() {
|
|||||||
|
|
||||||
// Admin User Management
|
// Admin User Management
|
||||||
admin.Get("/users", requireAnyUser, userHandler.ListUsers)
|
admin.Get("/users", requireAnyUser, userHandler.ListUsers)
|
||||||
admin.Get("/users/export", userHandler.ExportUsersCSV) // Removed requireAdmin to handle mock role in query param
|
admin.Get("/users/export", requireAdmin, userHandler.ExportUsersCSV)
|
||||||
admin.Post("/users", requireAdmin, userHandler.CreateUser)
|
admin.Post("/users", requireAdmin, userHandler.CreateUser)
|
||||||
admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers)
|
admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers)
|
||||||
admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers)
|
admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ require (
|
|||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/testcontainers/testcontainers-go v0.40.0
|
github.com/testcontainers/testcontainers-go v0.40.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres 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/crypto v0.48.0
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
@@ -105,8 +104,6 @@ require (
|
|||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||||
github.com/valyala/tcplisten v1.0.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
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||||
|
|||||||
@@ -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/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/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
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=
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
|
|||||||
@@ -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})
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,11 @@ import (
|
|||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/utils"
|
"baron-sso-backend/internal/utils"
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -57,6 +61,23 @@ type tenantListResponse struct {
|
|||||||
Total int64 `json:"total"`
|
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 {
|
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
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})
|
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 {
|
func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||||
if h.DB == nil {
|
if h.DB == nil {
|
||||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"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) {
|
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
|
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) {
|
func TestTenantHandler_ApproveTenant(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
|
|||||||
@@ -787,6 +787,9 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||||
search := strings.TrimSpace(c.Query("search"))
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
companyCode := strings.TrimSpace(c.Query("companyCode"))
|
companyCode := strings.TrimSpace(c.Query("companyCode"))
|
||||||
|
if companyCode == "" {
|
||||||
|
companyCode = strings.TrimSpace(c.Query("tenantSlug"))
|
||||||
|
}
|
||||||
|
|
||||||
var requesterRole string
|
var requesterRole string
|
||||||
var manageableSlugs []string
|
var manageableSlugs []string
|
||||||
@@ -867,7 +870,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|||||||
defer writer.Flush()
|
defer writer.Flush()
|
||||||
|
|
||||||
// Header row
|
// 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
|
// Collect all possible metadata keys for dynamic columns
|
||||||
metaKeysMap := make(map[string]bool)
|
metaKeysMap := make(map[string]bool)
|
||||||
@@ -892,10 +895,9 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|||||||
u.ID,
|
u.ID,
|
||||||
u.Email,
|
u.Email,
|
||||||
u.Name,
|
u.Name,
|
||||||
u.Role,
|
u.Phone,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.CompanyCode,
|
u.CompanyCode,
|
||||||
u.Department,
|
|
||||||
u.Position,
|
u.Position,
|
||||||
u.JobTitle,
|
u.JobTitle,
|
||||||
u.CreatedAt.Format(time.RFC3339),
|
u.CreatedAt.Format(time.RFC3339),
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -133,6 +136,51 @@ func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context,
|
|||||||
|
|
||||||
// --- Tests ---
|
// --- 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) {
|
func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
|||||||
@@ -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: ¤tParentID,
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user