forked from baron/baron-sso
Implement tenant import and RP auto login policies
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
@@ -23,22 +23,129 @@ import {
|
||||
type BulkUserItem,
|
||||
type BulkUserResult,
|
||||
bulkCreateUsers,
|
||||
createTenant,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
type TenantCSVRow,
|
||||
type TenantImportPreviewRow,
|
||||
buildTenantImportPreview,
|
||||
} from "../../tenants/utils/tenantCsvImport";
|
||||
import { parseUserCSV } from "../utils/csvParser";
|
||||
import {
|
||||
type HanmacImportEmailPreview,
|
||||
buildHanmacImportEmailPreview,
|
||||
} from "../utils/hanmacImportEmail";
|
||||
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
|
||||
|
||||
interface UserBulkUploadModalProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
function buildUserTenantPreviewRows(
|
||||
users: BulkUserItem[],
|
||||
tenants: Parameters<typeof buildTenantImportPreview>[1],
|
||||
) {
|
||||
const rowsByKey = new Map<string, TenantCSVRow>();
|
||||
|
||||
users.forEach((user, index) => {
|
||||
const key = tenantImportKeyFromUser(user);
|
||||
if (!key || rowsByKey.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
rowsByKey.set(key, {
|
||||
rowNumber: index + 2,
|
||||
tenantId: user.tenantImport?.sourceTenantId ?? "",
|
||||
name: user.tenantImport?.name || user.tenantSlug || key,
|
||||
type: user.tenantImport?.type || "COMPANY",
|
||||
parentTenantId: user.tenantImport?.parentTenantId ?? "",
|
||||
parentTenantSlug: user.tenantImport?.parentTenantSlug ?? "",
|
||||
slug: user.tenantImport?.slug || user.tenantSlug || key,
|
||||
memo: user.tenantImport?.memo ?? "",
|
||||
emailDomain: user.tenantImport?.emailDomain ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
return buildTenantImportPreview([...rowsByKey.values()], tenants);
|
||||
}
|
||||
|
||||
function tenantImportKeyFromUser(user: BulkUserItem) {
|
||||
return (
|
||||
user.tenantImport?.sourceTenantId ||
|
||||
user.tenantImport?.slug ||
|
||||
user.tenantSlug ||
|
||||
user.tenantImport?.name ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function tenantImportKeyFromRow(row: TenantCSVRow) {
|
||||
return row.tenantId || row.slug || row.name;
|
||||
}
|
||||
|
||||
function splitTenantImportDomains(value: string) {
|
||||
return value
|
||||
.replaceAll("\n", ";")
|
||||
.replaceAll(",", ";")
|
||||
.split(";")
|
||||
.map((domain) => domain.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function emailLocalPart(email: string) {
|
||||
return email.trim().toLowerCase().split("@")[0] || "";
|
||||
}
|
||||
|
||||
function hanmacEmailStatusLabel(preview?: HanmacImportEmailPreview) {
|
||||
if (!preview) return "";
|
||||
if (preview.status === "suggested") return "제안";
|
||||
if (preview.status === "needsReview") return "확인 필요";
|
||||
if (preview.status === "ruleMismatch") return "규칙 확인";
|
||||
if (preview.status === "blockingError") return "오류";
|
||||
return "";
|
||||
}
|
||||
|
||||
function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
||||
if (!preview) return "text-muted-foreground";
|
||||
if (preview.status === "blockingError") return "text-destructive";
|
||||
if (preview.status === "ruleMismatch" || preview.status === "needsReview") {
|
||||
return "text-amber-600";
|
||||
}
|
||||
if (preview.status === "suggested") return "text-blue-600";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [parsing, setParsing] = React.useState(false);
|
||||
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
|
||||
const [tenantPreviewRows, setTenantPreviewRows] = React.useState<
|
||||
TenantImportPreviewRow[]
|
||||
>([]);
|
||||
const [selectedTenantMatches, setSelectedTenantMatches] = React.useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [selectedTenantCreateSlugs, setSelectedTenantCreateSlugs] =
|
||||
React.useState<Record<number, string>>({});
|
||||
const [results, setResults] = React.useState<BulkUserResult[] | null>(null);
|
||||
const [preparing, setPreparing] = React.useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenants", "user-bulk-import"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["users", "user-bulk-import-email-policy"],
|
||||
queryFn: () => fetchUsers(10000, 0),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: bulkCreateUsers,
|
||||
onSuccess: (data) => {
|
||||
@@ -62,20 +169,87 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
const text = e.target?.result as string;
|
||||
const data = parseUserCSV(text);
|
||||
setPreviewData(data);
|
||||
const tenantRows = buildUserTenantPreviewRows(
|
||||
data,
|
||||
tenantQuery.data?.items ?? [],
|
||||
);
|
||||
setTenantPreviewRows(tenantRows);
|
||||
setSelectedTenantMatches(
|
||||
Object.fromEntries(
|
||||
tenantRows.map((row) => [
|
||||
row.row.rowNumber,
|
||||
row.defaultTenantId || "__create__",
|
||||
]),
|
||||
),
|
||||
);
|
||||
setSelectedTenantCreateSlugs(
|
||||
Object.fromEntries(
|
||||
tenantRows.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
|
||||
),
|
||||
);
|
||||
setParsing(false);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
const handleUpload = async () => {
|
||||
if (previewData.length > 0) {
|
||||
mutation.mutate(previewData);
|
||||
setPreparing(true);
|
||||
try {
|
||||
const users = await resolveUserImportTenants();
|
||||
mutation.mutate(users);
|
||||
} finally {
|
||||
setPreparing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUserImportTenants = async () => {
|
||||
const tenants = tenantQuery.data?.items ?? [];
|
||||
const tenantSlugByKey = new Map<string, string>();
|
||||
|
||||
for (const preview of tenantPreviewRows) {
|
||||
const key = tenantImportKeyFromRow(preview.row);
|
||||
const selected =
|
||||
selectedTenantMatches[preview.row.rowNumber] ?? "__create__";
|
||||
if (selected !== "__create__") {
|
||||
const tenant = tenants.find((item) => item.id === selected);
|
||||
if (tenant) {
|
||||
tenantSlugByKey.set(key, tenant.slug);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await createTenant({
|
||||
name: preview.row.name || preview.row.slug,
|
||||
slug:
|
||||
selectedTenantCreateSlugs[preview.row.rowNumber] ||
|
||||
preview.defaultCreateSlug,
|
||||
type: preview.row.type || "COMPANY",
|
||||
parentId: preview.row.parentTenantId || undefined,
|
||||
description: preview.row.memo,
|
||||
domains: splitTenantImportDomains(preview.row.emailDomain),
|
||||
status: "active",
|
||||
});
|
||||
tenantSlugByKey.set(key, created.slug);
|
||||
}
|
||||
|
||||
return previewData.map((user, index) => {
|
||||
const key = tenantImportKeyFromUser(user);
|
||||
const tenantSlug = key ? tenantSlugByKey.get(key) : user.tenantSlug;
|
||||
const emailPreview = hanmacEmailPreviews[index];
|
||||
const { tenantImport: _tenantImport, ...payload } = user;
|
||||
return {
|
||||
...payload,
|
||||
email: emailPreview?.finalEmail ?? payload.email,
|
||||
tenantSlug,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers =
|
||||
"email,name,phone,role,tenant,department,position,jobTitle,employee_id";
|
||||
"email,name,phone,role,tenant_slug,department,position,jobTitle,employee_id";
|
||||
const example =
|
||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
|
||||
const blob = new Blob([`${headers}\n${example}`], {
|
||||
@@ -92,12 +266,47 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setPreviewData([]);
|
||||
setTenantPreviewRows([]);
|
||||
setSelectedTenantMatches({});
|
||||
setSelectedTenantCreateSlugs({});
|
||||
setResults(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const successCount = results?.filter((r) => r.success).length ?? 0;
|
||||
const failCount = results ? results.length - successCount : 0;
|
||||
const tenants = tenantQuery.data?.items ?? [];
|
||||
const existingHanmacLocalParts = React.useMemo(() => {
|
||||
const values = new Set<string>();
|
||||
for (const user of usersQuery.data?.items ?? []) {
|
||||
if (!isHanmacFamilyUser(user, tenants)) {
|
||||
continue;
|
||||
}
|
||||
const localPart = emailLocalPart(user.email);
|
||||
if (localPart) values.add(localPart);
|
||||
}
|
||||
return values;
|
||||
}, [tenants, usersQuery.data?.items]);
|
||||
const hanmacEmailPreviews = React.useMemo(() => {
|
||||
const batchLocalParts = new Set<string>();
|
||||
return previewData.map((user) => {
|
||||
const tenant = tenants.find(
|
||||
(item) =>
|
||||
item.slug.toLowerCase() === user.tenantSlug?.trim().toLowerCase(),
|
||||
);
|
||||
if (!isHanmacFamilyTenant(tenant, tenants)) {
|
||||
return undefined;
|
||||
}
|
||||
return buildHanmacImportEmailPreview(
|
||||
user,
|
||||
existingHanmacLocalParts,
|
||||
batchLocalParts,
|
||||
);
|
||||
});
|
||||
}, [existingHanmacLocalParts, previewData, tenants]);
|
||||
const hasBlockingHanmacEmailRows = hanmacEmailPreviews.some(
|
||||
(preview) => preview?.status === "blockingError",
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -185,6 +394,82 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tenantPreviewRows.length > 0 && (
|
||||
<div
|
||||
className="rounded-md border p-3 text-sm"
|
||||
data-testid="user-import-tenant-resolution"
|
||||
>
|
||||
<div className="mb-2 font-medium">
|
||||
{t(
|
||||
"ui.admin.users.bulk.tenant_resolution",
|
||||
"테넌트 매핑",
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{tenantPreviewRows.map((preview) => (
|
||||
<div
|
||||
key={preview.row.rowNumber}
|
||||
className="grid gap-2 sm:grid-cols-[1fr_1fr]"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{preview.row.name}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{preview.row.slug}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={
|
||||
selectedTenantMatches[preview.row.rowNumber] ??
|
||||
"__create__"
|
||||
}
|
||||
onChange={(event) =>
|
||||
setSelectedTenantMatches((prev) => ({
|
||||
...prev,
|
||||
[preview.row.rowNumber]: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="__create__">
|
||||
{t(
|
||||
"ui.admin.users.bulk.create_missing_tenant",
|
||||
"신규 생성",
|
||||
)}
|
||||
</option>
|
||||
{preview.candidates.map((candidate) => (
|
||||
<option
|
||||
key={candidate.tenantId}
|
||||
value={candidate.tenantId}
|
||||
>
|
||||
{candidate.name} ({candidate.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(selectedTenantMatches[preview.row.rowNumber] ??
|
||||
"__create__") === "__create__" && (
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm"
|
||||
value={
|
||||
selectedTenantCreateSlugs[
|
||||
preview.row.rowNumber
|
||||
] ?? ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
setSelectedTenantCreateSlugs((prev) => ({
|
||||
...prev,
|
||||
[preview.row.rowNumber]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewData.length > 0 && (
|
||||
<ScrollArea className="h-[200px] rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
@@ -193,20 +478,45 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
<th className="p-2 text-left">Email</th>
|
||||
<th className="p-2 text-left">Name</th>
|
||||
<th className="p-2 text-left">Tenant</th>
|
||||
<th className="p-2 text-left">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewData.slice(0, 10).map((u) => (
|
||||
<tr key={u.email} className="border-t">
|
||||
<td className="p-2">{u.email}</td>
|
||||
{previewData.slice(0, 10).map((u, index) => (
|
||||
<tr key={`${u.email}-${index}`} className="border-t">
|
||||
<td className="p-2">
|
||||
<input
|
||||
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
|
||||
value={hanmacEmailPreviews[index]?.finalEmail ?? u.email}
|
||||
onChange={(event) =>
|
||||
setPreviewData((prev) =>
|
||||
prev.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? { ...item, email: event.target.value }
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">{u.name}</td>
|
||||
<td className="p-2">{u.tenantSlug || "-"}</td>
|
||||
<td
|
||||
className={`p-2 text-xs ${hanmacEmailStatusClass(
|
||||
hanmacEmailPreviews[index],
|
||||
)}`}
|
||||
>
|
||||
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
|
||||
{hanmacEmailPreviews[index]?.reason && (
|
||||
<div>{hanmacEmailPreviews[index]?.reason}</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{previewData.length > 10 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
colSpan={4}
|
||||
className="p-2 text-center text-muted-foreground italic"
|
||||
>
|
||||
... and {previewData.length - 10} more users
|
||||
@@ -277,11 +587,16 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
{!results ? (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={previewData.length === 0 || mutation.isPending}
|
||||
disabled={
|
||||
previewData.length === 0 ||
|
||||
mutation.isPending ||
|
||||
preparing ||
|
||||
hasBlockingHanmacEmailRows
|
||||
}
|
||||
className="w-full sm:w-auto"
|
||||
data-testid="bulk-start-btn"
|
||||
>
|
||||
{mutation.isPending && (
|
||||
{(mutation.isPending || preparing) && (
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
|
||||
|
||||
Reference in New Issue
Block a user