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,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||
|
||||
type UserGroupNode = GroupSummary & {
|
||||
children: UserGroupNode[];
|
||||
@@ -445,10 +444,6 @@ function TenantGroupsPage() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<OrgChartUploadModal
|
||||
tenantId={tenantId}
|
||||
onSuccess={() => groupsQuery.refetch()}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
CornerDownRight,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
@@ -21,6 +23,14 @@ import {
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Checkbox } from "../../../components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
@@ -34,16 +44,35 @@ import {
|
||||
type TenantSummary,
|
||||
deleteTenant,
|
||||
deleteTenantsBulk,
|
||||
exportTenantsCSV,
|
||||
fetchMe,
|
||||
fetchTenants,
|
||||
importTenantsCSV,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||
import {
|
||||
type TenantImportPreviewRow,
|
||||
buildTenantImportPreview,
|
||||
parseTenantCSV,
|
||||
serializeTenantImportCSV,
|
||||
} from "../utils/tenantCsvImport";
|
||||
|
||||
const tenantCSVTemplate =
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n";
|
||||
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [importMessage, setImportMessage] = React.useState("");
|
||||
const [previewRows, setPreviewRows] = React.useState<
|
||||
TenantImportPreviewRow[]
|
||||
>([]);
|
||||
const [selectedMatches, setSelectedMatches] = React.useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
@@ -87,6 +116,50 @@ function TenantListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: exportTenantsCSV,
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
});
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (file: File) => importTenantsCSV(file),
|
||||
onSuccess: (result) => {
|
||||
setImportMessage(
|
||||
t(
|
||||
"msg.admin.tenants.import_result",
|
||||
"생성 {{created}}, 갱신 {{updated}}, 실패 {{failed}}",
|
||||
{
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
failed: result.failed,
|
||||
},
|
||||
),
|
||||
);
|
||||
setPreviewOpen(false);
|
||||
setPreviewRows([]);
|
||||
setSelectedMatches({});
|
||||
query.refetch();
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
setImportMessage(
|
||||
error.response?.data?.error ??
|
||||
t(
|
||||
"msg.admin.tenants.import_error",
|
||||
"테넌트 가져오기에 실패했습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
profile &&
|
||||
profile.role !== "super_admin" &&
|
||||
@@ -161,8 +234,50 @@ function TenantListPage() {
|
||||
deleteBulkMutation.mutate(selectedIds);
|
||||
};
|
||||
|
||||
const rootTenant =
|
||||
tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
|
||||
const handleTemplateDownload = () => {
|
||||
const blob = new Blob([tenantCSVTemplate], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "tenant-import-template.csv";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImportFile = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
setImportMessage("");
|
||||
const text = await file.text();
|
||||
const rows = parseTenantCSV(text);
|
||||
if (rows.length === 0) {
|
||||
setImportMessage(
|
||||
t("msg.admin.tenants.import_empty", "가져올 테넌트 행이 없습니다."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const preview = buildTenantImportPreview(rows, allTenants);
|
||||
setPreviewRows(preview);
|
||||
setSelectedMatches(
|
||||
Object.fromEntries(
|
||||
preview
|
||||
.filter((row) => row.defaultTenantId)
|
||||
.map((row) => [row.row.rowNumber, row.defaultTenantId]),
|
||||
),
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleImportConfirm = () => {
|
||||
const csv = serializeTenantImportCSV(previewRows, selectedMatches);
|
||||
const file = new File([csv], "tenants.csv", { type: "text/csv" });
|
||||
importMutation.mutate(file);
|
||||
};
|
||||
|
||||
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||
if (
|
||||
@@ -210,10 +325,40 @@ function TenantListPage() {
|
||||
</RoleGuard>
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<OrgChartUploadModal
|
||||
tenantId={rootTenant?.id || "root"}
|
||||
onSuccess={() => query.refetch()}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
data-testid="tenant-import-input"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTemplateDownload}
|
||||
data-testid="tenant-template-btn"
|
||||
>
|
||||
<FileSpreadsheet size={16} />
|
||||
{t("ui.admin.tenants.csv_template", "템플릿")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => exportMutation.mutate()}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-btn"
|
||||
>
|
||||
<Download size={16} />
|
||||
{t("ui.admin.tenants.export", "내보내기")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importMutation.isPending}
|
||||
data-testid="tenant-import-btn"
|
||||
>
|
||||
<Upload size={16} />
|
||||
{t("ui.admin.tenants.import", "가져오기")}
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
|
||||
<Button
|
||||
@@ -233,6 +378,14 @@ function TenantListPage() {
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
</div>
|
||||
{importMessage && (
|
||||
<div
|
||||
className="basis-full rounded-md border border-border bg-secondary px-3 py-2 text-sm"
|
||||
data-testid="tenant-import-result"
|
||||
>
|
||||
{importMessage}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
@@ -247,7 +400,7 @@ function TenantListPage() {
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>{" "}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-6 flex items-center gap-4 flex-shrink-0">
|
||||
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
||||
@@ -410,6 +563,139 @@ function TenantListPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.import_preview.title", "CSV 가져오기 확인")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.import_preview.description",
|
||||
"tenant_id가 없는 행은 기존 테넌트 후보와 비교한 뒤 신규 생성 또는 기존 테넌트 갱신으로 처리합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-secondary">
|
||||
<TableRow>
|
||||
<TableHead className="w-[72px]">
|
||||
{t("ui.common.row", "행")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.import_preview.match", "매칭")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.import_preview.candidates", "후보")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{previewRows.map((preview) => (
|
||||
<TableRow
|
||||
key={preview.row.rowNumber}
|
||||
data-testid={`tenant-import-preview-row-${preview.row.rowNumber}`}
|
||||
>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{preview.row.rowNumber}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{preview.row.name}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{preview.row.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{preview.row.tenantId ? (
|
||||
<Badge variant="outline">
|
||||
{t(
|
||||
"ui.admin.tenants.import_preview.fixed_id",
|
||||
"ID 지정됨",
|
||||
)}
|
||||
</Badge>
|
||||
) : (
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={selectedMatches[preview.row.rowNumber] ?? ""}
|
||||
data-testid={`tenant-import-match-select-${preview.row.rowNumber}`}
|
||||
onChange={(event) =>
|
||||
setSelectedMatches((prev) => ({
|
||||
...prev,
|
||||
[preview.row.rowNumber]: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{t(
|
||||
"ui.admin.tenants.import_preview.create_new",
|
||||
"신규 생성",
|
||||
)}
|
||||
</option>
|
||||
{preview.candidates.map((candidate) => (
|
||||
<option
|
||||
key={candidate.tenantId}
|
||||
value={candidate.tenantId}
|
||||
>
|
||||
{candidate.name} ({candidate.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{preview.candidates.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{preview.candidates.map((candidate) => (
|
||||
<Badge
|
||||
key={candidate.tenantId}
|
||||
variant={
|
||||
candidate.score >= 0.95 ? "default" : "outline"
|
||||
}
|
||||
data-testid="tenant-import-candidate"
|
||||
>
|
||||
{candidate.name}{" "}
|
||||
{Math.round(candidate.score * 100)}%
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.tenants.import_preview.no_candidates",
|
||||
"후보 없음",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPreviewOpen(false)}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={importMutation.isPending}
|
||||
data-testid="tenant-import-confirm-btn"
|
||||
>
|
||||
{t("ui.admin.tenants.import_preview.confirm", "가져오기 실행")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -46,14 +46,14 @@ import {
|
||||
bulkDeleteUsers,
|
||||
bulkUpdateUsers,
|
||||
deleteUser,
|
||||
exportUsersCSVUrl,
|
||||
exportUsersCSV,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
updateUser,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
|
||||
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
||||
|
||||
type UserSchemaField = {
|
||||
@@ -144,6 +144,41 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: () => exportUsersCSV(search, selectedCompany),
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t(
|
||||
"msg.admin.users.export_error",
|
||||
"사용자 내보내기에 실패했습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ userId, status }: { userId: string; status: string }) =>
|
||||
updateUser(userId, { status }),
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t("msg.admin.users.status_error", "사용자 상태 변경에 실패했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearch(searchDraft);
|
||||
setPage(1);
|
||||
@@ -156,8 +191,7 @@ function UserListPage() {
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const url = exportUsersCSVUrl(search, selectedCompany);
|
||||
window.open(url, "_blank");
|
||||
exportMutation.mutate();
|
||||
};
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
@@ -275,7 +309,12 @@ function UserListPage() {
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExport} className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
className="gap-2"
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
<FileDown size={16} />
|
||||
{t("ui.common.export", "내보내기")}
|
||||
</Button>
|
||||
@@ -428,21 +467,12 @@ function UserListPage() {
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t(
|
||||
"ui.admin.users.list.table.name_email",
|
||||
"NAME / EMAIL",
|
||||
"이름 / 이메일 / 전화번호",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.admin.users.list.table.tenant_dept",
|
||||
"TENANT / DEPT",
|
||||
)}
|
||||
</TableHead>
|
||||
{/* Dynamic Columns from Schema */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
@@ -464,7 +494,7 @@ function UserListPage() {
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7 + userSchema.length}
|
||||
colSpan={5 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
@@ -474,7 +504,7 @@ function UserListPage() {
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7 + userSchema.length}
|
||||
colSpan={5 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
@@ -513,35 +543,47 @@ function UserListPage() {
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className="truncate text-sm"
|
||||
data-testid={`user-contact-${user.id}`}
|
||||
>
|
||||
<span className="font-medium">{user.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
{user.email}
|
||||
</span>
|
||||
{user.phone && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
{user.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{t(`ui.admin.role.${user.role}`, user.role)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
user.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="font-medium text-blue-600">
|
||||
{user.tenant?.name || user.tenantSlug || "-"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department || "-"}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={user.status === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
statusMutation.mutate({
|
||||
userId: user.id,
|
||||
status: checked ? "active" : "inactive",
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.toggle_status",
|
||||
"{{name}} 활성 상태",
|
||||
{ name: user.name },
|
||||
)}
|
||||
data-testid={`user-status-toggle-${user.id}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -625,16 +667,6 @@ function UserListPage() {
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성화")}
|
||||
</Button>
|
||||
<UserBulkMoveGroupModal
|
||||
userIds={selectedUserIds}
|
||||
selectedUsers={items.filter((u) =>
|
||||
selectedUserIds.includes(u.id),
|
||||
)}
|
||||
onSuccess={() => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
}}
|
||||
/>
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
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>;
|
||||
};
|
||||
|
||||
export type TenantImportResult = {
|
||||
created: number;
|
||||
updated: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export type ApiKeySummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -145,6 +152,36 @@ export async function deleteTenantsBulk(ids: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportTenantsCSV() {
|
||||
const response = await apiClient.get<Blob>("/v1/admin/tenants/export", {
|
||||
responseType: "blob",
|
||||
});
|
||||
const dispositionHeader = response.headers["content-disposition"];
|
||||
const disposition = Array.isArray(dispositionHeader)
|
||||
? dispositionHeader[0]
|
||||
: String(dispositionHeader ?? "");
|
||||
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/i);
|
||||
return {
|
||||
blob: response.data,
|
||||
filename: filenameMatch?.[1] ?? "tenants.csv",
|
||||
};
|
||||
}
|
||||
|
||||
export async function importTenantsCSV(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const { data } = await apiClient.post<TenantImportResult>(
|
||||
"/v1/admin/tenants/import",
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function approveTenant(tenantId: string) {
|
||||
const { data } = await apiClient.post<TenantSummary>(
|
||||
`/v1/admin/tenants/${tenantId}/approve`,
|
||||
@@ -266,44 +303,6 @@ export async function removeGroupMember(
|
||||
);
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
totalRows: number;
|
||||
processed: number;
|
||||
userCreated: number;
|
||||
userUpdated: number;
|
||||
tenantCreated: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export async function fetchImportProgress(
|
||||
tenantId: string,
|
||||
progressId: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<{ current: number; total: number }>(
|
||||
`/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function importOrgChart(
|
||||
tenantId: string,
|
||||
file: File,
|
||||
progressId?: string,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const url = progressId
|
||||
? `/v1/admin/tenants/${tenantId}/organization/import?progressId=${progressId}`
|
||||
: `/v1/admin/tenants/${tenantId}/organization/import`;
|
||||
|
||||
const { data } = await apiClient.post<{ data: ImportResult }>(url, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export type GroupRole = {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
@@ -412,6 +411,10 @@ export type UserCreateRequest = {
|
||||
department?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
primaryTenantId?: string;
|
||||
primaryTenantName?: string;
|
||||
primaryTenantIsOwner?: boolean;
|
||||
additionalAppointments?: UserAppointment[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -430,9 +433,22 @@ export type UserUpdateRequest = {
|
||||
department?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
primaryTenantId?: string;
|
||||
primaryTenantName?: string;
|
||||
primaryTenantIsOwner?: boolean;
|
||||
additionalAppointments?: UserAppointment[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type UserAppointment = {
|
||||
tenantId: string;
|
||||
tenantSlug?: string;
|
||||
tenantName: string;
|
||||
isOwner: boolean;
|
||||
jobTitle?: string;
|
||||
position?: string;
|
||||
};
|
||||
|
||||
export type BulkUserItem = {
|
||||
email: string;
|
||||
loginId?: string;
|
||||
@@ -492,19 +508,20 @@ export async function createUser(payload: UserCreateRequest) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.append("search", search);
|
||||
if (tenantSlug) params.append("tenantSlug", tenantSlug);
|
||||
|
||||
// Get mock role from storage if exists for dev environment
|
||||
const isMockRoleEnabled =
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||
if (isMockRoleEnabled && mockRole) params.append("x-test-role", mockRole);
|
||||
|
||||
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
|
||||
return `${baseUrl}/admin/users/export?${params.toString()}`;
|
||||
export async function exportUsersCSV(search?: string, tenantSlug?: string) {
|
||||
const response = await apiClient.get<Blob>("/v1/admin/users/export", {
|
||||
params: { search, tenantSlug },
|
||||
responseType: "blob",
|
||||
});
|
||||
const dispositionHeader = response.headers["content-disposition"];
|
||||
const disposition = Array.isArray(dispositionHeader)
|
||||
? dispositionHeader[0]
|
||||
: String(dispositionHeader ?? "");
|
||||
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/i);
|
||||
return {
|
||||
blob: response.data,
|
||||
filename: filenameMatch?.[1] ?? "users.csv",
|
||||
};
|
||||
}
|
||||
|
||||
export async function bulkCreateUsers(users: BulkUserItem[]) {
|
||||
|
||||
@@ -114,24 +114,100 @@ test.describe("Tenants Management", () => {
|
||||
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
|
||||
});
|
||||
|
||||
test("should clarify organization import creates org tenants and users together", async ({
|
||||
test("should export and import tenant CSV without organization/user combined import", async ({
|
||||
page,
|
||||
}) => {
|
||||
let exportRequested = false;
|
||||
let importRequested = false;
|
||||
let importBody = "";
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
const url = route.request().url();
|
||||
const method = route.request().method();
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
|
||||
if (url.includes("/export")) {
|
||||
exportRequested = true;
|
||||
return route.fulfill({
|
||||
body: "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n",
|
||||
contentType: "text/csv",
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Disposition": 'attachment; filename="tenants.csv"',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("/import")) {
|
||||
importRequested = true;
|
||||
importBody = route.request().postData() ?? "";
|
||||
return route.fulfill({
|
||||
json: { created: 0, updated: 1, failed: 0, errors: [] },
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "tenant-alpha-id",
|
||||
name: "Tenant Alpha",
|
||||
slug: "tenant-alpha",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await page.goto("/tenants");
|
||||
await expect(page.locator("h2").last()).toContainText(
|
||||
/테넌트 목록|Tenants/i,
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
await page
|
||||
.locator("button")
|
||||
.filter({ hasText: /임포트|Import/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByText(/조직\/사용자 통합/)).toHaveCount(0);
|
||||
await expect(page.getByTestId("tenant-template-btn")).toBeVisible();
|
||||
await expect(page.getByTestId("tenant-export-btn")).toBeVisible();
|
||||
await expect(page.getByTestId("tenant-import-btn")).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("dialog")).toContainText(
|
||||
/조직\/사용자 통합 일괄 등록|organization and user batch registration/i,
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByTestId("tenant-export-btn").click();
|
||||
await download;
|
||||
expect(exportRequested).toBe(true);
|
||||
|
||||
await page.getByTestId("tenant-import-input").setInputFiles({
|
||||
name: "tenants.csv",
|
||||
mimeType: "text/csv",
|
||||
buffer: Buffer.from(
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Tenant Alpha,COMPANY,,tenant-alpha-copy,Imported memo,imported.example.com\n",
|
||||
),
|
||||
});
|
||||
|
||||
await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인");
|
||||
await expect(page.getByTestId("tenant-import-candidate")).toContainText(
|
||||
"Tenant Alpha",
|
||||
);
|
||||
await page.getByTestId("tenant-import-confirm-btn").click();
|
||||
|
||||
await expect(page.getByTestId("tenant-import-result")).toContainText(
|
||||
/갱신 1|Updated 1/i,
|
||||
);
|
||||
expect(importRequested).toBe(true);
|
||||
expect(importBody).toContain("tenant-alpha-id");
|
||||
});
|
||||
|
||||
test("should show validation error on empty name", async ({ page }) => {
|
||||
|
||||
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,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.match(/\/admin\/tenants$/) && method === "POST") {
|
||||
const postData = route.request().postDataJSON();
|
||||
return route.fulfill({
|
||||
status: 201,
|
||||
json: {
|
||||
id: "personal-tenant-id",
|
||||
slug: postData?.slug || "personal",
|
||||
name: postData?.name || "Personal",
|
||||
type: postData?.type || "PERSONAL",
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.match(/\/admin\/tenants\/t-1$/) && method === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
@@ -107,6 +166,21 @@ test.describe("User Management", () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
url.match(/\/admin\/tenants\/03dbe16b-e47b-4f72-927b-782807d67a35$/) &&
|
||||
method === "GET"
|
||||
) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||
slug: "tech-planning",
|
||||
name: "기술기획",
|
||||
type: "USER_GROUP",
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.match(/\/admin\/users\/u-1$/) && method === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
@@ -185,6 +259,7 @@ test.describe("User Management", () => {
|
||||
name: "John Doe Updated",
|
||||
email: "john@test.com",
|
||||
loginId: "johndoe_updated",
|
||||
status: "inactive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -197,9 +272,11 @@ test.describe("User Management", () => {
|
||||
id: "u-1",
|
||||
name: "John Doe",
|
||||
email: "john@test.com",
|
||||
phone: "010-1111-2222",
|
||||
loginId: "johndoe",
|
||||
role: "user",
|
||||
status: "active",
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
@@ -285,8 +362,26 @@ test.describe("User Management", () => {
|
||||
|
||||
// Ensure the page title is loaded
|
||||
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
|
||||
const userTypeTabs = page.getByRole("tab");
|
||||
await expect(userTypeTabs).toHaveText([
|
||||
"한맥가족 구성원",
|
||||
"외부 기업 회원",
|
||||
"개인 회원",
|
||||
]);
|
||||
await expect(page.getByRole("tab", { name: /외부 기업 회원/i })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: /개인 회원/i })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||
).toHaveAttribute("data-state", "active");
|
||||
await expect(
|
||||
page.getByLabel(/한맥 가족 구성원으로 등록/i),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Select Tenant first (important for schema fields to show up)
|
||||
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
|
||||
await page.selectOption("select#tenantSlug", "test-tenant");
|
||||
|
||||
// Fill required fields
|
||||
@@ -305,6 +400,303 @@ test.describe("User Management", () => {
|
||||
await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test("should export users through the authenticated API client", async ({
|
||||
page,
|
||||
}) => {
|
||||
let authorizationHeader: string | undefined;
|
||||
|
||||
await page.route(/\/admin\/users\/export(\?.*)?$/, async (route) => {
|
||||
authorizationHeader = route.request().headers().authorization;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/csv; charset=utf-8",
|
||||
"content-disposition": 'attachment; filename="users.csv"',
|
||||
},
|
||||
body: "email,name\njohn@test.com,John Doe\n",
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/users");
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: /내보내기|Export/i }).click(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename()).toBe("users.csv");
|
||||
expect(authorizationHeader).toBe("Bearer fake-token");
|
||||
});
|
||||
|
||||
test("should show contact info in one row, hide roles, and toggle user status", async ({
|
||||
page,
|
||||
}) => {
|
||||
let updatePayload: Record<string, unknown> | undefined;
|
||||
|
||||
await page.route(/\/admin\/users\/u-1$/, async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
updatePayload = route.request().postDataJSON();
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "u-1",
|
||||
name: "John Doe",
|
||||
email: "john@test.com",
|
||||
phone: "010-1111-2222",
|
||||
loginId: "johndoe",
|
||||
status: "inactive",
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/users");
|
||||
const table = page.locator("table");
|
||||
await expect(
|
||||
table.getByRole("columnheader", { name: /ROLE|역할/i }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("user-contact-u-1")).toContainText(
|
||||
"John Doe john@test.com 010-1111-2222",
|
||||
);
|
||||
|
||||
await page.getByTestId("user-status-toggle-u-1").click();
|
||||
await expect
|
||||
.poll(() => updatePayload)
|
||||
.toMatchObject({ status: "inactive" });
|
||||
});
|
||||
|
||||
test("should create a Hanmac family user with tenant appointments and no representative affiliation", async ({
|
||||
page,
|
||||
}) => {
|
||||
let createPayload: Record<string, unknown> | undefined;
|
||||
|
||||
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
createPayload = route.request().postDataJSON();
|
||||
return route.fulfill({
|
||||
status: 201,
|
||||
json: {
|
||||
id: "new-user-id",
|
||||
name: "Family User",
|
||||
email: "family@test.com",
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/users/new");
|
||||
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||
).toHaveAttribute("data-state", "active");
|
||||
await expect(
|
||||
page.getByLabel(/한맥 가족 구성원으로 등록/i),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator("select#role")).toHaveCount(0);
|
||||
await expect(page.locator("input#department")).toHaveCount(0);
|
||||
|
||||
await expect(page.getByText(/대표 소속/i)).toHaveCount(0);
|
||||
await page.getByRole("button", { name: /^추가$/i }).click();
|
||||
await expect(page.getByTestId("appointment-row-0")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("appointment-tenant-owner-line-0"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("appointment-position-line-0")).toBeVisible();
|
||||
await page.getByRole("button", { name: /테넌트 선택/i }).click();
|
||||
|
||||
await expect(page.getByTitle(/테넌트 선택/i)).toHaveAttribute(
|
||||
"src",
|
||||
/\/login\?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id$/,
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "orgfront:picker:confirm",
|
||||
payload: {
|
||||
mode: "single",
|
||||
selections: [
|
||||
{
|
||||
type: "tenant",
|
||||
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||
name: "기술기획",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(page.getByText("기술기획")).toBeVisible();
|
||||
await page.getByLabel(/조직장/i).check();
|
||||
await page.getByLabel(/^직무$/i).fill("플랫폼 운영");
|
||||
await page.getByLabel(/^직급$/i).fill("책임");
|
||||
|
||||
await page.locator('input[name="name"]').fill("Family User");
|
||||
await page.locator('input[name="email"]').fill("family@test.com");
|
||||
await page.getByRole("button", { name: /생성/i }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => createPayload)
|
||||
.toMatchObject({
|
||||
metadata: {
|
||||
hanmacFamily: true,
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||
tenantSlug: "tech-planning",
|
||||
tenantName: "기술기획",
|
||||
isOwner: true,
|
||||
jobTitle: "플랫폼 운영",
|
||||
position: "책임",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(createPayload).not.toHaveProperty("role");
|
||||
expect(createPayload).not.toHaveProperty("department");
|
||||
expect(createPayload).not.toHaveProperty("tenantSlug");
|
||||
expect(createPayload).not.toHaveProperty("companyCode");
|
||||
expect(createPayload).not.toHaveProperty("primaryTenantId");
|
||||
});
|
||||
|
||||
test("should hide Hanmac family subtree and system tenants when creating a non-family user", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/users/new");
|
||||
|
||||
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
|
||||
|
||||
const tenantOptionValues = await page
|
||||
.locator("select#tenantSlug option")
|
||||
.evaluateAll((options) =>
|
||||
options.map((option) => (option as HTMLOptionElement).value),
|
||||
);
|
||||
|
||||
expect(tenantOptionValues).toContain("test-tenant");
|
||||
expect(tenantOptionValues).toContain("external-tenant");
|
||||
expect(tenantOptionValues).not.toContain("");
|
||||
expect(tenantOptionValues).not.toContain("system");
|
||||
expect(tenantOptionValues).not.toContain("hanmac-family");
|
||||
expect(tenantOptionValues).not.toContain("hanmac-company");
|
||||
expect(tenantOptionValues).not.toContain("hanmac-team");
|
||||
expect(tenantOptionValues).not.toContain("tech-planning");
|
||||
});
|
||||
|
||||
test("should create a personal user and provision Personal tenant when missing", async ({
|
||||
page,
|
||||
}) => {
|
||||
let tenantPayload: Record<string, unknown> | undefined;
|
||||
let createPayload: Record<string, unknown> | undefined;
|
||||
|
||||
await page.route(/\/admin\/tenants$/, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
tenantPayload = route.request().postDataJSON();
|
||||
return route.fulfill({
|
||||
status: 201,
|
||||
json: {
|
||||
id: "personal-tenant-id",
|
||||
slug: "personal",
|
||||
name: "Personal",
|
||||
type: "PERSONAL",
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
createPayload = route.request().postDataJSON();
|
||||
return route.fulfill({
|
||||
status: 201,
|
||||
json: {
|
||||
id: "personal-user-id",
|
||||
name: "Personal User",
|
||||
email: "personal@test.com",
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/users/new");
|
||||
await page.getByRole("tab", { name: /개인 회원/i }).click();
|
||||
|
||||
await expect(page.getByTestId("personal-tenant-summary")).toContainText(
|
||||
/Personal/i,
|
||||
);
|
||||
await page.locator('input[name="name"]').fill("Personal User");
|
||||
await page.locator('input[name="email"]').fill("personal@test.com");
|
||||
await page.getByRole("button", { name: /생성/i }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => tenantPayload)
|
||||
.toMatchObject({ name: "Personal", slug: "personal", type: "PERSONAL" });
|
||||
await expect
|
||||
.poll(() => createPayload)
|
||||
.toMatchObject({
|
||||
tenantSlug: "personal",
|
||||
metadata: { userType: "personal", hanmacFamily: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("should show Hanmac family appointments layout on user detail", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route(/\/admin\/users\/u-1$/, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "u-1",
|
||||
name: "Family User",
|
||||
email: "family@test.com",
|
||||
phone: "010-1111-2222",
|
||||
loginId: "familyuser",
|
||||
role: "user",
|
||||
status: "active",
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
updatedAt: "2026-04-01T00:00:00Z",
|
||||
metadata: {
|
||||
hanmacFamily: true,
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||
tenantSlug: "tech-planning",
|
||||
tenantName: "기술기획",
|
||||
isOwner: true,
|
||||
jobTitle: "플랫폼 운영",
|
||||
position: "책임",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/users/u-1");
|
||||
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||
).toHaveAttribute("data-state", "active");
|
||||
await expect(
|
||||
page.getByLabel(/한맥 가족 구성원으로 등록/i),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("detail-appointment-tenant-owner-line-0"),
|
||||
).toContainText(/기술기획|조직장/);
|
||||
await expect(
|
||||
page.getByTestId("detail-appointment-position-line-0"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show conflict error when creating with an existing Login ID", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -312,6 +704,8 @@ test.describe("User Management", () => {
|
||||
|
||||
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
|
||||
|
||||
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
|
||||
|
||||
// Select Tenant first (important for schema fields to show up)
|
||||
await page.selectOption("select#tenantSlug", "test-tenant");
|
||||
|
||||
|
||||
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)
|
||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||
|
||||
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
|
||||
orgChartHandler := handler.NewOrgChartHandler(orgChartService)
|
||||
|
||||
// 3. Initialize Fiber
|
||||
appEnv := getEnv("APP_ENV", "dev")
|
||||
clientLogDebugFlag := getEnv("CLIENT_LOG_DEBUG", "")
|
||||
@@ -622,6 +619,8 @@ func main() {
|
||||
|
||||
// Tenant Management (Mixed roles, handler filters results)
|
||||
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
|
||||
admin.Get("/tenants/export", requireSuperAdmin, tenantHandler.ExportTenantsCSV)
|
||||
admin.Post("/tenants/import", requireSuperAdmin, tenantHandler.ImportTenantsCSV)
|
||||
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
|
||||
|
||||
// [New] Shared Link Management
|
||||
@@ -643,9 +642,6 @@ func main() {
|
||||
|
||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||
org := admin.Group("/tenants/:tenantId/organization")
|
||||
org.Post("/import", orgChartHandler.ImportOrgChart) // Org Chart Bulk Import API
|
||||
org.Get("/import/progress/:progressId", orgChartHandler.GetImportProgress) // Progress API
|
||||
|
||||
org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
|
||||
org.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
|
||||
org.Get("/:id", userGroupHandler.Get)
|
||||
@@ -688,7 +684,7 @@ func main() {
|
||||
|
||||
// Admin User Management
|
||||
admin.Get("/users", requireAnyUser, userHandler.ListUsers)
|
||||
admin.Get("/users/export", userHandler.ExportUsersCSV) // Removed requireAdmin to handle mock role in query param
|
||||
admin.Get("/users/export", requireAdmin, userHandler.ExportUsersCSV)
|
||||
admin.Post("/users", requireAdmin, userHandler.CreateUser)
|
||||
admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers)
|
||||
admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers)
|
||||
|
||||
@@ -19,7 +19,6 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go v0.40.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
|
||||
github.com/xuri/excelize/v2 v2.10.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
@@ -105,8 +104,6 @@ require (
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||
|
||||
@@ -235,12 +235,6 @@ github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7Fw
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
|
||||
@@ -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/service"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -57,6 +61,23 @@ type tenantListResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type tenantImportResult struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Failed int `json:"failed"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
type tenantCSVRecord struct {
|
||||
TenantID string
|
||||
Name string
|
||||
Type string
|
||||
ParentTenantID *string
|
||||
Slug string
|
||||
Memo string
|
||||
Domains []string
|
||||
}
|
||||
|
||||
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
@@ -229,6 +250,361 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
|
||||
}
|
||||
|
||||
func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
||||
tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "")
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := csv.NewWriter(&buf)
|
||||
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "slug", "memo", "email_domain"}); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
for _, tenant := range tenants {
|
||||
parentID := ""
|
||||
if tenant.ParentID != nil {
|
||||
parentID = *tenant.ParentID
|
||||
}
|
||||
domains := make([]string, 0, len(tenant.Domains))
|
||||
for _, domainName := range tenant.Domains {
|
||||
domainName := strings.TrimSpace(domainName.Domain)
|
||||
if domainName != "" {
|
||||
domains = append(domains, domainName)
|
||||
}
|
||||
}
|
||||
if err := writer.Write([]string{
|
||||
tenant.ID,
|
||||
tenant.Name,
|
||||
tenant.Type,
|
||||
parentID,
|
||||
tenant.Slug,
|
||||
tenant.Description,
|
||||
strings.Join(domains, ";"),
|
||||
}); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
c.Set(fiber.HeaderContentType, "text/csv")
|
||||
c.Set(fiber.HeaderContentDisposition, `attachment; filename="tenants.csv"`)
|
||||
return c.Send(buf.Bytes())
|
||||
}
|
||||
|
||||
func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
|
||||
reader, err := tenantCSVReaderFromRequest(c)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
records, err := parseTenantCSVRecords(reader)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
creatorID := ""
|
||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||
creatorID = profile.ID
|
||||
}
|
||||
|
||||
result := tenantImportResult{Errors: make([]string, 0)}
|
||||
for i, record := range records {
|
||||
rowNumber := i + 2
|
||||
if record.TenantID != "" || (h.DB != nil && record.Slug != "") {
|
||||
updated, err := h.upsertTenantCSVRecord(c, record)
|
||||
if err != nil {
|
||||
result.Failed++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
|
||||
continue
|
||||
}
|
||||
if updated {
|
||||
result.Updated++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.createTenantCSVRecord(c, record, creatorID); err != nil {
|
||||
result.Failed++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
|
||||
continue
|
||||
}
|
||||
result.Created++
|
||||
}
|
||||
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
func tenantCSVReaderFromRequest(c *fiber.Ctx) (io.Reader, error) {
|
||||
file, err := c.FormFile("file")
|
||||
if err == nil && file != nil {
|
||||
opened, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to open uploaded file")
|
||||
}
|
||||
defer opened.Close()
|
||||
data, err := io.ReadAll(opened)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to read uploaded file")
|
||||
}
|
||||
return bytes.NewReader(data), nil
|
||||
}
|
||||
|
||||
body := c.Body()
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return nil, errors.New("csv file is required")
|
||||
}
|
||||
return bytes.NewReader(body), nil
|
||||
}
|
||||
|
||||
func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to read csv")
|
||||
}
|
||||
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||
|
||||
reader := csv.NewReader(bytes.NewReader(data))
|
||||
reader.FieldsPerRecord = -1
|
||||
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid csv: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, errors.New("csv is empty")
|
||||
}
|
||||
|
||||
header := tenantCSVHeaderIndex(rows[0])
|
||||
required := []string{"name", "type", "slug"}
|
||||
for _, key := range required {
|
||||
if _, ok := header[key]; !ok {
|
||||
return nil, fmt.Errorf("missing required column: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
records := make([]tenantCSVRecord, 0, len(rows)-1)
|
||||
for i, row := range rows[1:] {
|
||||
if tenantCSVRowIsEmpty(row) {
|
||||
continue
|
||||
}
|
||||
|
||||
name := tenantCSVValue(row, header, "name")
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("row %d: name is required", i+2)
|
||||
}
|
||||
|
||||
tenantType := normalizeTenantType(tenantCSVValue(row, header, "type"))
|
||||
if tenantType == "" {
|
||||
return nil, fmt.Errorf("row %d: invalid tenant type", i+2)
|
||||
}
|
||||
|
||||
slug := utils.GenerateSlug(tenantCSVValue(row, header, "slug"))
|
||||
if slug == "" {
|
||||
return nil, fmt.Errorf("row %d: slug is required", i+2)
|
||||
}
|
||||
|
||||
parentValue := tenantCSVValue(row, header, "parent_tenant_id")
|
||||
var parentID *string
|
||||
if parentValue != "" {
|
||||
parentID = &parentValue
|
||||
}
|
||||
|
||||
records = append(records, tenantCSVRecord{
|
||||
TenantID: tenantCSVValue(row, header, "tenant_id"),
|
||||
Name: name,
|
||||
Type: tenantType,
|
||||
ParentTenantID: parentID,
|
||||
Slug: slug,
|
||||
Memo: tenantCSVValue(row, header, "memo"),
|
||||
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
|
||||
})
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func tenantCSVHeaderIndex(header []string) map[string]int {
|
||||
index := make(map[string]int, len(header))
|
||||
aliases := map[string]string{
|
||||
"id": "tenant_id",
|
||||
"tenantid": "tenant_id",
|
||||
"tenant_id": "tenant_id",
|
||||
"name": "name",
|
||||
"type": "type",
|
||||
"parentid": "parent_tenant_id",
|
||||
"parent_id": "parent_tenant_id",
|
||||
"parenttenantid": "parent_tenant_id",
|
||||
"parent_tenant_id": "parent_tenant_id",
|
||||
"slug": "slug",
|
||||
"memo": "memo",
|
||||
"description": "memo",
|
||||
"email-domain": "email_domain",
|
||||
"emaildomain": "email_domain",
|
||||
"email_domain": "email_domain",
|
||||
"domain": "email_domain",
|
||||
"domains": "email_domain",
|
||||
}
|
||||
for i, column := range header {
|
||||
key := strings.ToLower(strings.TrimSpace(column))
|
||||
key = strings.ReplaceAll(key, " ", "_")
|
||||
if canonical, ok := aliases[key]; ok {
|
||||
index[canonical] = i
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func tenantCSVValue(row []string, header map[string]int, key string) string {
|
||||
idx, ok := header[key]
|
||||
if !ok || idx >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[idx])
|
||||
}
|
||||
|
||||
func tenantCSVRowIsEmpty(row []string) bool {
|
||||
for _, value := range row {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func splitTenantCSVDomains(value string) []string {
|
||||
value = strings.ReplaceAll(value, "\n", ";")
|
||||
value = strings.ReplaceAll(value, ",", ";")
|
||||
parts := strings.Split(value, ";")
|
||||
domains := make([]string, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
domainName := strings.ToLower(strings.TrimSpace(part))
|
||||
if domainName == "" || seen[domainName] {
|
||||
continue
|
||||
}
|
||||
seen[domainName] = true
|
||||
domains = append(domains, domainName)
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord) (bool, error) {
|
||||
if h.DB == nil {
|
||||
if record.TenantID != "" {
|
||||
return false, errors.New("database not available for tenant update")
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var tenant domain.Tenant
|
||||
query := h.DB.Preload("Domains")
|
||||
var err error
|
||||
if record.TenantID != "" {
|
||||
err = query.First(&tenant, "id = ?", record.TenantID).Error
|
||||
} else {
|
||||
err = query.First(&tenant, "slug = ?", record.Slug).Error
|
||||
}
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
tenant.Name = record.Name
|
||||
tenant.Type = record.Type
|
||||
tenant.ParentID = record.ParentTenantID
|
||||
tenant.Slug = record.Slug
|
||||
tenant.Description = record.Memo
|
||||
if tenant.Status == "" {
|
||||
tenant.Status = domain.TenantStatusActive
|
||||
}
|
||||
|
||||
if err := h.DB.Save(&tenant).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
repo := repository.NewTenantRepository(h.DB)
|
||||
for _, domainName := range record.Domains {
|
||||
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord, creatorID string) error {
|
||||
if h.DB != nil && record.TenantID != "" {
|
||||
var exists int64
|
||||
if err := h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", record.Slug).Count(&exists).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if exists > 0 {
|
||||
return errors.New("tenant slug already exists")
|
||||
}
|
||||
|
||||
tenant := domain.Tenant{
|
||||
ID: record.TenantID,
|
||||
Type: record.Type,
|
||||
ParentID: record.ParentTenantID,
|
||||
Name: record.Name,
|
||||
Slug: record.Slug,
|
||||
Description: record.Memo,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
if err := h.DB.Create(&tenant).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if h.KetoOutbox != nil {
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "admins",
|
||||
Subject: "System:global#super_admins",
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
if tenant.ParentID != nil {
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + *tenant.ParentID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
if creatorID != "" {
|
||||
for _, relation := range []string{"owners", "admins", "members"} {
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: relation,
|
||||
Subject: "User:" + creatorID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
repo := repository.NewTenantRepository(h.DB)
|
||||
for _, domainName := range record.Domains {
|
||||
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := h.Service.RegisterTenant(c.Context(), record.Name, record.Slug, record.Type, record.Memo, record.Domains, record.ParentTenantID, creatorID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -120,6 +123,12 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
|
||||
for _, call := range m.ExpectedCalls {
|
||||
if call.Method == "List" {
|
||||
args := m.Called(ctx, offset, limit, search, companyCode)
|
||||
return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
@@ -244,6 +253,84 @@ func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
h := &TenantHandler{Service: mockSvc}
|
||||
|
||||
app.Get("/tenants/export", h.ExportTenantsCSV)
|
||||
|
||||
parentID := "parent-1"
|
||||
tenants := []domain.Tenant{
|
||||
{
|
||||
ID: "t1",
|
||||
Name: "Tenant A",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &parentID,
|
||||
Slug: "tenant-a",
|
||||
Description: "Primary tenant",
|
||||
Domains: []domain.TenantDomain{
|
||||
{Domain: "tenant-a.example.com"},
|
||||
{Domain: "login.tenant-a.example.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(1), nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants/export", nil)
|
||||
resp, _ := app.Test(req)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv")
|
||||
assert.Equal(t, "text/csv", strings.Split(resp.Header.Get("Content-Type"), ";")[0])
|
||||
assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain")
|
||||
assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com")
|
||||
}
|
||||
|
||||
func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
h := &TenantHandler{Service: mockSvc}
|
||||
|
||||
app.Post("/tenants/import", h.ImportTenantsCSV)
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "tenants.csv")
|
||||
assert.NoError(t, err)
|
||||
_, err = part.Write([]byte("tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Imported Tenant,COMPANY,parent-1,imported-tenant,Imported memo,imported.example.com;login.imported.example.com\n"))
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, writer.Close())
|
||||
|
||||
mockSvc.On(
|
||||
"RegisterTenant",
|
||||
mock.Anything,
|
||||
"Imported Tenant",
|
||||
"imported-tenant",
|
||||
domain.TenantTypeCompany,
|
||||
"Imported memo",
|
||||
[]string{"imported.example.com", "login.imported.example.com"},
|
||||
mock.MatchedBy(func(parentID *string) bool {
|
||||
return parentID != nil && *parentID == "parent-1"
|
||||
}),
|
||||
"",
|
||||
).Return(&domain.Tenant{ID: "imported-1", Name: "Imported Tenant", Slug: "imported-tenant"}, nil)
|
||||
|
||||
req := httptest.NewRequest("POST", "/tenants/import", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var got map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.Equal(t, float64(1), got["created"])
|
||||
assert.Equal(t, float64(0), got["updated"])
|
||||
assert.Equal(t, float64(0), got["failed"])
|
||||
mockSvc.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ApproveTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
|
||||
@@ -787,6 +787,9 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
companyCode := strings.TrimSpace(c.Query("companyCode"))
|
||||
if companyCode == "" {
|
||||
companyCode = strings.TrimSpace(c.Query("tenantSlug"))
|
||||
}
|
||||
|
||||
var requesterRole string
|
||||
var manageableSlugs []string
|
||||
@@ -867,7 +870,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||
defer writer.Flush()
|
||||
|
||||
// Header row
|
||||
header := []string{"ID", "Email", "Name", "Role", "Status", "Tenant", "Department", "Position", "JobTitle", "CreatedAt"}
|
||||
header := []string{"ID", "Email", "Name", "Phone", "Status", "Tenant", "Position", "JobTitle", "CreatedAt"}
|
||||
|
||||
// Collect all possible metadata keys for dynamic columns
|
||||
metaKeysMap := make(map[string]bool)
|
||||
@@ -892,10 +895,9 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||
u.ID,
|
||||
u.Email,
|
||||
u.Name,
|
||||
u.Role,
|
||||
u.Phone,
|
||||
u.Status,
|
||||
u.CompanyCode,
|
||||
u.Department,
|
||||
u.Position,
|
||||
u.JobTitle,
|
||||
u.CreatedAt.Format(time.RFC3339),
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -133,6 +136,51 @@ func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context,
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
h := &UserHandler{UserRepo: mockRepo}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users/export", h.ExportUsersCSV)
|
||||
|
||||
createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", "test-tenant").
|
||||
Return([]domain.User{
|
||||
{
|
||||
ID: "u-1",
|
||||
Email: "user@test.com",
|
||||
Name: "Test User",
|
||||
Phone: "010-1111-2222",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
Status: "active",
|
||||
CompanyCode: "test-tenant",
|
||||
Department: "Legacy Department",
|
||||
Position: "책임",
|
||||
JobTitle: "플랫폼 운영",
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
}, int64(1), nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
|
||||
assert.Contains(t, body, "ID,Email,Name,Phone,Status,Tenant,Position,JobTitle,CreatedAt")
|
||||
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,test-tenant")
|
||||
assert.NotContains(t, body, "Role")
|
||||
assert.NotContains(t, body, "Department")
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
|
||||
@@ -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