1
0
forked from baron/baron-sso

Implement tenant import and RP auto login policies

This commit is contained in:
2026-04-30 15:45:34 +09:00
parent 24807eab0f
commit f7e4d43b16
76 changed files with 5307 additions and 441 deletions

View File

@@ -54,16 +54,7 @@ import {
filterNonHanmacFamilyTenants,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean" | "date";
required?: boolean;
adminOnly?: boolean;
validation?: string;
isLoginId?: boolean;
};
import type { UserSchemaField } from "./userSchemaFields";
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
type UserType = "hanmac" | "external" | "personal";

View File

@@ -77,16 +77,7 @@ import {
isHanmacFamilyUser,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean" | "date";
required?: boolean;
adminOnly?: boolean;
validation?: string;
isLoginId?: boolean;
};
import type { UserSchemaField } from "./userSchemaFields";
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
metadata: Record<string, Record<string, string | number | boolean>>;

View File

@@ -145,7 +145,8 @@ function UserListPage() {
});
const exportMutation = useMutation({
mutationFn: () => exportUsersCSV(search, selectedCompany),
mutationFn: (includeIds: boolean) =>
exportUsersCSV(search, selectedCompany, includeIds),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -190,8 +191,8 @@ function UserListPage() {
}
};
const handleExport = () => {
exportMutation.mutate();
const handleExport = (includeIds = false) => {
exportMutation.mutate(includeIds);
};
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
@@ -311,12 +312,21 @@ function UserListPage() {
</Button>
<Button
variant="outline"
onClick={handleExport}
onClick={() => handleExport(false)}
className="gap-2"
disabled={exportMutation.isPending}
>
<FileDown size={16} />
{t("ui.common.export", "내보내기")}
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
</Button>
<Button
variant="outline"
onClick={() => handleExport(true)}
className="gap-2"
disabled={exportMutation.isPending}
>
<FileDown size={16} />
{t("ui.common.export_with_ids", "UUID 포함")}
</Button>
<UserBulkUploadModal onSuccess={() => query.refetch()} />
<Dialog>

View File

@@ -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", "등록 시작")}

View File

@@ -0,0 +1,18 @@
export type UserSchemaFieldType =
| "text"
| "number"
| "boolean"
| "date"
| "float"
| "datetime";
export type UserSchemaField = {
key: string;
label?: string;
type?: UserSchemaFieldType;
required?: boolean;
adminOnly?: boolean;
validation?: string;
isLoginId?: boolean;
indexed?: boolean;
};

View File

@@ -43,4 +43,24 @@ test@test.com,Test,baron`;
expect(result[0].email).toBe("test@test.com");
expect(result[0].tenantSlug).toBe("baron");
});
it("should parse tenant conflict metadata for import resolution", () => {
const csv = `email,name,tenant_id,tenant_slug,tenant_name,tenant_type,parent_tenant_slug,tenant_memo,email_domain
test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-slug,Imported memo,missing.example.com`;
const result = parseUserCSV(csv);
expect(result[0]).toMatchObject({
tenantSlug: "missing-slug",
tenantImport: {
sourceTenantId: "local-tenant-id",
slug: "missing-slug",
name: "Missing Tenant",
type: "COMPANY",
parentTenantSlug: "parent-slug",
memo: "Imported memo",
emailDomain: "missing.example.com",
},
});
});
});

View File

@@ -32,6 +32,52 @@ export function parseUserCSV(text: string): BulkUserItem[] {
item.role = value;
} else if (header === "tenant") {
item.tenantSlug = value;
} else if (header === "tenant_slug" || header === "companycode") {
item.tenantSlug = value;
item.tenantImport = {
...(item.tenantImport ?? {}),
slug: value,
};
} else if (header === "tenant_id") {
item.tenantImport = {
...(item.tenantImport ?? {}),
sourceTenantId: value,
};
} else if (header === "tenant_name") {
item.tenantImport = {
...(item.tenantImport ?? {}),
name: value,
};
} else if (header === "tenant_type") {
item.tenantImport = {
...(item.tenantImport ?? {}),
type: value,
};
} else if (header === "parent_tenant_id") {
item.tenantImport = {
...(item.tenantImport ?? {}),
parentTenantId: value,
};
} else if (header === "parent_tenant_slug") {
item.tenantImport = {
...(item.tenantImport ?? {}),
parentTenantSlug: value,
};
} else if (header === "parent_tenant_name") {
item.tenantImport = {
...(item.tenantImport ?? {}),
parentTenantName: value,
};
} else if (header === "tenant_memo") {
item.tenantImport = {
...(item.tenantImport ?? {}),
memo: value,
};
} else if (header === "email_domain" || header === "tenant_domain") {
item.tenantImport = {
...(item.tenantImport ?? {}),
emailDomain: value,
};
} else if (header === "department") {
item.department = value;
} else if (header === "position") {

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
buildHanmacImportEmailPreview,
buildKoreanNameEmailBase,
matchesSuggestedNameRule,
} from "./hanmacImportEmail";
describe("hanmac import email policy", () => {
it("builds name initials plus surname base", () => {
expect(buildKoreanNameEmailBase("한치영")).toEqual({
base: "cyhan",
needsReview: false,
});
});
it("matches base plus numeric suffix only", () => {
expect(matchesSuggestedNameRule("cyhan", "cyhan")).toBe(true);
expect(matchesSuggestedNameRule("cyhan2", "cyhan")).toBe(true);
expect(matchesSuggestedNameRule("hcy", "cyhan")).toBe(false);
expect(matchesSuggestedNameRule("cyhan-a", "cyhan")).toBe(false);
});
it("suggests the next available local part for domain-only email", () => {
const preview = buildHanmacImportEmailPreview(
{
email: "@hanmaceng.co.kr",
name: "한치영",
tenantSlug: "hanmac",
metadata: {},
},
new Set(["cyhan", "cyhan1"]),
new Set(),
);
expect(preview.finalEmail).toBe("cyhan2@hanmaceng.co.kr");
expect(preview.status).toBe("suggested");
expect(preview.warnings).toContain("suggested");
});
it("marks rule mismatch as a warning without blocking the row", () => {
const preview = buildHanmacImportEmailPreview(
{
email: "hcy@hanmaceng.co.kr",
name: "한치영",
tenantSlug: "hanmac",
metadata: {},
},
new Set(),
new Set(),
);
expect(preview.finalEmail).toBe("hcy@hanmaceng.co.kr");
expect(preview.status).toBe("ruleMismatch");
expect(preview.warnings).toContain("ruleMismatch");
expect(preview.blockingErrors).toEqual([]);
});
it("blocks duplicate full local part for Hanmac family", () => {
const preview = buildHanmacImportEmailPreview(
{
email: "han@samaneng.com",
name: "한치영",
tenantSlug: "hanmac",
metadata: {},
},
new Set(["han"]),
new Set(),
);
expect(preview.status).toBe("blockingError");
expect(preview.blockingErrors).toContain("duplicateLocalPart");
});
});

View File

@@ -0,0 +1,296 @@
import type { BulkUserItem } from "../../../lib/adminApi";
export type HanmacImportEmailStatus =
| "valid"
| "suggested"
| "needsReview"
| "ruleMismatch"
| "blockingError";
export type HanmacImportEmailPreview = {
originalEmail: string;
suggestedEmail?: string;
finalEmail: string;
status: HanmacImportEmailStatus;
warnings: string[];
blockingErrors: string[];
reason?: string;
localPart?: string;
};
const surnameRomanization: Record<string, string> = {
: "han",
: "kim",
: "lee",
: "park",
: "choi",
: "jung",
: "cho",
: "kang",
: "yoon",
: "jang",
: "lim",
: "lim",
: "shin",
: "oh",
: "seo",
: "kwon",
: "hwang",
: "ahn",
: "song",
: "jeon",
: "hong",
: "yoo",
: "ko",
: "moon",
: "yang",
: "son",
: "bae",
: "baek",
: "heo",
: "nam",
: "sim",
: "noh",
: "ha",
: "kwak",
: "sung",
: "cha",
: "joo",
: "woo",
: "koo",
: "min",
: "ryu",
: "na",
: "jin",
: "ji",
: "um",
: "chae",
: "won",
: "cheon",
: "bang",
: "gong",
: "hyun",
: "ham",
: "yeo",
: "choo",
: "do",
: "so",
: "seok",
: "sun",
: "seol",
: "ma",
: "gil",
: "yeon",
: "wi",
: "pyo",
: "myung",
: "ki",
: "ban",
: "ra",
: "wang",
: "geum",
: "ok",
: "yook",
: "in",
: "maeng",
: "je",
: "mo",
: "tak",
: "guk",
: "eo",
: "eun",
: "pyeon",
: "yong",
};
const initialRomanization = [
"g",
"g",
"n",
"d",
"d",
"r",
"m",
"b",
"b",
"s",
"s",
"y",
"j",
"j",
"c",
"k",
"t",
"p",
"h",
];
export function buildKoreanNameEmailBase(name: string) {
const runes = [...name.trim()].filter((char) => !/\s/.test(char));
if (runes.length < 2) {
return { base: "", needsReview: true };
}
const surname = surnameRomanization[runes[0]];
if (!surname) {
return { base: "", needsReview: true };
}
const initials = runes.slice(1).map((char) => romanizedHangulInitial(char));
if (initials.some((value) => !value)) {
return { base: "", needsReview: true };
}
return { base: `${initials.join("")}${surname}`, needsReview: false };
}
export function matchesSuggestedNameRule(localPart: string, base: string) {
const normalizedLocalPart = localPart.trim().toLowerCase();
const normalizedBase = base.trim().toLowerCase();
if (!normalizedLocalPart || !normalizedBase) {
return false;
}
if (normalizedLocalPart === normalizedBase) {
return true;
}
return new RegExp(`^${escapeRegExp(normalizedBase)}\\d+$`).test(
normalizedLocalPart,
);
}
export function buildHanmacImportEmailPreview(
user: BulkUserItem,
existingLocalParts: Set<string>,
batchLocalParts: Set<string>,
): HanmacImportEmailPreview {
const originalEmail = user.email.trim();
const split = splitEmailDomain(originalEmail);
if (!split) {
return {
originalEmail,
finalEmail: originalEmail,
status: "blockingError",
warnings: [],
blockingErrors: ["invalidEmail"],
reason: "이메일 형식을 확인해 주세요.",
};
}
const usedLocalParts = new Set([
...[...existingLocalParts].map((value) => value.toLowerCase()),
...[...batchLocalParts].map((value) => value.toLowerCase()),
]);
const { base, needsReview } = buildKoreanNameEmailBase(user.name);
const warnings: string[] = [];
if (needsReview) {
warnings.push("needsReview");
}
if (!split.localPart) {
if (!base) {
return {
originalEmail,
finalEmail: originalEmail,
status: "blockingError",
warnings,
blockingErrors: ["missingLocalPart"],
reason: "이름으로 이메일 ID를 제안할 수 없습니다.",
};
}
const nextLocalPart = nextAvailableLocalPart(base, usedLocalParts);
const finalEmail = `${nextLocalPart}@${split.domain}`;
batchLocalParts.add(nextLocalPart);
return {
originalEmail,
suggestedEmail: finalEmail,
finalEmail,
status: "suggested",
warnings: appendUnique(warnings, "suggested"),
blockingErrors: [],
localPart: nextLocalPart,
};
}
const localPart = split.localPart.toLowerCase();
if (usedLocalParts.has(localPart)) {
return {
originalEmail,
finalEmail: originalEmail,
status: "blockingError",
warnings,
blockingErrors: ["duplicateLocalPart"],
reason: "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.",
localPart,
};
}
batchLocalParts.add(localPart);
if (base && !matchesSuggestedNameRule(localPart, base)) {
return {
originalEmail,
finalEmail: originalEmail,
status: "ruleMismatch",
warnings: appendUnique(warnings, "ruleMismatch"),
blockingErrors: [],
reason: "권장 이메일 ID 규칙과 다릅니다.",
localPart,
};
}
return {
originalEmail,
finalEmail: originalEmail,
status: warnings.includes("needsReview") ? "needsReview" : "valid",
warnings,
blockingErrors: [],
localPart,
};
}
function splitEmailDomain(email: string) {
const normalized = email.trim().toLowerCase();
const parts = normalized.split("@");
if (parts.length !== 2 || !parts[1] || !parts[1].includes(".")) {
return null;
}
return {
localPart: parts[0],
domain: parts[1],
};
}
function romanizedHangulInitial(char: string) {
const code = char.codePointAt(0);
if (code === undefined || code < 0xac00 || code > 0xd7a3) {
return "";
}
const index = Math.floor((code - 0xac00) / 588);
return initialRomanization[index] ?? "";
}
function nextAvailableLocalPart(base: string, usedLocalParts: Set<string>) {
const normalizedBase = base.trim().toLowerCase();
if (!usedLocalParts.has(normalizedBase)) {
return normalizedBase;
}
let index = 1;
while (usedLocalParts.has(`${normalizedBase}${index}`)) {
index += 1;
}
return `${normalizedBase}${index}`;
}
function appendUnique(values: string[], value: string) {
if (values.includes(value)) {
return values;
}
return [...values, value];
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}