Add governed tabular and LDAP address sources
This commit is contained in:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user