feat: add governed LDIF contact imports

This commit is contained in:
2026-08-20 11:19:23 +02:00
parent ec32ad2c37
commit 147f34c5c1
9 changed files with 732 additions and 53 deletions
+3 -2
View File
@@ -494,6 +494,7 @@ export type AddressImportConfiguration = {
duplicate_source_key_policy: "reject" | "first" | "last";
existing_contact_policy: "update" | "ignore" | "reject";
blank_value_policy: "ignore" | "clear" | "reject";
ldif_change_record_policy: "reject" | "ignore" | "treat_add_as_entry";
locale?: string | null;
default_tags: string[];
max_rows: number;
@@ -508,7 +509,7 @@ export type AddressImportProfile = {
scope_id?: string | null;
name: string;
description?: string | null;
source_format: "csv" | "xlsx";
source_format: "csv" | "xlsx" | "ldif";
configuration: AddressImportConfiguration;
is_current: boolean;
created_at: string;
@@ -1068,7 +1069,7 @@ export function createAddressImportProfile(
scope_id?: string | null;
name: string;
description?: string | null;
source_format: "csv" | "xlsx";
source_format: "csv" | "xlsx" | "ldif";
configuration: AddressImportConfiguration;
}
): Promise<AddressImportProfile> {
@@ -237,7 +237,7 @@ type ImportMode = "vcard" | "tabular";
type ImportProfileFormState = {
name: string;
source_format: "csv" | "xlsx";
source_format: "csv" | "xlsx" | "ldif";
delimiter: "," | ";" | "\t" | "|";
encoding: "utf-8" | "utf-8-sig" | "cp1252" | "latin-1";
header_row: string;
@@ -245,6 +245,7 @@ type ImportProfileFormState = {
duplicate_source_key_policy: "reject" | "first" | "last";
existing_contact_policy: "update" | "ignore" | "reject";
blank_value_policy: "ignore" | "clear" | "reject";
ldif_change_record_policy: "reject" | "ignore" | "treat_add_as_entry";
locale: string;
default_tags: string;
max_rows: string;
@@ -398,6 +399,39 @@ const EMPTY_LDAP_FORM: LdapFormState = {
attribute_map: DEFAULT_LDAP_ATTRIBUTE_MAP
};
const DEFAULT_TABULAR_FIELD_MAPPINGS: Record<string, string> = {
source_key: "id",
display_name: "display_name",
given_name: "given_name",
family_name: "family_name",
organization: "organization",
role_title: "role_title",
email: "email",
phone: "phone",
street: "street",
postal_code: "postal_code",
locality: "locality",
region: "region",
country: "country",
tags: "tags"
};
const DEFAULT_LDIF_FIELD_MAPPINGS: Record<string, string> = {
source_key: "dn",
display_name: "cn",
given_name: "givenname",
family_name: "sn",
organization: "o",
role_title: "title",
email: "mail",
phone: "telephonenumber",
street: "street",
postal_code: "postalcode",
locality: "l",
region: "st",
country: "c"
};
const EMPTY_IMPORT_PROFILE_FORM: ImportProfileFormState = {
name: "",
source_format: "csv",
@@ -408,25 +442,11 @@ const EMPTY_IMPORT_PROFILE_FORM: ImportProfileFormState = {
duplicate_source_key_policy: "reject",
existing_contact_policy: "update",
blank_value_policy: "ignore",
ldif_change_record_policy: "reject",
locale: "",
default_tags: "",
max_rows: "10000",
field_mappings: {
source_key: "id",
display_name: "display_name",
given_name: "given_name",
family_name: "family_name",
organization: "organization",
role_title: "role_title",
email: "email",
phone: "phone",
street: "street",
postal_code: "postal_code",
locality: "locality",
region: "region",
country: "country",
tags: "tags"
}
field_mappings: DEFAULT_TABULAR_FIELD_MAPPINGS
};
const IMPORT_MAPPING_FIELDS = [
@@ -2384,6 +2404,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
duplicate_source_key_policy: importProfileForm.duplicate_source_key_policy,
existing_contact_policy: importProfileForm.existing_contact_policy,
blank_value_policy: importProfileForm.blank_value_policy,
ldif_change_record_policy: importProfileForm.ldif_change_record_policy,
locale: importProfileForm.locale.trim() || null,
default_tags: importProfileForm.default_tags.split(",").map((tag) => tag.trim()).filter(Boolean),
max_rows: Number(importProfileForm.max_rows) || 10000
@@ -2438,6 +2459,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
duplicate_source_key_policy: config.duplicate_source_key_policy,
existing_contact_policy: config.existing_contact_policy,
blank_value_policy: config.blank_value_policy,
ldif_change_record_policy: config.ldif_change_record_policy ?? "reject",
locale: config.locale ?? "",
default_tags: config.default_tags.join(", "),
max_rows: String(config.max_rows),
@@ -3798,7 +3820,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
role="group"
size="equal"
ariaLabel="Contact import format"
options={[{ id: "vcard", label: "vCard" }, { id: "tabular", label: "CSV / XLSX" }]}
options={[{ id: "vcard", label: "vCard" }, { id: "tabular", label: "Mapped file" }]}
value={importMode}
onChange={(mode) => { setImportMode(mode); clearRetainedImportRun(); }}
/>
@@ -3861,9 +3883,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Profile name"><input value={importProfileForm.name} onChange={(event) => setImportProfileForm((current) => ({ ...current, name: event.target.value }))} /></FormField>
<FormField label="Format">
<select value={importProfileForm.source_format} disabled={Boolean(editingImportProfileId)} onChange={(event) => setImportProfileForm((current) => ({ ...current, source_format: event.target.value as "csv" | "xlsx" }))}>
<select value={importProfileForm.source_format} disabled={Boolean(editingImportProfileId)} onChange={(event) => setImportProfileForm((current) => ({
...current,
source_format: event.target.value as ImportProfileFormState["source_format"],
field_mappings: event.target.value === "ldif"
? DEFAULT_LDIF_FIELD_MAPPINGS
: current.source_format === "ldif"
? DEFAULT_TABULAR_FIELD_MAPPINGS
: current.field_mappings
}))}>
<option value="csv">CSV</option>
<option value="xlsx">XLSX</option>
<option value="ldif">LDIF</option>
</select>
</FormField>
{importProfileForm.source_format === "csv" && <>
@@ -3879,7 +3910,15 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</FormField>
</>}
{importProfileForm.source_format === "xlsx" && <FormField label="Sheet name"><input value={importProfileForm.sheet_name} onChange={(event) => setImportProfileForm((current) => ({ ...current, sheet_name: event.target.value }))} placeholder="First sheet" /></FormField>}
<FormField label="Header row"><input type="number" min="1" max="100" value={importProfileForm.header_row} onChange={(event) => setImportProfileForm((current) => ({ ...current, header_row: event.target.value }))} /></FormField>
{importProfileForm.source_format !== "ldif" && <FormField label="Header row"><input type="number" min="1" max="100" value={importProfileForm.header_row} onChange={(event) => setImportProfileForm((current) => ({ ...current, header_row: event.target.value }))} /></FormField>}
{importProfileForm.source_format === "ldif" &&
<FormField label="LDIF change records">
<select value={importProfileForm.ldif_change_record_policy} onChange={(event) => setImportProfileForm((current) => ({ ...current, ldif_change_record_policy: event.target.value as ImportProfileFormState["ldif_change_record_policy"] }))}>
<option value="reject">Reject with diagnostics</option>
<option value="ignore">Ignore all change records</option>
<option value="treat_add_as_entry">Import add records; reject modify/delete</option>
</select>
</FormField>}
<FormField label="Duplicate source keys">
<select value={importProfileForm.duplicate_source_key_policy} onChange={(event) => setImportProfileForm((current) => ({ ...current, duplicate_source_key_policy: event.target.value as ImportProfileFormState["duplicate_source_key_policy"] }))}>
<option value="reject">Reject duplicates</option><option value="first">Use first row</option><option value="last">Use last row</option>
@@ -3905,7 +3944,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<input
value={importProfileForm.field_mappings[target] ?? ""}
onChange={(event) => setImportProfileForm((current) => ({ ...current, field_mappings: { ...current.field_mappings, [target]: event.target.value } }))}
placeholder="Source column"
placeholder={importProfileForm.source_format === "ldif" ? "LDIF attribute" : "Source column"}
/>
</FormField>)}
</div>
@@ -3914,7 +3953,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Import file">
<input
type="file"
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
accept=".csv,.xlsx,.ldif,.ldi,text/csv,application/ldif,text/ldif,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); clearRetainedImportRun(); }}
/>
</FormField>