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