forked from baron/baron-sso
Implement tenant import and RP auto login policies
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DomainTagInput } from "./DomainTagInput";
|
||||
|
||||
describe("DomainTagInput", () => {
|
||||
it("shows a clear duplicate tenant warning and adds the domain after confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const onConfirmedConflictsChange = vi.fn();
|
||||
|
||||
render(
|
||||
<DomainTagInput
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
tenants={[
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "한맥가족",
|
||||
slug: "hanmac-family",
|
||||
type: "COMPANY",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: ["samaneng.com"],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
]}
|
||||
currentTenantId="tenant-2"
|
||||
confirmedConflicts={[]}
|
||||
onConfirmedConflictsChange={onConfirmedConflictsChange}
|
||||
placeholder="example.com"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("example.com"), "samaneng.com ");
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "계속 진행" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["samaneng.com"]);
|
||||
expect(onConfirmedConflictsChange).toHaveBeenCalledWith(["samaneng.com"]);
|
||||
});
|
||||
});
|
||||
187
adminfront/src/features/tenants/components/DomainTagInput.tsx
Normal file
187
adminfront/src/features/tenants/components/DomainTagInput.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
type DomainConflict,
|
||||
findDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
normalizeDomainTokens,
|
||||
} from "../utils/domainTags";
|
||||
|
||||
type DomainTagInputProps = {
|
||||
id?: string;
|
||||
value: string[];
|
||||
onChange: (domains: string[]) => void;
|
||||
tenants?: TenantSummary[];
|
||||
currentTenantId?: string;
|
||||
confirmedConflicts?: string[];
|
||||
onConfirmedConflictsChange?: (domains: string[]) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function DomainTagInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
tenants = [],
|
||||
currentTenantId,
|
||||
confirmedConflicts = [],
|
||||
onConfirmedConflictsChange,
|
||||
placeholder,
|
||||
}: DomainTagInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const addConfirmedConflict = (domain: string) => {
|
||||
if (!confirmedConflicts.includes(domain)) {
|
||||
onConfirmedConflictsChange?.([...confirmedConflicts, domain]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeConfirmedConflict = (domain: string) => {
|
||||
if (confirmedConflicts.includes(domain)) {
|
||||
onConfirmedConflictsChange?.(
|
||||
confirmedConflicts.filter((item) => item !== domain),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const addDomain = (domain: string, confirmed = false) => {
|
||||
if (value.includes(domain)) {
|
||||
return;
|
||||
}
|
||||
onChange([...value, domain]);
|
||||
if (confirmed) {
|
||||
addConfirmedConflict(domain);
|
||||
}
|
||||
};
|
||||
|
||||
const tokenizeInput = () => {
|
||||
const tokens = normalizeDomainTokens(input);
|
||||
if (tokens.length === 0) {
|
||||
setInput("");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const token of tokens) {
|
||||
if (value.includes(token)) {
|
||||
continue;
|
||||
}
|
||||
const conflict = findDomainConflict(token, tenants, currentTenantId);
|
||||
if (conflict && !confirmedConflicts.includes(token)) {
|
||||
setPendingConflict(conflict);
|
||||
setInput("");
|
||||
return;
|
||||
}
|
||||
addDomain(token, confirmedConflicts.includes(token));
|
||||
}
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const removeDomain = (domain: string) => {
|
||||
onChange(value.filter((item) => item !== domain));
|
||||
removeConfirmedConflict(domain);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-10 w-full flex-wrap items-center gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-within:ring-1 focus-within:ring-ring">
|
||||
{value.map((domain) => (
|
||||
<Badge
|
||||
key={domain}
|
||||
variant={confirmedConflicts.includes(domain) ? "warning" : "muted"}
|
||||
className="gap-1 rounded-md"
|
||||
>
|
||||
<span>{domain}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
||||
onClick={() => removeDomain(domain)}
|
||||
aria-label={t("ui.common.remove", "삭제")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<Input
|
||||
id={id}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onBlur={tokenizeInput}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === " " ||
|
||||
event.key === "Enter" ||
|
||||
event.key === "," ||
|
||||
event.key === ";"
|
||||
) {
|
||||
event.preventDefault();
|
||||
tokenizeInput();
|
||||
}
|
||||
}}
|
||||
className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0"
|
||||
placeholder={value.length === 0 ? placeholder : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={pendingConflict !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingConflict(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.domain_conflict.title", "도메인 충돌")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingConflict
|
||||
? t(
|
||||
"ui.admin.tenants.domain_conflict.description",
|
||||
formatDomainConflictMessage(pendingConflict),
|
||||
)
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPendingConflict(null)}
|
||||
>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (pendingConflict) {
|
||||
addDomain(pendingConflict.domain, true);
|
||||
}
|
||||
setPendingConflict(null);
|
||||
}}
|
||||
>
|
||||
{t("ui.common.continue", "계속 진행")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
|
||||
import { Building2, Sparkles } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -17,6 +16,11 @@ import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import {
|
||||
formatDomainConflictMessage,
|
||||
type ServerDomainConflict,
|
||||
} from "../utils/domainTags";
|
||||
|
||||
function TenantCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -26,7 +30,10 @@ function TenantCreatePage() {
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState("");
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", { limit: 1000 }],
|
||||
@@ -34,7 +41,7 @@ function TenantCreatePage() {
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: (overrideForceDomains?: string[]) =>
|
||||
createTenant({
|
||||
name,
|
||||
type,
|
||||
@@ -42,14 +49,34 @@ function TenantCreatePage() {
|
||||
parentId: parentId || undefined,
|
||||
description: description || undefined,
|
||||
status,
|
||||
domains: domains
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d !== ""),
|
||||
domains,
|
||||
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
navigate("/tenants");
|
||||
},
|
||||
onError: (
|
||||
err: AxiosError<{
|
||||
code?: string;
|
||||
error?: string;
|
||||
conflicts?: ServerDomainConflict[];
|
||||
}>,
|
||||
) => {
|
||||
const conflicts = err.response?.data?.conflicts ?? [];
|
||||
if (
|
||||
err.response?.data?.code === "tenant_domain_conflict" &&
|
||||
conflicts.length > 0
|
||||
) {
|
||||
const nextForceDomains = Array.from(
|
||||
new Set([...forceDomainConflicts, ...conflicts.map((c) => c.domain)]),
|
||||
);
|
||||
const message = conflicts.map(formatDomainConflictMessage).join("\n");
|
||||
if (window.confirm(message)) {
|
||||
setForceDomainConflicts(nextForceDomains);
|
||||
mutation.mutate(nextForceDomains);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
|
||||
@@ -195,11 +222,13 @@ function TenantCreatePage() {
|
||||
"허용된 도메인 (콤마로 구분)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
<DomainTagInput
|
||||
id="tenant-domains"
|
||||
name="domains"
|
||||
value={domains}
|
||||
onChange={(e) => setDomains(e.target.value)}
|
||||
onChange={setDomains}
|
||||
tenants={parentQuery.data?.items ?? []}
|
||||
confirmedConflicts={forceDomainConflicts}
|
||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.create.form.domains_placeholder",
|
||||
"example.com, example.kr",
|
||||
@@ -268,7 +297,7 @@ function TenantCreatePage() {
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
onClick={() => mutation.mutate(undefined)}
|
||||
disabled={mutation.isPending || name.trim() === ""}
|
||||
>
|
||||
{t("ui.common.create", "생성")}
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
type TenantImportResolution,
|
||||
type TenantImportPreviewRow,
|
||||
buildTenantImportPreview,
|
||||
parseTenantCSV,
|
||||
@@ -58,7 +59,7 @@ import {
|
||||
} from "../utils/tenantCsvImport";
|
||||
|
||||
const tenantCSVTemplate =
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n";
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain\n";
|
||||
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -72,6 +73,9 @@ function TenantListPage() {
|
||||
const [selectedMatches, setSelectedMatches] = React.useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [selectedCreateSlugs, setSelectedCreateSlugs] = React.useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
@@ -117,7 +121,7 @@ function TenantListPage() {
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: exportTenantsCSV,
|
||||
mutationFn: (includeIds: boolean) => exportTenantsCSV(includeIds),
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -265,16 +269,44 @@ function TenantListPage() {
|
||||
setPreviewRows(preview);
|
||||
setSelectedMatches(
|
||||
Object.fromEntries(
|
||||
preview
|
||||
.filter((row) => row.defaultTenantId)
|
||||
.map((row) => [row.row.rowNumber, row.defaultTenantId]),
|
||||
preview.map((row) => [
|
||||
row.row.rowNumber,
|
||||
row.defaultTenantId || "__create__",
|
||||
]),
|
||||
),
|
||||
);
|
||||
setSelectedCreateSlugs(
|
||||
Object.fromEntries(
|
||||
preview.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
|
||||
),
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleImportConfirm = () => {
|
||||
const csv = serializeTenantImportCSV(previewRows, selectedMatches);
|
||||
const resolutions: Record<number, TenantImportResolution> =
|
||||
Object.fromEntries(
|
||||
previewRows.map((preview) => {
|
||||
const selected = selectedMatches[preview.row.rowNumber] ?? "";
|
||||
if (selected && selected !== "__create__") {
|
||||
return [
|
||||
preview.row.rowNumber,
|
||||
{ mode: "existing", tenantId: selected },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
preview.row.rowNumber,
|
||||
{
|
||||
mode: "create",
|
||||
slug:
|
||||
selectedCreateSlugs[preview.row.rowNumber] ||
|
||||
preview.defaultCreateSlug,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
const csv = serializeTenantImportCSV(previewRows, resolutions);
|
||||
const file = new File([csv], "tenants.csv", { type: "text/csv" });
|
||||
importMutation.mutate(file);
|
||||
};
|
||||
@@ -343,12 +375,21 @@ function TenantListPage() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => exportMutation.mutate()}
|
||||
onClick={() => exportMutation.mutate(false)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-btn"
|
||||
>
|
||||
<Download size={16} />
|
||||
{t("ui.admin.tenants.export", "내보내기")}
|
||||
{t("ui.admin.tenants.export_without_ids", "UUID 제외 내보내기")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => exportMutation.mutate(true)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-with-ids-btn"
|
||||
>
|
||||
<Download size={16} />
|
||||
{t("ui.admin.tenants.export_with_ids", "UUID 포함")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -622,19 +663,41 @@ function TenantListPage() {
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{preview.row.slug}
|
||||
{preview.conflicts.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{preview.conflicts.map((conflict) => (
|
||||
<Badge
|
||||
key={conflict}
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{conflict === "external_tenant_id"
|
||||
? t(
|
||||
"ui.admin.tenants.import_preview.external_id",
|
||||
"외부 ID",
|
||||
)
|
||||
: conflict === "slug_exists"
|
||||
? t(
|
||||
"ui.admin.tenants.import_preview.slug_exists",
|
||||
"slug 충돌",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.import_preview.parent_unresolved",
|
||||
"부모 확인 필요",
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{preview.row.tenantId ? (
|
||||
<Badge variant="outline">
|
||||
{t(
|
||||
"ui.admin.tenants.import_preview.fixed_id",
|
||||
"ID 지정됨",
|
||||
)}
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={selectedMatches[preview.row.rowNumber] ?? ""}
|
||||
value={
|
||||
selectedMatches[preview.row.rowNumber] ??
|
||||
"__create__"
|
||||
}
|
||||
data-testid={`tenant-import-match-select-${preview.row.rowNumber}`}
|
||||
onChange={(event) =>
|
||||
setSelectedMatches((prev) => ({
|
||||
@@ -643,10 +706,10 @@ function TenantListPage() {
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
<option value="__create__">
|
||||
{t(
|
||||
"ui.admin.tenants.import_preview.create_new",
|
||||
"신규 생성",
|
||||
"ui.admin.tenants.import_preview.create_new_reset",
|
||||
"신규 생성 (ID/slug 재설정)",
|
||||
)}
|
||||
</option>
|
||||
{preview.candidates.map((candidate) => (
|
||||
@@ -658,7 +721,22 @@ function TenantListPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{(selectedMatches[preview.row.rowNumber] ??
|
||||
"__create__") === "__create__" && (
|
||||
<Input
|
||||
value={
|
||||
selectedCreateSlugs[preview.row.rowNumber] ?? ""
|
||||
}
|
||||
data-testid={`tenant-import-create-slug-${preview.row.rowNumber}`}
|
||||
onChange={(event) =>
|
||||
setSelectedCreateSlugs((prev) => ({
|
||||
...prev,
|
||||
[preview.row.rowNumber]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{preview.candidates.length > 0 ? (
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
updateTenant,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import {
|
||||
formatDomainConflictMessage,
|
||||
type ServerDomainConflict,
|
||||
} from "../utils/domainTags";
|
||||
|
||||
export function TenantProfilePage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
@@ -53,7 +58,10 @@ export function TenantProfilePage() {
|
||||
const [slug, setSlug] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState("");
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [parentId, setParentId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,13 +71,14 @@ export function TenantProfilePage() {
|
||||
setSlug(tenantQuery.data.slug);
|
||||
setDescription(tenantQuery.data.description ?? "");
|
||||
setStatus(tenantQuery.data.status);
|
||||
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
||||
setDomains(tenantQuery.data.domains ?? []);
|
||||
setForceDomainConflicts([]);
|
||||
setParentId(tenantQuery.data.parentId ?? "");
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: (overrideForceDomains?: string[]) =>
|
||||
updateTenant(tenantId, {
|
||||
name,
|
||||
type,
|
||||
@@ -77,17 +86,36 @@ export function TenantProfilePage() {
|
||||
description: description || undefined,
|
||||
status,
|
||||
parentId: parentId || undefined,
|
||||
domains: domains
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d !== ""),
|
||||
domains,
|
||||
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
toast.success(t("msg.info.saved_success", "저장되었습니다."));
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
onError: (
|
||||
err: AxiosError<{
|
||||
code?: string;
|
||||
error?: string;
|
||||
conflicts?: ServerDomainConflict[];
|
||||
}>,
|
||||
) => {
|
||||
const conflicts = err.response?.data?.conflicts ?? [];
|
||||
if (
|
||||
err.response?.data?.code === "tenant_domain_conflict" &&
|
||||
conflicts.length > 0
|
||||
) {
|
||||
const nextForceDomains = Array.from(
|
||||
new Set([...forceDomainConflicts, ...conflicts.map((c) => c.domain)]),
|
||||
);
|
||||
const message = conflicts.map(formatDomainConflictMessage).join("\n");
|
||||
if (window.confirm(message)) {
|
||||
setForceDomainConflicts(nextForceDomains);
|
||||
updateMutation.mutate(nextForceDomains);
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("err.common.unknown", "오류가 발생했습니다."),
|
||||
@@ -257,9 +285,14 @@ export function TenantProfilePage() {
|
||||
"허용된 도메인 (콤마로 구분)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
<DomainTagInput
|
||||
id="tenant-domains"
|
||||
value={domains}
|
||||
onChange={(e) => setDomains(e.target.value)}
|
||||
onChange={setDomains}
|
||||
tenants={parentQuery.data?.items ?? []}
|
||||
currentTenantId={tenantId}
|
||||
confirmedConflicts={forceDomainConflicts}
|
||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||
placeholder="example.com, example.kr"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -322,7 +355,7 @@ export function TenantProfilePage() {
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate()}
|
||||
onClick={() => updateMutation.mutate(undefined)}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
tenantQuery.isLoading ||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSchemaField, normalizeSchemaField } from "./TenantSchemaPage";
|
||||
|
||||
describe("TenantSchemaPage schema field helpers", () => {
|
||||
it("creates text fields without varchar maxLength policy", () => {
|
||||
const field = createSchemaField();
|
||||
|
||||
expect(field.type).toBe("text");
|
||||
expect("maxLength" in field).toBe(false);
|
||||
expect(field.indexed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not add maxLength to legacy text schema fields", () => {
|
||||
const field = normalizeSchemaField({
|
||||
key: "emp_id",
|
||||
label: "사번",
|
||||
type: "text",
|
||||
});
|
||||
|
||||
expect("maxLength" in field).toBe(false);
|
||||
});
|
||||
|
||||
it("forces indexed when a field can be used as login ID", () => {
|
||||
const field = normalizeSchemaField({
|
||||
key: "emp_id",
|
||||
label: "사번",
|
||||
type: "text",
|
||||
indexed: false,
|
||||
isLoginId: true,
|
||||
});
|
||||
|
||||
expect(field.indexed).toBe(true);
|
||||
expect(field.isLoginId).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type SchemaFieldType =
|
||||
export type SchemaFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
@@ -25,7 +25,7 @@ type SchemaFieldType =
|
||||
| "float"
|
||||
| "datetime";
|
||||
|
||||
type SchemaField = {
|
||||
export type SchemaField = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -35,6 +35,7 @@ type SchemaField = {
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
isLoginId?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
@@ -44,6 +45,54 @@ function createFieldId() {
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function isSchemaFieldType(value: unknown): value is SchemaFieldType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "boolean" ||
|
||||
value === "date" ||
|
||||
value === "float" ||
|
||||
value === "datetime"
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSchemaField(field: unknown): SchemaField {
|
||||
const source =
|
||||
typeof field === "object" && field !== null
|
||||
? (field as Record<string, unknown>)
|
||||
: {};
|
||||
const type = isSchemaFieldType(source.type) ? source.type : "text";
|
||||
const isLoginId = Boolean(source.isLoginId);
|
||||
|
||||
return {
|
||||
id: typeof source.id === "string" ? source.id : createFieldId(),
|
||||
key: typeof source.key === "string" ? source.key : "",
|
||||
label: typeof source.label === "string" ? source.label : "",
|
||||
type,
|
||||
required: Boolean(source.required),
|
||||
adminOnly: Boolean(source.adminOnly),
|
||||
validation:
|
||||
typeof source.validation === "string" ? source.validation : "",
|
||||
unsigned: Boolean(source.unsigned),
|
||||
isLoginId,
|
||||
indexed: isLoginId || Boolean(source.indexed),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSchemaField(): SchemaField {
|
||||
return {
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
indexed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function TenantSchemaPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -71,27 +120,7 @@ export function TenantSchemaPage() {
|
||||
const rawSchema = tenantQuery.data?.config?.userSchema;
|
||||
|
||||
if (Array.isArray(rawSchema)) {
|
||||
setFields(
|
||||
rawSchema.map((field) => ({
|
||||
id: typeof field?.id === "string" ? field.id : createFieldId(),
|
||||
key: typeof field?.key === "string" ? field.key : "",
|
||||
label: typeof field?.label === "string" ? field.label : "",
|
||||
type:
|
||||
field?.type === "number" ||
|
||||
field?.type === "boolean" ||
|
||||
field?.type === "date" ||
|
||||
field?.type === "float" ||
|
||||
field?.type === "datetime"
|
||||
? field.type
|
||||
: "text",
|
||||
required: Boolean(field?.required),
|
||||
adminOnly: Boolean(field?.adminOnly),
|
||||
validation:
|
||||
typeof field?.validation === "string" ? field.validation : "",
|
||||
unsigned: Boolean(field?.unsigned),
|
||||
isLoginId: Boolean(field?.isLoginId),
|
||||
})),
|
||||
);
|
||||
setFields(rawSchema.map(normalizeSchemaField));
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
@@ -158,19 +187,7 @@ export function TenantSchemaPage() {
|
||||
}
|
||||
|
||||
const addField = () => {
|
||||
setFields([
|
||||
...fields,
|
||||
{
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
},
|
||||
]);
|
||||
setFields([...fields, createSchemaField()]);
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
@@ -261,16 +278,15 @@ export function TenantSchemaPage() {
|
||||
value={field.type}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
if (
|
||||
nextType === "text" ||
|
||||
nextType === "number" ||
|
||||
nextType === "boolean" ||
|
||||
nextType === "date" ||
|
||||
nextType === "float" ||
|
||||
nextType === "datetime"
|
||||
) {
|
||||
if (isSchemaFieldType(nextType)) {
|
||||
updateField(index, {
|
||||
type: nextType as SchemaFieldType,
|
||||
type: nextType,
|
||||
isLoginId:
|
||||
nextType === "text" ? field.isLoginId : false,
|
||||
indexed:
|
||||
nextType === "text"
|
||||
? field.indexed || field.isLoginId || false
|
||||
: field.indexed,
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -351,7 +367,11 @@ export function TenantSchemaPage() {
|
||||
type="checkbox"
|
||||
checked={field.isLoginId || false}
|
||||
onChange={(e) =>
|
||||
updateField(index, { isLoginId: e.target.checked })
|
||||
updateField(index, {
|
||||
isLoginId: e.target.checked,
|
||||
indexed: e.target.checked ? true : field.indexed,
|
||||
type: e.target.checked ? "text" : field.type,
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
@@ -362,6 +382,23 @@ export function TenantSchemaPage() {
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.indexed || field.isLoginId || false}
|
||||
disabled={field.isLoginId}
|
||||
onChange={(e) =>
|
||||
updateField(index, { indexed: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.indexed",
|
||||
"검색 인덱스 필요",
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{(field.type === "number" || field.type === "float") && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
|
||||
67
adminfront/src/features/tenants/utils/domainTags.test.ts
Normal file
67
adminfront/src/features/tenants/utils/domainTags.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findDomainConflict,
|
||||
formatDomainConflictMessage,
|
||||
normalizeDomainTokens,
|
||||
} from "./domainTags";
|
||||
|
||||
describe("domainTags", () => {
|
||||
it("splits domains by comma and whitespace", () => {
|
||||
expect(
|
||||
normalizeDomainTokens("samaneng.com, hanmaceng.co.kr login.hmac.kr"),
|
||||
).toEqual(["samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"]);
|
||||
});
|
||||
|
||||
it("finds a domain already assigned to another tenant", () => {
|
||||
const conflict = findDomainConflict("hanmaceng.co.kr", [
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
type: "COMPANY",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: ["hanmaceng.co.kr"],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(conflict?.tenant.name).toBe("한맥기술");
|
||||
});
|
||||
|
||||
it("ignores the current tenant when checking domain conflicts", () => {
|
||||
const conflict = findDomainConflict(
|
||||
"hanmaceng.co.kr",
|
||||
[
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
type: "COMPANY",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: ["hanmaceng.co.kr"],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
],
|
||||
"tenant-1",
|
||||
);
|
||||
|
||||
expect(conflict).toBeNull();
|
||||
});
|
||||
|
||||
it("formats a duplicate domain message with tenant context", () => {
|
||||
expect(
|
||||
formatDomainConflictMessage({
|
||||
domain: "samaneng.com",
|
||||
tenantName: "한맥가족",
|
||||
}),
|
||||
).toBe(
|
||||
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
|
||||
);
|
||||
});
|
||||
});
|
||||
59
adminfront/src/features/tenants/utils/domainTags.ts
Normal file
59
adminfront/src/features/tenants/utils/domainTags.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
|
||||
export type DomainConflict = {
|
||||
domain: string;
|
||||
tenant: TenantSummary;
|
||||
};
|
||||
|
||||
export type ServerDomainConflict = {
|
||||
domain: string;
|
||||
tenantId?: string;
|
||||
tenantName?: string;
|
||||
tenantSlug?: string;
|
||||
};
|
||||
|
||||
export function normalizeDomainTokens(value: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const tokens: string[] = [];
|
||||
for (const raw of value.split(/[,\s;]+/)) {
|
||||
const token = raw.trim().toLowerCase();
|
||||
if (!token || seen.has(token)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(token);
|
||||
tokens.push(token);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function findDomainConflict(
|
||||
domain: string,
|
||||
tenants: TenantSummary[] = [],
|
||||
currentTenantId?: string,
|
||||
): DomainConflict | null {
|
||||
const normalized = domain.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const tenant of tenants) {
|
||||
if (tenant.id === currentTenantId) {
|
||||
continue;
|
||||
}
|
||||
const domains = tenant.domains ?? [];
|
||||
if (domains.some((item) => item.trim().toLowerCase() === normalized)) {
|
||||
return { domain: normalized, tenant };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatDomainConflictMessage(
|
||||
conflict: DomainConflict | ServerDomainConflict,
|
||||
): string {
|
||||
const tenantName =
|
||||
"tenant" in conflict
|
||||
? conflict.tenant.name
|
||||
: conflict.tenantName || conflict.tenantSlug || conflict.tenantId || "다른";
|
||||
return `${conflict.domain} 도메인은 ${tenantName} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
|
||||
}
|
||||
@@ -46,6 +46,7 @@ describe("tenantCsvImport", () => {
|
||||
name: "Hanmac Tech",
|
||||
type: "COMPANY",
|
||||
parentTenantId: "",
|
||||
parentTenantSlug: "",
|
||||
slug: "hanmac-tech",
|
||||
memo: "Memo",
|
||||
emailDomain: "hanmac-tech.example.com",
|
||||
@@ -89,4 +90,88 @@ describe("tenantCsvImport", () => {
|
||||
"tenant-1,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes create resolutions by resetting external tenant id and conflicting slug", () => {
|
||||
const rows = parseTenantCSV(
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\nlocal-tenant-id,Hanmac Technology,COMPANY,,hanmac,Memo,hanmac.example.com\n",
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
|
||||
expect(preview[0].conflicts).toEqual(
|
||||
expect.arrayContaining(["external_tenant_id", "slug_exists"]),
|
||||
);
|
||||
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: {
|
||||
mode: "create",
|
||||
tenantId: "staging-new-tenant-id",
|
||||
slug: "hanmac-imported",
|
||||
},
|
||||
});
|
||||
|
||||
expect(csv).toContain(
|
||||
"staging-new-tenant-id,Hanmac Technology,COMPANY,,hanmac-imported,Memo,hanmac.example.com",
|
||||
);
|
||||
expect(csv).not.toContain("local-tenant-id");
|
||||
});
|
||||
|
||||
it("remaps child parent_tenant_id from source ids to selected staging ids", () => {
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain",
|
||||
"local-parent-id,Parent Tenant,COMPANY,,parent-local,,",
|
||||
"local-child-id,Child Tenant,USER_GROUP,local-parent-id,child-local,,",
|
||||
].join("\n"),
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: {
|
||||
mode: "create",
|
||||
tenantId: "staging-parent-id",
|
||||
slug: "parent-staging",
|
||||
},
|
||||
3: {
|
||||
mode: "create",
|
||||
tenantId: "staging-child-id",
|
||||
slug: "child-staging",
|
||||
},
|
||||
});
|
||||
|
||||
expect(csv).toContain(
|
||||
"staging-parent-id,Parent Tenant,COMPANY,,parent-staging,,",
|
||||
);
|
||||
expect(csv).toContain(
|
||||
"staging-child-id,Child Tenant,USER_GROUP,staging-parent-id,child-staging,,",
|
||||
);
|
||||
expect(csv).not.toContain("local-parent-id");
|
||||
expect(csv).not.toContain("local-child-id");
|
||||
});
|
||||
|
||||
it("parses parent_tenant_slug and remaps it to selected staging ids", () => {
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain",
|
||||
"Parent Tenant,COMPANY,,parent-slug,,",
|
||||
"Child Tenant,USER_GROUP,parent-slug,child-slug,,",
|
||||
].join("\n"),
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: {
|
||||
mode: "create",
|
||||
tenantId: "staging-parent-id",
|
||||
slug: "parent-slug",
|
||||
},
|
||||
3: {
|
||||
mode: "create",
|
||||
tenantId: "staging-child-id",
|
||||
slug: "child-slug",
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows[1].parentTenantSlug).toBe("parent-slug");
|
||||
expect(csv).toContain(
|
||||
"staging-child-id,Child Tenant,USER_GROUP,staging-parent-id,child-slug,,",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export type TenantCSVRow = {
|
||||
name: string;
|
||||
type: string;
|
||||
parentTenantId: string;
|
||||
parentTenantSlug: string;
|
||||
slug: string;
|
||||
memo: string;
|
||||
emailDomain: string;
|
||||
@@ -23,8 +24,30 @@ export type TenantImportPreviewRow = {
|
||||
row: TenantCSVRow;
|
||||
candidates: TenantImportCandidate[];
|
||||
defaultTenantId: string;
|
||||
defaultCreateSlug: string;
|
||||
conflicts: TenantImportConflict[];
|
||||
};
|
||||
|
||||
export type TenantImportConflict =
|
||||
| "external_tenant_id"
|
||||
| "slug_exists"
|
||||
| "parent_tenant_id_unresolved";
|
||||
|
||||
export type TenantImportResolution =
|
||||
| {
|
||||
mode: "existing";
|
||||
tenantId: string;
|
||||
}
|
||||
| {
|
||||
mode: "create";
|
||||
tenantId?: string;
|
||||
slug?: string;
|
||||
parentTenantId?: string;
|
||||
}
|
||||
| {
|
||||
mode: "skip";
|
||||
};
|
||||
|
||||
const importHeaders = [
|
||||
"tenant_id",
|
||||
"name",
|
||||
@@ -45,6 +68,8 @@ const headerAliases: Record<string, keyof TenantCSVRow> = {
|
||||
parent_id: "parentTenantId",
|
||||
parenttenantid: "parentTenantId",
|
||||
parent_tenant_id: "parentTenantId",
|
||||
parenttenantslug: "parentTenantSlug",
|
||||
parent_tenant_slug: "parentTenantSlug",
|
||||
slug: "slug",
|
||||
memo: "memo",
|
||||
description: "memo",
|
||||
@@ -80,6 +105,7 @@ export function parseTenantCSV(text: string): TenantCSVRow[] {
|
||||
name: value("name"),
|
||||
type: value("type"),
|
||||
parentTenantId: value("parentTenantId"),
|
||||
parentTenantSlug: value("parentTenantSlug"),
|
||||
slug: value("slug"),
|
||||
memo: value("memo"),
|
||||
emailDomain: value("emailDomain"),
|
||||
@@ -93,14 +119,17 @@ export function buildTenantImportPreview(
|
||||
): TenantImportPreviewRow[] {
|
||||
return rows
|
||||
.map((row) => {
|
||||
const candidates = row.tenantId ? [] : findTenantCandidates(row, tenants);
|
||||
const candidates = findTenantCandidates(row, tenants);
|
||||
const conflicts = findTenantImportConflicts(row, tenants);
|
||||
return {
|
||||
row,
|
||||
candidates,
|
||||
conflicts,
|
||||
defaultTenantId:
|
||||
candidates[0] && candidates[0].score >= 0.95
|
||||
? candidates[0].tenantId
|
||||
: "",
|
||||
defaultCreateSlug: suggestUniqueTenantSlug(row.slug || row.name, tenants),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@@ -113,24 +142,148 @@ export function buildTenantImportPreview(
|
||||
|
||||
export function serializeTenantImportCSV(
|
||||
previewRows: TenantImportPreviewRow[],
|
||||
selectedTenantIds: Record<number, string>,
|
||||
selectedTenantIds: Record<number, string | TenantImportResolution>,
|
||||
) {
|
||||
const lines = [importHeaders];
|
||||
for (const preview of [...previewRows].sort(
|
||||
const sortedRows = [...previewRows].sort(
|
||||
(a, b) => a.row.rowNumber - b.row.rowNumber,
|
||||
)) {
|
||||
const selectedTenantId = selectedTenantIds[preview.row.rowNumber] ?? "";
|
||||
);
|
||||
const targetTenantIds = buildTargetTenantIds(
|
||||
sortedRows,
|
||||
selectedTenantIds,
|
||||
);
|
||||
|
||||
for (const preview of sortedRows) {
|
||||
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
||||
if (typeof resolution === "object" && resolution.mode === "skip") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const selectedTenantId =
|
||||
typeof resolution === "string"
|
||||
? resolution
|
||||
: resolution.mode === "existing"
|
||||
? resolution.tenantId
|
||||
: "";
|
||||
const slug =
|
||||
typeof resolution === "object" && resolution.mode === "create"
|
||||
? resolution.slug || preview.defaultCreateSlug
|
||||
: preview.row.slug;
|
||||
const parentTenantId =
|
||||
typeof resolution === "object" && resolution.mode === "create"
|
||||
? (resolution.parentTenantId ??
|
||||
remapParentTenantId(
|
||||
preview.row.parentTenantId,
|
||||
preview.row.parentTenantSlug,
|
||||
targetTenantIds,
|
||||
))
|
||||
: preview.row.parentTenantId;
|
||||
const tenantId =
|
||||
typeof resolution === "object" && resolution.mode === "create"
|
||||
? (resolution.tenantId ??
|
||||
targetTenantIds.bySourceId.get(preview.row.tenantId) ??
|
||||
createTenantImportId())
|
||||
: selectedTenantId || preview.row.tenantId;
|
||||
|
||||
lines.push([
|
||||
preview.row.tenantId || selectedTenantId,
|
||||
tenantId,
|
||||
preview.row.name,
|
||||
preview.row.type,
|
||||
preview.row.parentTenantId,
|
||||
preview.row.slug,
|
||||
parentTenantId,
|
||||
slug,
|
||||
preview.row.memo,
|
||||
preview.row.emailDomain,
|
||||
]);
|
||||
}
|
||||
return lines.map(formatCSVRecord).join("\n") + "\n";
|
||||
return `${lines.map(formatCSVRecord).join("\n")}\n`;
|
||||
}
|
||||
|
||||
function buildTargetTenantIds(
|
||||
previewRows: TenantImportPreviewRow[],
|
||||
selectedTenantIds: Record<number, string | TenantImportResolution>,
|
||||
) {
|
||||
const bySourceId = new Map<string, string>();
|
||||
const bySourceSlug = new Map<string, string>();
|
||||
|
||||
for (const preview of previewRows) {
|
||||
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
||||
if (typeof resolution === "object" && resolution.mode === "skip") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetTenantId =
|
||||
typeof resolution === "string"
|
||||
? resolution || preview.row.tenantId
|
||||
: resolution.mode === "existing"
|
||||
? resolution.tenantId
|
||||
: resolution.tenantId || createTenantImportId();
|
||||
|
||||
if (preview.row.tenantId) {
|
||||
bySourceId.set(preview.row.tenantId, targetTenantId);
|
||||
}
|
||||
if (preview.row.slug) {
|
||||
bySourceSlug.set(preview.row.slug.toLowerCase(), targetTenantId);
|
||||
}
|
||||
}
|
||||
|
||||
return { bySourceId, bySourceSlug };
|
||||
}
|
||||
|
||||
function remapParentTenantId(
|
||||
parentTenantId: string,
|
||||
parentTenantSlug: string,
|
||||
targetTenantIds: {
|
||||
bySourceId: Map<string, string>;
|
||||
bySourceSlug: Map<string, string>;
|
||||
},
|
||||
) {
|
||||
if (parentTenantId) {
|
||||
return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId;
|
||||
}
|
||||
if (parentTenantSlug) {
|
||||
return targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createTenantImportId() {
|
||||
if (globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `00000000-0000-4000-8000-${Math.random()
|
||||
.toString(16)
|
||||
.slice(2, 14)
|
||||
.padEnd(12, "0")}`;
|
||||
}
|
||||
|
||||
function findTenantImportConflicts(
|
||||
row: TenantCSVRow,
|
||||
tenants: TenantSummary[],
|
||||
): TenantImportConflict[] {
|
||||
const conflicts: TenantImportConflict[] = [];
|
||||
const matchingId = row.tenantId
|
||||
? tenants.find((tenant) => tenant.id === row.tenantId)
|
||||
: undefined;
|
||||
const matchingSlug = row.slug
|
||||
? tenants.find(
|
||||
(tenant) => normalizeToken(tenant.slug) === normalizeToken(row.slug),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (row.tenantId && !matchingId) {
|
||||
conflicts.push("external_tenant_id");
|
||||
}
|
||||
if (matchingSlug && matchingSlug.id !== row.tenantId) {
|
||||
conflicts.push("slug_exists");
|
||||
}
|
||||
if (
|
||||
row.parentTenantId &&
|
||||
!tenants.some((tenant) => tenant.id === row.parentTenantId)
|
||||
) {
|
||||
conflicts.push("parent_tenant_id_unresolved");
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function findTenantCandidates(
|
||||
@@ -230,6 +383,28 @@ function normalizeToken(value: string) {
|
||||
.replace(/[^\p{L}\p{N}]/gu, "");
|
||||
}
|
||||
|
||||
function suggestUniqueTenantSlug(value: string, tenants: TenantSummary[]) {
|
||||
const base = slugify(value) || "tenant";
|
||||
const used = new Set(tenants.map((tenant) => tenant.slug.toLowerCase()));
|
||||
if (!used.has(base)) {
|
||||
return base;
|
||||
}
|
||||
|
||||
let index = 2;
|
||||
while (used.has(`${base}-${index}`)) {
|
||||
index += 1;
|
||||
}
|
||||
return `${base}-${index}`;
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9가-힣ㄱ-ㅎㅏ-ㅣ]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function similarity(left: string, right: string) {
|
||||
const a = normalizeToken(left);
|
||||
const b = normalizeToken(right);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", "등록 시작")}
|
||||
|
||||
18
adminfront/src/features/users/userSchemaFields.ts
Normal file
18
adminfront/src/features/users/userSchemaFields.ts
Normal 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;
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
296
adminfront/src/features/users/utils/hanmacImportEmail.ts
Normal file
296
adminfront/src/features/users/utils/hanmacImportEmail.ts
Normal 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, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user