forked from baron/baron-sso
Implement tenant import and RP auto login policies
This commit is contained in:
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);
|
||||
|
||||
Reference in New Issue
Block a user