Add governed tabular and LDAP address sources

This commit is contained in:
2026-08-02 15:39:34 +02:00
parent e60339a5bf
commit 2c421022d4
19 changed files with 3846 additions and 78 deletions
+173
View File
@@ -484,6 +484,92 @@ export type AddressSyncConflict = {
updated_at: string;
};
export type AddressImportConfiguration = {
field_mappings: Record<string, string>;
delimiter: "," | ";" | "\t" | "|";
encoding: "utf-8" | "utf-8-sig" | "cp1252" | "latin-1";
header_row: number;
sheet_name?: string | null;
source_key_column?: string | null;
duplicate_source_key_policy: "reject" | "first" | "last";
existing_contact_policy: "update" | "ignore" | "reject";
blank_value_policy: "ignore" | "clear" | "reject";
locale?: string | null;
default_tags: string[];
max_rows: number;
};
export type AddressImportProfile = {
id: string;
profile_key: string;
version: number;
tenant_id?: string | null;
scope_type: AddressBookScope;
scope_id?: string | null;
name: string;
description?: string | null;
source_format: "csv" | "xlsx";
configuration: AddressImportConfiguration;
is_current: boolean;
created_at: string;
updated_at: string;
};
export type AddressImportEffect = {
row_number: number;
action: "create" | "update" | "conflict" | "unchanged" | "ignored";
source_key?: string | null;
contact_id?: string | null;
display_name?: string | null;
changed_fields: string[];
message?: string | null;
};
export type AddressImportDiagnostic = {
severity: "info" | "warning" | "error";
code: string;
message: string;
row_number?: number | null;
field?: string | null;
details: Record<string, unknown>;
};
export type AddressImportRun = {
id: string;
address_book_id: string;
profile_id: string;
source_filename: string;
source_format: string;
input_hash: string;
plan_hash: string;
status: string;
row_count: number;
statistics: Record<string, number>;
diagnostics: AddressImportDiagnostic[];
effects: AddressImportEffect[];
can_apply: boolean;
result_evidence: Record<string, unknown>;
created_at: string;
updated_at: string;
applied_at?: string | null;
rolled_back_at?: string | null;
};
export type AddressLdapSourcePayload = {
url: string;
credential_ref?: string | null;
bind_dn?: string | null;
start_tls: boolean;
connect_timeout?: number;
receive_timeout?: number;
display_name: string;
base_dn: string;
search_filter: string;
page_size: number;
max_entries: number;
attribute_map: Record<string, string>;
};
type AddressSyncSourceListResponse = {
sync_sources: AddressSyncSource[];
};
@@ -508,6 +594,14 @@ type AddressSyncConflictListResponse = {
conflicts: AddressSyncConflict[];
};
type AddressImportProfileListResponse = {
profiles: AddressImportProfile[];
};
type AddressLdapDiscoveryResponse = {
base_dns: string[];
};
type ContactChannelRuleListResponse = {
rules: ContactChannelRule[];
};
@@ -692,6 +786,27 @@ export function createCardDavSyncSource(
});
}
export function discoverLdapBaseDns(
settings: ApiSettings,
payload: Pick<AddressLdapSourcePayload, "url" | "credential_ref" | "bind_dn" | "start_tls" | "connect_timeout" | "receive_timeout">
): Promise<string[]> {
return apiFetch<AddressLdapDiscoveryResponse>(settings, "/api/v1/addresses/ldap/discover", {
method: "POST",
body: JSON.stringify(payload)
}).then((response) => response.base_dns);
}
export function createLdapSyncSource(
settings: ApiSettings,
addressBookId: string,
payload: AddressLdapSourcePayload
): Promise<AddressSyncSource> {
return apiFetch<AddressSyncSource>(settings, `/api/v1/addresses/address-books/${addressBookId}/ldap/sources`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listAddressCredentials(
settings: ApiSettings,
sourceId?: string | null
@@ -941,6 +1056,64 @@ export function importAddressBookVcards(settings: ApiSettings, addressBookId: st
});
}
export async function listAddressImportProfiles(settings: ApiSettings): Promise<AddressImportProfile[]> {
const response = await apiFetch<AddressImportProfileListResponse>(settings, "/api/v1/addresses/import-profiles");
return response.profiles;
}
export function createAddressImportProfile(
settings: ApiSettings,
payload: {
scope_type: AddressBookScope;
scope_id?: string | null;
name: string;
description?: string | null;
source_format: "csv" | "xlsx";
configuration: AddressImportConfiguration;
}
): Promise<AddressImportProfile> {
return apiFetch<AddressImportProfile>(settings, "/api/v1/addresses/import-profiles", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateAddressImportProfile(
settings: ApiSettings,
profileId: string,
payload: { name?: string; description?: string | null; configuration?: AddressImportConfiguration }
): Promise<AddressImportProfile> {
return apiFetch<AddressImportProfile>(settings, `/api/v1/addresses/import-profiles/${profileId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function previewAddressImport(
settings: ApiSettings,
addressBookId: string,
payload: { profile_id: string; filename: string; content_base64: string }
): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/address-books/${addressBookId}/imports/preview`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function applyAddressImport(settings: ApiSettings, run: AddressImportRun): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${run.id}/apply`, {
method: "POST",
body: JSON.stringify({ expected_plan_hash: run.plan_hash })
});
}
export function rollbackAddressImport(settings: ApiSettings, runId: string, reason: string): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${runId}/rollback`, {
method: "POST",
body: JSON.stringify({ reason })
});
}
export function exportAddressBookVcards(settings: ApiSettings, addressBookId: string): Promise<string> {
return apiFetch<string>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/export`);
}
@@ -1,4 +1,4 @@
import { Download, Edit3, GitMerge, History, Link2, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
import { Download, Edit3, GitMerge, History, Link2, Network, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import {
ApiError,
@@ -29,7 +29,9 @@ import {
createAddressBook,
createAddressList,
createAddressListEntry,
createAddressImportProfile,
createCardDavSyncSource,
createLdapSyncSource,
createContact,
createContactChannelRule,
createContactQualityDecision,
@@ -39,12 +41,15 @@ import {
deleteContact,
deleteAddressSyncSource,
discoverCardDavAddressBooks,
discoverLdapBaseDns,
endContactChannelRule,
exportAddressBookVcards,
exportContactVcard,
importAddressBookVcards,
applyAddressImport,
getAddressQualitySummary,
listAddressBooks,
listAddressImportProfiles,
listAddressCredentials,
listAddressListEntries,
listAddressLists,
@@ -59,6 +64,7 @@ import {
listContactMerges,
listContactProvenance,
previewAddressSyncSource,
previewAddressImport,
mergeContacts,
recoverContactMerge,
restoreAddressBook,
@@ -67,10 +73,13 @@ import {
resolveAddressSyncConflict,
runAddressSyncSource,
updateAddressBook,
updateAddressImportProfile,
updateAddressList,
updateAddressSyncSource,
updateContact,
type AddressCardDavAddressBook,
type AddressImportProfile,
type AddressImportRun,
type AddressBook,
type AddressBookScope,
type AddressChannelDecision,
@@ -190,6 +199,37 @@ type CardDavFormState = {
sync_direction: "read_only" | "import" | "export" | "two_way";
};
type LdapFormState = {
url: string;
display_name: string;
credential_envelope_id: string;
bind_dn: string;
start_tls: boolean;
base_dn: string;
search_filter: string;
page_size: string;
max_entries: string;
attribute_map: Record<string, string>;
};
type ImportMode = "vcard" | "tabular";
type ImportProfileFormState = {
name: string;
source_format: "csv" | "xlsx";
delimiter: "," | ";" | "\t" | "|";
encoding: "utf-8" | "utf-8-sig" | "cp1252" | "latin-1";
header_row: string;
sheet_name: string;
duplicate_source_key_policy: "reject" | "first" | "last";
existing_contact_policy: "update" | "ignore" | "reject";
blank_value_policy: "ignore" | "clear" | "reject";
locale: string;
default_tags: string;
max_rows: string;
field_mappings: Record<string, string>;
};
type SyncInspectorState = {
source: AddressSyncSource;
} | null;
@@ -306,6 +346,90 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = {
sync_direction: "read_only"
};
const DEFAULT_LDAP_ATTRIBUTE_MAP: Record<string, string> = {
source_key: "entryUUID",
source_revision: "modifyTimestamp",
display_name: "displayName",
given_name: "givenName",
family_name: "sn",
organization: "o",
role_title: "title",
email: "mail",
phone: "telephoneNumber",
street: "streetAddress",
postal_code: "postalCode",
locality: "l",
region: "st",
country: "c",
tags: "memberOf"
};
const EMPTY_LDAP_FORM: LdapFormState = {
url: "ldaps://",
display_name: "",
credential_envelope_id: "",
bind_dn: "",
start_tls: true,
base_dn: "",
search_filter: "(&(objectClass=person)(mail=*))",
page_size: "500",
max_entries: "10000",
attribute_map: DEFAULT_LDAP_ATTRIBUTE_MAP
};
const EMPTY_IMPORT_PROFILE_FORM: ImportProfileFormState = {
name: "",
source_format: "csv",
delimiter: ";",
encoding: "utf-8-sig",
header_row: "1",
sheet_name: "",
duplicate_source_key_policy: "reject",
existing_contact_policy: "update",
blank_value_policy: "ignore",
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"
}
};
const IMPORT_MAPPING_FIELDS = [
["source_key", "Stable source key"],
["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"]
] as const;
const LDAP_MAPPING_FIELDS = [
["source_revision", "Source revision"],
...IMPORT_MAPPING_FIELDS
] as const;
const EMPTY_CHANNEL_RULE_FORM: ChannelRuleFormState = {
channel: "email",
purpose: "",
@@ -756,6 +880,20 @@ function downloadText(filename: string, content: string, type = "text/vcard;char
window.URL.revokeObjectURL(url);
}
function fileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("The selected file could not be read."));
reader.onload = () => {
const value = String(reader.result ?? "");
const separator = value.indexOf(",");
if (separator < 0) reject(new Error("The selected file did not produce readable content."));
else resolve(value.slice(separator + 1));
};
reader.readAsDataURL(file);
});
}
function channelRuleState(rule: ContactChannelRule): "active" | "scheduled" | "ended" {
const now = Date.now();
if (rule.effective_until && new Date(rule.effective_until).getTime() <= now) return "ended";
@@ -814,12 +952,23 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [memberTargetByContactId, setMemberTargetByContactId] = useState<Record<string, string>>({});
const [dropTargetListId, setDropTargetListId] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [importMode, setImportMode] = useState<ImportMode>("vcard");
const [vcardContent, setVcardContent] = useState("");
const [importProfiles, setImportProfiles] = useState<AddressImportProfile[]>([]);
const [selectedImportProfileId, setSelectedImportProfileId] = useState("");
const [creatingImportProfile, setCreatingImportProfile] = useState(false);
const [editingImportProfileId, setEditingImportProfileId] = useState("");
const [importProfileForm, setImportProfileForm] = useState<ImportProfileFormState>(EMPTY_IMPORT_PROFILE_FORM);
const [importFile, setImportFile] = useState<File | null>(null);
const [importRun, setImportRun] = useState<AddressImportRun | null>(null);
const [cardDavOpen, setCardDavOpen] = useState(false);
const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM);
const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]);
const [cardDavCredentials, setCardDavCredentials] = useState<AddressCredentialEnvelope[]>([]);
const [cardDavCredentialsError, setCardDavCredentialsError] = useState("");
const [ldapOpen, setLdapOpen] = useState(false);
const [ldapForm, setLdapForm] = useState<LdapFormState>(EMPTY_LDAP_FORM);
const [ldapBaseDns, setLdapBaseDns] = useState<string[]>([]);
const [syncInspector, setSyncInspector] = useState<SyncInspectorState>(null);
const [syncPlan, setSyncPlan] = useState<AddressSyncPlan | null>(null);
const [syncDiagnostics, setSyncDiagnostics] = useState<AddressSyncDiagnostic[]>([]);
@@ -842,7 +991,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const canWriteSync = hasScope(auth, "addresses:sync:write");
useEffect(() => {
if (!cardDavOpen || !canWriteSync) {
if ((!cardDavOpen && !ldapOpen) || !canWriteSync) {
setCardDavCredentials([]);
setCardDavCredentialsError("");
return;
@@ -867,11 +1016,28 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}, [
canWriteSync,
cardDavOpen,
ldapOpen,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
useEffect(() => {
if (!importOpen || importMode !== "tabular") return;
let active = true;
listAddressImportProfiles(settings)
.then((profiles) => {
if (!active) return;
setImportProfiles(profiles);
setSelectedImportProfileId((current) => current || profiles[0]?.id || "");
setCreatingImportProfile(profiles.length === 0);
})
.catch((err) => {
if (active) setError(errorMessage(err));
});
return () => { active = false; };
}, [importMode, importOpen, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (auth.groups_loaded || !onAuthChange) return;
let active = true;
@@ -1033,6 +1199,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[!selectedBook, "Select an address book before importing vCards."],
[!vcardContent.trim(), "Paste vCard content before importing."]
);
const importProfileSaveReason = disabledReason(
[saving, savingReason],
[!importProfileForm.name.trim(), "Enter a mapping profile name."],
[!importProfileForm.field_mappings.source_key?.trim(), "Map a stable source-key column."]
);
const tabularPreviewReason = disabledReason(
[saving, savingReason],
[!selectedBook, "Select an address book before importing."],
[!selectedImportProfileId, "Select or create a mapping profile."],
[!importFile, "Select a CSV or XLSX file."]
);
const tabularApplyReason = disabledReason(
[saving, savingReason],
[!importRun, "Preview the import first."],
[!importRun?.can_apply, "Resolve all import diagnostics before applying."],
[importRun?.status !== "previewed", "This import plan is no longer pending."]
);
const connectLdapReason = disabledReason(
[!selectedBook, "Select an address book before connecting LDAP."],
[!canWriteSync, "You need permission to manage address sync."],
[Boolean(selectedBook?.deleted_at), "Restore this address book before connecting sync."],
[saving, savingReason]
);
const addContactRowReason = disabledReason([saving, savingReason]);
const removeEmailRowReason = disabledReason(
[saving, savingReason],
@@ -1990,6 +2179,154 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}
}
function openImportDialog() {
setImportMode("vcard");
setImportRun(null);
setImportFile(null);
setCreatingImportProfile(false);
setEditingImportProfileId("");
setImportProfileForm(EMPTY_IMPORT_PROFILE_FORM);
setImportOpen(true);
}
async function saveImportProfile() {
if (!selectedBook) return;
setSaving(true);
setError("");
try {
const fieldMappings = Object.fromEntries(
Object.entries(importProfileForm.field_mappings)
.map(([key, value]) => [key, value.trim()])
.filter(([, value]) => Boolean(value))
);
const configuration = {
field_mappings: fieldMappings,
delimiter: importProfileForm.delimiter,
encoding: importProfileForm.encoding,
header_row: Number(importProfileForm.header_row) || 1,
sheet_name: importProfileForm.source_format === "xlsx" ? importProfileForm.sheet_name.trim() || null : null,
source_key_column: null,
duplicate_source_key_policy: importProfileForm.duplicate_source_key_policy,
existing_contact_policy: importProfileForm.existing_contact_policy,
blank_value_policy: importProfileForm.blank_value_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
};
const profile = editingImportProfileId
? await updateAddressImportProfile(settings, editingImportProfileId, {
name: importProfileForm.name.trim(),
configuration
})
: await createAddressImportProfile(settings, {
scope_type: selectedBook.scope_type,
scope_id: selectedBook.scope_id ?? null,
name: importProfileForm.name.trim(),
source_format: importProfileForm.source_format,
configuration
});
setImportProfiles((current) => [
...current.filter((item) => item.profile_key !== profile.profile_key),
profile
].sort((left, right) => left.name.localeCompare(right.name)));
setSelectedImportProfileId(profile.id);
setCreatingImportProfile(false);
setEditingImportProfileId("");
setImportRun(null);
setNotice(`Saved import mapping "${profile.name}" version ${profile.version}.`);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
function startNewImportProfile() {
setEditingImportProfileId("");
setImportProfileForm(EMPTY_IMPORT_PROFILE_FORM);
setCreatingImportProfile(true);
setImportRun(null);
}
function editSelectedImportProfile() {
const profile = importProfiles.find((item) => item.id === selectedImportProfileId);
if (!profile) return;
const config = profile.configuration;
setEditingImportProfileId(profile.id);
setImportProfileForm({
name: profile.name,
source_format: profile.source_format,
delimiter: config.delimiter,
encoding: config.encoding,
header_row: String(config.header_row),
sheet_name: config.sheet_name ?? "",
duplicate_source_key_policy: config.duplicate_source_key_policy,
existing_contact_policy: config.existing_contact_policy,
blank_value_policy: config.blank_value_policy,
locale: config.locale ?? "",
default_tags: config.default_tags.join(", "),
max_rows: String(config.max_rows),
field_mappings: { ...config.field_mappings }
});
setCreatingImportProfile(true);
setImportRun(null);
}
function cancelImportProfileEditor() {
setCreatingImportProfile(false);
setEditingImportProfileId("");
setImportProfileForm(EMPTY_IMPORT_PROFILE_FORM);
}
function downloadImportCorrections() {
if (!importRun) return;
const quote = (value: unknown) => `"${String(value ?? "").replaceAll('"', '""')}"`;
const lines = [
["severity", "row", "field", "code", "message"].map(quote).join(","),
...importRun.diagnostics.map((item) => [item.severity, item.row_number ?? "", item.field ?? "", item.code, item.message].map(quote).join(","))
];
downloadText(`${importRun.source_filename}.corrections.csv`, lines.join("\r\n"), "text/csv;charset=utf-8");
}
async function previewTabularImport() {
if (!selectedBook || !importFile || !selectedImportProfileId) return;
setSaving(true);
setError("");
setNotice("");
try {
const content = await fileAsBase64(importFile);
const run = await previewAddressImport(settings, selectedBook.id, {
profile_id: selectedImportProfileId,
filename: importFile.name,
content_base64: content
});
setImportRun(run);
setNotice(`Previewed ${run.row_count} row${run.row_count === 1 ? "" : "s"}.`);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function applyTabularImport() {
if (!selectedBook || !importRun) return;
setSaving(true);
setError("");
setNotice("");
try {
const run = await applyAddressImport(settings, importRun);
setImportRun(run);
setNotice(`Applied import plan: ${run.statistics.create ?? 0} created, ${run.statistics.update ?? 0} updated.`);
await refreshBooks();
await refreshContacts(selectedBook.id, query);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
function openCardDavDialog() {
setCardDavForm(EMPTY_CARDDAV_FORM);
setCardDavDiscovery([]);
@@ -2065,6 +2402,72 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}
}
function openLdapDialog() {
setLdapForm({ ...EMPTY_LDAP_FORM, attribute_map: { ...DEFAULT_LDAP_ATTRIBUTE_MAP } });
setLdapBaseDns([]);
setCardDavCredentialsError("");
setLdapOpen(true);
}
function ldapConnectionPayload() {
return {
url: ldapForm.url.trim(),
credential_ref: ldapForm.credential_envelope_id ? `credential-envelope:${ldapForm.credential_envelope_id}` : null,
bind_dn: ldapForm.bind_dn.trim() || null,
start_tls: ldapForm.start_tls,
connect_timeout: 10,
receive_timeout: 30
};
}
async function discoverLdapSources() {
setSaving(true);
setError("");
setNotice("");
try {
const baseDns = await discoverLdapBaseDns(settings, ldapConnectionPayload());
setLdapBaseDns(baseDns);
if (!ldapForm.base_dn && baseDns[0]) setLdapForm((current) => ({ ...current, base_dn: baseDns[0] }));
setNotice(`Found ${baseDns.length} LDAP base DN${baseDns.length === 1 ? "" : "s"}.`);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function submitLdapSource(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedBook) return;
setSaving(true);
setError("");
setNotice("");
try {
const attributeMap = Object.fromEntries(
Object.entries(ldapForm.attribute_map)
.map(([key, value]) => [key, value.trim()])
.filter(([, value]) => Boolean(value))
);
await createLdapSyncSource(settings, selectedBook.id, {
...ldapConnectionPayload(),
display_name: ldapForm.display_name.trim() || "LDAP directory",
base_dn: ldapForm.base_dn.trim(),
search_filter: ldapForm.search_filter.trim(),
page_size: Number(ldapForm.page_size) || 500,
max_entries: Number(ldapForm.max_entries) || 10000,
attribute_map: attributeMap
});
setLdapOpen(false);
setLdapBaseDns([]);
setNotice("LDAP / Active Directory source connected.");
await refreshBooks();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function loadSyncDetails(source: AddressSyncSource) {
const [diagnostics, tombstones, conflicts] = await Promise.all([
listAddressSyncDiagnostics(settings, source.id),
@@ -2261,9 +2664,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="button" title="Review address quality" aria-label="Review address quality" onClick={() => void openQualityReview()} disabledReason={qualityDashboardReason}><ShieldCheck size={15} /></Button>
<Button type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></Button>
<Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
<Button type="button" title="Import vCard into selected address book" aria-label="Import vCard into selected address book" onClick={() => setImportOpen(true)} disabledReason={importBookReason}><Upload size={15} /></Button>
<Button type="button" title="Import contacts" aria-label="Import contacts" onClick={openImportDialog} disabledReason={importBookReason}><Upload size={15} /></Button>
<Button type="button" title="Export selected address book as vCard" aria-label="Export selected address book as vCard" onClick={() => void exportSelectedBook()} disabledReason={exportBookReason}><Download size={15} /></Button>
<Button type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></Button>
<Button type="button" title="Connect LDAP or Active Directory" aria-label="Connect LDAP or Active Directory" onClick={openLdapDialog} disabledReason={connectLdapReason}><Network size={15} /></Button>
<Button type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></Button>
<Button type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</Button>
<Button type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</Button>
@@ -3167,28 +3571,155 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Dialog
open={importOpen}
title="Import vCard"
title="Import contacts"
onClose={() => setImportOpen(false)}
closeDisabled={saving}
className="address-import-dialog"
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>
{importMode === "vcard" &&
<Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>}
{importMode === "tabular" && creatingImportProfile &&
<Button type="button" variant="primary" onClick={() => void saveImportProfile()} disabledReason={importProfileSaveReason}><Save size={16} /> {editingImportProfileId ? "Save new version" : "Save mapping"}</Button>}
{importMode === "tabular" && !creatingImportProfile &&
<Button type="button" onClick={() => void previewTabularImport()} disabledReason={tabularPreviewReason}><Search size={16} /> Preview</Button>}
{importMode === "tabular" && !creatingImportProfile && importRun &&
<Button type="button" variant="primary" onClick={() => void applyTabularImport()} disabledReason={tabularApplyReason}><Upload size={16} /> Apply import</Button>}
</>
}>
<form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
<p className="muted">Paste one or more vCard entries into the selected address book.</p>
<FormField label="vCard content">
<textarea
className="address-vcard-textarea"
value={vcardContent}
onChange={(event) => setVcardContent(event.target.value)}
rows={14}
placeholder={"BEGIN:VCARD\nVERSION:4.0\nFN:Ada Lovelace\nEMAIL;TYPE=work:ada@example.local\nEND:VCARD"}
/>
</FormField>
</form>
<div className="address-dialog-form">
<SegmentedControl<ImportMode>
role="group"
size="equal"
ariaLabel="Contact import format"
options={[{ id: "vcard", label: "vCard" }, { id: "tabular", label: "CSV / XLSX" }]}
value={importMode}
onChange={(mode) => { setImportMode(mode); setImportRun(null); }}
/>
{importMode === "vcard" &&
<form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
<p className="muted">Paste one or more vCard entries into the selected address book.</p>
<FormField label="vCard content">
<textarea
className="address-vcard-textarea"
value={vcardContent}
onChange={(event) => setVcardContent(event.target.value)}
rows={14}
placeholder={"BEGIN:VCARD\nVERSION:4.0\nFN:Ada Lovelace\nEMAIL;TYPE=work:ada@example.local\nEND:VCARD"}
/>
</FormField>
</form>}
{importMode === "tabular" &&
<div className="address-import-workspace">
<div className="form-grid two">
<FormField label="Mapping profile">
<select
value={selectedImportProfileId}
disabled={creatingImportProfile}
onChange={(event) => { setSelectedImportProfileId(event.target.value); setImportRun(null); }}>
<option value="">Select a saved mapping</option>
{importProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} · {profile.source_format.toUpperCase()} · v{profile.version}</option>)}
</select>
</FormField>
<div className="button-row address-import-profile-actions">
{!creatingImportProfile && selectedImportProfileId &&
<Button type="button" title="Edit mapping profile" aria-label="Edit mapping profile" onClick={editSelectedImportProfile} disabledReason={savingReason}><Edit3 size={15} /></Button>}
<Button
type="button"
onClick={creatingImportProfile ? cancelImportProfileEditor : startNewImportProfile}
disabledReason={savingReason}>
{creatingImportProfile ? <X size={15} /> : <Plus size={15} />}
{creatingImportProfile ? "Cancel editor" : "New mapping"}
</Button>
</div>
</div>
{creatingImportProfile ?
<div className="address-import-profile-editor">
<div className="form-grid two">
<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" }))}>
<option value="csv">CSV</option>
<option value="xlsx">XLSX</option>
</select>
</FormField>
{importProfileForm.source_format === "csv" && <>
<FormField label="Delimiter">
<select value={importProfileForm.delimiter} onChange={(event) => setImportProfileForm((current) => ({ ...current, delimiter: event.target.value as ImportProfileFormState["delimiter"] }))}>
<option value=";">Semicolon</option><option value=",">Comma</option><option value="\t">Tab</option><option value="|">Pipe</option>
</select>
</FormField>
<FormField label="Encoding">
<select value={importProfileForm.encoding} onChange={(event) => setImportProfileForm((current) => ({ ...current, encoding: event.target.value as ImportProfileFormState["encoding"] }))}>
<option value="utf-8-sig">UTF-8</option><option value="cp1252">Windows-1252</option><option value="latin-1">Latin-1</option>
</select>
</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>
<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>
</select>
</FormField>
<FormField label="Existing contacts">
<select value={importProfileForm.existing_contact_policy} onChange={(event) => setImportProfileForm((current) => ({ ...current, existing_contact_policy: event.target.value as ImportProfileFormState["existing_contact_policy"] }))}>
<option value="update">Update</option><option value="ignore">Keep unchanged</option><option value="reject">Reject row</option>
</select>
</FormField>
<FormField label="Blank values">
<select value={importProfileForm.blank_value_policy} onChange={(event) => setImportProfileForm((current) => ({ ...current, blank_value_policy: event.target.value as ImportProfileFormState["blank_value_policy"] }))}>
<option value="ignore">Keep existing value</option><option value="clear">Clear mapped field</option><option value="reject">Reject row</option>
</select>
</FormField>
<FormField label="Locale"><input value={importProfileForm.locale} onChange={(event) => setImportProfileForm((current) => ({ ...current, locale: event.target.value }))} placeholder="de-DE" /></FormField>
<FormField label="Default tags"><input value={importProfileForm.default_tags} onChange={(event) => setImportProfileForm((current) => ({ ...current, default_tags: event.target.value }))} placeholder="monthly, imported" /></FormField>
<FormField label="Maximum rows"><input type="number" min="1" max="10000" value={importProfileForm.max_rows} onChange={(event) => setImportProfileForm((current) => ({ ...current, max_rows: event.target.value }))} /></FormField>
</div>
<div className="address-import-mapping-grid">
{IMPORT_MAPPING_FIELDS.map(([target, label]) =>
<FormField label={label} key={target}>
<input
value={importProfileForm.field_mappings[target] ?? ""}
onChange={(event) => setImportProfileForm((current) => ({ ...current, field_mappings: { ...current.field_mappings, [target]: event.target.value } }))}
placeholder="Source column"
/>
</FormField>)}
</div>
</div> :
<>
<FormField label="Import file">
<input
type="file"
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); setImportRun(null); }}
/>
</FormField>
{importRun &&
<div className="address-import-preview">
<div className="address-sync-plan-grid">
{(["create", "update", "unchanged", "ignored", "conflict", "errors"] as const).map((key) =>
<div key={key}><strong>{importRun.statistics[key] ?? 0}</strong><small>{key}</small></div>)}
</div>
{importRun.diagnostics.map((diagnostic, index) =>
<DismissibleAlert key={`${diagnostic.code}-${diagnostic.row_number ?? index}`} tone={diagnostic.severity === "error" ? "danger" : diagnostic.severity === "warning" ? "warning" : "info"}>
{diagnostic.row_number ? `Row ${diagnostic.row_number}: ` : ""}{diagnostic.message}
</DismissibleAlert>)}
{importRun.diagnostics.length > 0 &&
<div className="button-row"><Button type="button" onClick={downloadImportCorrections}><Download size={15} /> Download corrections</Button></div>}
<div className="address-sync-result-list">
{importRun.effects.map((effect) =>
<div className="address-sync-plan-row" key={`${effect.row_number}-${effect.source_key ?? "row"}`}>
<StatusBadge status={effect.action} />
<span><strong>Row {effect.row_number} · {effect.display_name || effect.source_key || "Unnamed contact"}</strong><small>{effect.changed_fields.join(", ") || effect.message || "No field changes"}</small></span>
</div>)}
</div>
</div>}
</>}
</div>}
</div>
</Dialog>
<Dialog
@@ -3291,6 +3822,73 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</form>
</Dialog>
<Dialog
open={ldapOpen}
title="Connect LDAP / Active Directory"
onClose={() => setLdapOpen(false)}
closeDisabled={saving}
className="address-sync-dialog address-ldap-dialog"
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setLdapOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="button" onClick={() => void discoverLdapSources()} disabledReason={disabledReason([saving, savingReason], [!ldapForm.url.trim(), "Enter an LDAP URL before discovery."])}><Search size={16} /> Discover</Button>
<Button type="submit" form="address-ldap-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!ldapForm.url.trim(), "Enter an LDAP URL."], [!ldapForm.base_dn.trim(), "Select or enter a base DN."], [!ldapForm.attribute_map.source_key?.trim(), "Map a stable source-key attribute."])}><Save size={16} /> Connect</Button>
</>
}>
<form id="address-ldap-form" className="address-dialog-form" onSubmit={(event) => void submitLdapSource(event)}>
<div className="form-grid two">
<FormField label="Directory URL">
<input value={ldapForm.url} onChange={(event) => setLdapForm((current) => ({ ...current, url: event.target.value }))} placeholder="ldaps://directory.example.org" autoFocus />
</FormField>
<FormField label="Display name">
<input value={ldapForm.display_name} onChange={(event) => setLdapForm((current) => ({ ...current, display_name: event.target.value }))} placeholder="Corporate directory" />
</FormField>
<FormField label="Reusable credential">
<select
value={ldapForm.credential_envelope_id}
onChange={(event) => {
const credentialId = event.target.value;
const credential = cardDavCredentials.find((item) => item.id === credentialId);
const username = credential?.public_data?.username ?? credential?.public_data?.bind_dn;
setLdapForm((current) => ({ ...current, credential_envelope_id: credentialId, bind_dn: credentialId && username ? String(username) : current.bind_dn }));
}}>
<option value="">Anonymous bind</option>
{cardDavCredentials.map((credential) => <option key={credential.id} value={credential.id}>{credential.name}</option>)}
</select>
</FormField>
<FormField label="Bind DN / username">
<input value={ldapForm.bind_dn} onChange={(event) => setLdapForm((current) => ({ ...current, bind_dn: event.target.value }))} />
</FormField>
</div>
<ToggleSwitch
label="Require StartTLS for ldap://"
checked={ldapForm.start_tls}
onChange={() => setLdapForm((current) => ({ ...current, start_tls: !current.start_tls }))}
help="LDAPS always uses TLS. Plain ldap:// endpoints are accepted only when StartTLS is enabled."
/>
{cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>}
<div className="form-grid two">
<FormField label="Base DN">
<input list="address-ldap-base-dns" value={ldapForm.base_dn} onChange={(event) => setLdapForm((current) => ({ ...current, base_dn: event.target.value }))} placeholder="ou=people,dc=example,dc=org" />
<datalist id="address-ldap-base-dns">{ldapBaseDns.map((baseDn) => <option value={baseDn} key={baseDn} />)}</datalist>
</FormField>
<FormField label="LDAP filter">
<input value={ldapForm.search_filter} onChange={(event) => setLdapForm((current) => ({ ...current, search_filter: event.target.value }))} />
</FormField>
<FormField label="Page size"><input type="number" min="1" max="1000" value={ldapForm.page_size} onChange={(event) => setLdapForm((current) => ({ ...current, page_size: event.target.value }))} /></FormField>
<FormField label="Maximum entries"><input type="number" min="1" max="10000" value={ldapForm.max_entries} onChange={(event) => setLdapForm((current) => ({ ...current, max_entries: event.target.value }))} /></FormField>
</div>
<div className="address-import-mapping-grid">
{LDAP_MAPPING_FIELDS.map(([target, label]) =>
<FormField label={label} key={target}>
<input value={ldapForm.attribute_map[target] ?? ""} onChange={(event) => setLdapForm((current) => ({ ...current, attribute_map: { ...current.attribute_map, [target]: event.target.value } }))} placeholder="LDAP attribute" />
</FormField>)}
</div>
<p className="muted">The directory remains authoritative and read-only. Preview the first refresh before applying it.</p>
</form>
</Dialog>
<Dialog
open={Boolean(syncInspector)}
title={syncInspector ? `Sync: ${syncSourceLabel(syncInspector.source)}` : "Sync"}
+64
View File
@@ -150,6 +150,60 @@
width: min(960px, calc(100vw - 36px));
}
.address-import-dialog {
max-width: min(1080px, calc(100vw - 36px));
width: min(1080px, calc(100vw - 36px));
}
.address-import-workspace,
.address-import-profile-editor,
.address-import-preview {
display: grid;
gap: 14px;
min-height: 0;
}
.address-import-profile-actions {
align-items: end;
justify-content: flex-start;
padding-bottom: 1px;
}
.address-import-mapping-grid {
display: grid;
gap: 10px 14px;
grid-template-columns: repeat(3, minmax(0, 1fr));
max-height: min(42vh, 440px);
overflow: auto;
padding-right: 4px;
}
.address-import-preview > .address-sync-plan-grid {
border: 0;
gap: 8px;
grid-template-columns: repeat(6, minmax(0, 1fr));
max-height: none;
overflow: visible;
}
.address-import-preview > .address-sync-plan-grid > div {
background: var(--panel-soft);
border: var(--border-line);
border-radius: 6px;
display: grid;
gap: 2px;
padding: 9px 10px;
}
.address-import-preview > .address-sync-plan-grid strong {
font-size: 1.05rem;
}
.address-import-preview > .address-sync-plan-grid small {
color: var(--muted);
text-transform: capitalize;
}
.address-sync-record-list,
.address-sync-plan-grid {
display: grid;
@@ -532,6 +586,16 @@
min-height: 280px;
}
@media (max-width: 760px) {
.address-import-mapping-grid {
grid-template-columns: minmax(0, 1fr);
}
.address-import-preview > .address-sync-plan-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.address-form-section {
border: var(--border-line);
border-radius: 6px;