|
|
|
@@ -1,6 +1,8 @@
|
|
|
|
|
import { MetricGrid } from "@govoplan/core-webui";
|
|
|
|
|
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 {
|
|
|
|
|
import { useSearchParams } from "react-router";
|
|
|
|
|
import { DialogSection, DialogForm, FormGrid, ActionToolbar,
|
|
|
|
|
ApiError,
|
|
|
|
|
ActionBlockerHint,
|
|
|
|
|
Button,
|
|
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
|
|
fetchAuthGroups,
|
|
|
|
|
formatDateTime,
|
|
|
|
|
FormField,
|
|
|
|
|
FormSection,
|
|
|
|
|
LoadingFrame,
|
|
|
|
|
MetricCard,
|
|
|
|
|
PasswordField,
|
|
|
|
@@ -49,6 +52,7 @@ import {
|
|
|
|
|
endContactChannelRule,
|
|
|
|
|
exportAddressBookVcards,
|
|
|
|
|
exportContactVcard,
|
|
|
|
|
getAddressImportRun,
|
|
|
|
|
importAddressBookVcards,
|
|
|
|
|
applyAddressImport,
|
|
|
|
|
getAddressQualitySummary,
|
|
|
|
@@ -69,6 +73,7 @@ import {
|
|
|
|
|
listContactProvenance,
|
|
|
|
|
previewAddressSyncSource,
|
|
|
|
|
previewAddressImport,
|
|
|
|
|
rollbackAddressImport,
|
|
|
|
|
mergeContacts,
|
|
|
|
|
recoverContactMerge,
|
|
|
|
|
restoreAddressBook,
|
|
|
|
@@ -112,6 +117,12 @@ import {
|
|
|
|
|
ADDRESSES_DOCUMENTATION,
|
|
|
|
|
ADDRESSES_I18N
|
|
|
|
|
} from "./interfacePatterns";
|
|
|
|
|
import {
|
|
|
|
|
importRunIdFromSearch,
|
|
|
|
|
importRunLifecycle,
|
|
|
|
|
unavailableImportRunMessage,
|
|
|
|
|
withImportRunSearch
|
|
|
|
|
} from "./importRunState";
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
settings: ApiSettings;
|
|
|
|
@@ -920,6 +931,8 @@ function formKey(value: unknown): string {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
|
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
|
const requestedImportRunId = importRunIdFromSearch(searchParams);
|
|
|
|
|
const [books, setBooks] = useState<AddressBook[]>([]);
|
|
|
|
|
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
|
|
|
|
|
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
|
|
|
|
@@ -978,6 +991,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
const [importProfileForm, setImportProfileForm] = useState<ImportProfileFormState>(EMPTY_IMPORT_PROFILE_FORM);
|
|
|
|
|
const [importFile, setImportFile] = useState<File | null>(null);
|
|
|
|
|
const [importRun, setImportRun] = useState<AddressImportRun | null>(null);
|
|
|
|
|
const [importRunLoading, setImportRunLoading] = useState(false);
|
|
|
|
|
const [importRunUnavailable, setImportRunUnavailable] = useState("");
|
|
|
|
|
const [importRollbackOpen, setImportRollbackOpen] = useState(false);
|
|
|
|
|
const [importRollbackReason, setImportRollbackReason] = useState("");
|
|
|
|
|
const [cardDavOpen, setCardDavOpen] = useState(false);
|
|
|
|
|
const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM);
|
|
|
|
|
const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]);
|
|
|
|
@@ -1075,13 +1092,41 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
if (!active) return;
|
|
|
|
|
setImportProfiles(profiles);
|
|
|
|
|
setSelectedImportProfileId((current) => current || profiles[0]?.id || "");
|
|
|
|
|
setCreatingImportProfile(profiles.length === 0);
|
|
|
|
|
setCreatingImportProfile(profiles.length === 0 && !requestedImportRunId);
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
if (active) setError(errorMessage(err));
|
|
|
|
|
});
|
|
|
|
|
return () => { active = false; };
|
|
|
|
|
}, [importMode, importOpen, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
|
}, [importMode, importOpen, requestedImportRunId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!requestedImportRunId) return;
|
|
|
|
|
let active = true;
|
|
|
|
|
setImportOpen(true);
|
|
|
|
|
setImportMode("tabular");
|
|
|
|
|
setCreatingImportProfile(false);
|
|
|
|
|
setImportRunLoading(true);
|
|
|
|
|
setImportRunUnavailable("");
|
|
|
|
|
getAddressImportRun(settings, requestedImportRunId)
|
|
|
|
|
.then((run) => {
|
|
|
|
|
if (!active) return;
|
|
|
|
|
setImportRun(run);
|
|
|
|
|
setSelectedBookId(run.address_book_id);
|
|
|
|
|
setSelectedImportProfileId(run.profile_id);
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
if (!active) return;
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
setImportRunUnavailable(
|
|
|
|
|
unavailableImportRunMessage(err instanceof ApiError ? err.status : undefined)
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
.finally(() => {
|
|
|
|
|
if (active) setImportRunLoading(false);
|
|
|
|
|
});
|
|
|
|
|
return () => { active = false; };
|
|
|
|
|
}, [requestedImportRunId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (auth.groups_loaded || !onAuthChange) return;
|
|
|
|
@@ -1093,6 +1138,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
}, [auth.groups_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
|
|
|
|
|
|
const selectedBook = books.find((book) => book.id === selectedBookId) ?? books[0] ?? null;
|
|
|
|
|
const importLifecycle = importRun ? importRunLifecycle(importRun.status) : null;
|
|
|
|
|
const selectedList = addressLists.find((list) => list.id === selectedListId) ?? null;
|
|
|
|
|
const selectedBookSyncSources = useMemo(
|
|
|
|
|
() => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [],
|
|
|
|
@@ -1259,7 +1305,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
[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."]
|
|
|
|
|
[!importLifecycle?.canApply, "This import plan is no longer pending."]
|
|
|
|
|
);
|
|
|
|
|
const tabularRollbackReason = disabledReason(
|
|
|
|
|
[saving, savingReason],
|
|
|
|
|
[!importRun, "Load an applied import run before rolling it back."],
|
|
|
|
|
[!importLifecycle?.canRollback, "Only an applied import run can be rolled back."],
|
|
|
|
|
[importRollbackReason.trim().length < 3, "Record why this import is being rolled back."]
|
|
|
|
|
);
|
|
|
|
|
const connectLdapReason = disabledReason(
|
|
|
|
|
[!selectedBook, "Select an address book before connecting LDAP."],
|
|
|
|
@@ -2261,8 +2313,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openImportDialog() {
|
|
|
|
|
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
|
|
|
|
|
setImportMode("vcard");
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
setImportRunUnavailable("");
|
|
|
|
|
setImportFile(null);
|
|
|
|
|
setCreatingImportProfile(false);
|
|
|
|
|
setEditingImportProfileId("");
|
|
|
|
@@ -2270,6 +2324,46 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
setImportOpen(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeImportDialog() {
|
|
|
|
|
setImportOpen(false);
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
setImportRunUnavailable("");
|
|
|
|
|
setImportRollbackOpen(false);
|
|
|
|
|
setImportRollbackReason("");
|
|
|
|
|
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function retainImportRun(run: AddressImportRun) {
|
|
|
|
|
setImportRun(run);
|
|
|
|
|
setImportRunUnavailable("");
|
|
|
|
|
setSearchParams(withImportRunSearch(searchParams, run.id), { replace: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearRetainedImportRun() {
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
setImportRunUnavailable("");
|
|
|
|
|
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function reloadImportRun() {
|
|
|
|
|
if (!importRun?.id && !requestedImportRunId) return;
|
|
|
|
|
setImportRunLoading(true);
|
|
|
|
|
setImportRunUnavailable("");
|
|
|
|
|
try {
|
|
|
|
|
const run = await getAddressImportRun(settings, importRun?.id || requestedImportRunId);
|
|
|
|
|
retainImportRun(run);
|
|
|
|
|
setSelectedBookId(run.address_book_id);
|
|
|
|
|
setSelectedImportProfileId(run.profile_id);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
setImportRunUnavailable(
|
|
|
|
|
unavailableImportRunMessage(err instanceof ApiError ? err.status : undefined)
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
setImportRunLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveImportProfile() {
|
|
|
|
|
if (!selectedBook) return;
|
|
|
|
|
setSaving(true);
|
|
|
|
@@ -2313,7 +2407,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
setSelectedImportProfileId(profile.id);
|
|
|
|
|
setCreatingImportProfile(false);
|
|
|
|
|
setEditingImportProfileId("");
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
clearRetainedImportRun();
|
|
|
|
|
setNotice(`Saved import mapping "${profile.name}" version ${profile.version}.`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(errorMessage(err));
|
|
|
|
@@ -2326,7 +2420,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
setEditingImportProfileId("");
|
|
|
|
|
setImportProfileForm(EMPTY_IMPORT_PROFILE_FORM);
|
|
|
|
|
setCreatingImportProfile(true);
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
clearRetainedImportRun();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function editSelectedImportProfile() {
|
|
|
|
@@ -2350,7 +2444,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
field_mappings: { ...config.field_mappings }
|
|
|
|
|
});
|
|
|
|
|
setCreatingImportProfile(true);
|
|
|
|
|
setImportRun(null);
|
|
|
|
|
clearRetainedImportRun();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelImportProfileEditor() {
|
|
|
|
@@ -2381,7 +2475,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
filename: importFile.name,
|
|
|
|
|
content_base64: content
|
|
|
|
|
});
|
|
|
|
|
setImportRun(run);
|
|
|
|
|
retainImportRun(run);
|
|
|
|
|
setNotice(`Previewed ${run.row_count} row${run.row_count === 1 ? "" : "s"}.`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(errorMessage(err));
|
|
|
|
@@ -2397,7 +2491,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
setNotice("");
|
|
|
|
|
try {
|
|
|
|
|
const run = await applyAddressImport(settings, importRun);
|
|
|
|
|
setImportRun(run);
|
|
|
|
|
retainImportRun(run);
|
|
|
|
|
setNotice(`Applied import plan: ${run.statistics.create ?? 0} created, ${run.statistics.update ?? 0} updated.`);
|
|
|
|
|
await refreshBooks();
|
|
|
|
|
await refreshContacts(selectedBook.id, query);
|
|
|
|
@@ -2408,6 +2502,26 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function rollbackTabularImport() {
|
|
|
|
|
if (!importRun) return;
|
|
|
|
|
setSaving(true);
|
|
|
|
|
setError("");
|
|
|
|
|
setNotice("");
|
|
|
|
|
try {
|
|
|
|
|
const run = await rollbackAddressImport(settings, importRun, importRollbackReason.trim());
|
|
|
|
|
retainImportRun(run);
|
|
|
|
|
setImportRollbackOpen(false);
|
|
|
|
|
setImportRollbackReason("");
|
|
|
|
|
setNotice("Rolled back the persisted import run.");
|
|
|
|
|
await refreshBooks();
|
|
|
|
|
await refreshContacts(run.address_book_id, query);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(errorMessage(err));
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openCardDavDialog() {
|
|
|
|
|
setCardDavForm(EMPTY_CARDDAV_FORM);
|
|
|
|
|
setCardDavDiscovery([]);
|
|
|
|
@@ -3059,7 +3173,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
<div className="address-contact-toolbar">
|
|
|
|
|
<ActionToolbar className="address-contact-toolbar">
|
|
|
|
|
<input
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(event) => {
|
|
|
|
@@ -3069,7 +3183,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
placeholder="Search contacts" />
|
|
|
|
|
{selectedBook?.read_only && <StatusBadge status="read-only" />}
|
|
|
|
|
{selectedBook?.deleted_at && <StatusBadge status="archived" />}
|
|
|
|
|
</div>
|
|
|
|
|
</ActionToolbar>
|
|
|
|
|
<div className="address-contact-list">
|
|
|
|
|
{visibleContacts.length === 0 ?
|
|
|
|
|
<div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> :
|
|
|
|
@@ -3111,8 +3225,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Button type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</Button>
|
|
|
|
|
</>
|
|
|
|
|
}>
|
|
|
|
|
<form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
|
|
|
|
|
<div className="form-grid two">
|
|
|
|
|
<DialogForm id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
|
|
|
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Scope" documentation={ADDRESS_FIELDS_DOCUMENTATION}>
|
|
|
|
|
<select value={bookForm.scope_type} disabled={bookDialog?.mode === "edit"} onChange={(event) => setBookForm((current) => ({ ...current, scope_type: event.target.value as AddressBookScope }))}>
|
|
|
|
|
<option value="user">Personal</option>
|
|
|
|
@@ -3129,10 +3243,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
</select>
|
|
|
|
|
</FormField>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
</FormGrid>
|
|
|
|
|
<FormField label="Name" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={bookForm.name} onChange={(event) => setBookForm((current) => ({ ...current, name: event.target.value }))} autoFocus /></FormField>
|
|
|
|
|
<FormField label="Description"><textarea value={bookForm.description} onChange={(event) => setBookForm((current) => ({ ...current, description: event.target.value }))} rows={3} /></FormField>
|
|
|
|
|
</form>
|
|
|
|
|
</DialogForm>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
@@ -3147,7 +3261,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Button type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</Button>
|
|
|
|
|
</>
|
|
|
|
|
}>
|
|
|
|
|
<form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
|
|
|
|
|
<DialogForm id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
|
|
|
|
|
<p className="muted">Lists group contacts inside the selected address book.</p>
|
|
|
|
|
<FormField label="Address book" documentation={ADDRESS_FIELDS_DOCUMENTATION}>
|
|
|
|
|
<input value={selectedBook?.name ?? ""} disabled readOnly />
|
|
|
|
@@ -3158,7 +3272,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<FormField label="Description">
|
|
|
|
|
<textarea value={listForm.description} onChange={(event) => setListForm((current) => ({ ...current, description: event.target.value }))} rows={3} />
|
|
|
|
|
</FormField>
|
|
|
|
|
</form>
|
|
|
|
|
</DialogForm>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
@@ -3174,22 +3288,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Button type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</Button>
|
|
|
|
|
</>
|
|
|
|
|
}>
|
|
|
|
|
<form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
|
|
|
|
|
<div className="form-grid three">
|
|
|
|
|
<DialogForm id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
|
|
|
|
|
<FormGrid columns={3} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Display name" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={contactForm.display_name} onChange={(event) => setContactForm((current) => ({ ...current, display_name: event.target.value }))} autoFocus /></FormField>
|
|
|
|
|
<FormField label="Given name"><input value={contactForm.given_name} onChange={(event) => setContactForm((current) => ({ ...current, given_name: event.target.value }))} /></FormField>
|
|
|
|
|
<FormField label="Family name"><input value={contactForm.family_name} onChange={(event) => setContactForm((current) => ({ ...current, family_name: event.target.value }))} /></FormField>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="form-grid two">
|
|
|
|
|
</FormGrid>
|
|
|
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Organization" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={contactForm.organization} onChange={(event) => setContactForm((current) => ({ ...current, organization: event.target.value }))} /></FormField>
|
|
|
|
|
<FormField label="Role title"><input value={contactForm.role_title} onChange={(event) => setContactForm((current) => ({ ...current, role_title: event.target.value }))} /></FormField>
|
|
|
|
|
</div>
|
|
|
|
|
<section className="address-form-section">
|
|
|
|
|
<div className="address-form-section-heading">
|
|
|
|
|
<strong>Email addresses</strong>
|
|
|
|
|
<Button type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="address-form-list">
|
|
|
|
|
</FormGrid>
|
|
|
|
|
<FormSection
|
|
|
|
|
className="address-form-section"
|
|
|
|
|
title="Email addresses"
|
|
|
|
|
actions={<Button type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</Button>}
|
|
|
|
|
contentClassName="address-form-list">
|
|
|
|
|
{contactForm.emails.map((email) => (
|
|
|
|
|
<div className="address-form-row address-form-row-email" key={email.rowId}>
|
|
|
|
|
<label className="address-primary-choice"><input type="radio" name="address-primary-email" checked={email.is_primary} onChange={() => setPrimaryEmailRow(email.rowId)} /> Primary</label>
|
|
|
|
@@ -3198,15 +3311,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Button type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</FormSection>
|
|
|
|
|
|
|
|
|
|
<section className="address-form-section">
|
|
|
|
|
<div className="address-form-section-heading">
|
|
|
|
|
<strong>Phone numbers</strong>
|
|
|
|
|
<Button type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="address-form-list">
|
|
|
|
|
<FormSection
|
|
|
|
|
className="address-form-section"
|
|
|
|
|
title="Phone numbers"
|
|
|
|
|
actions={<Button type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</Button>}
|
|
|
|
|
contentClassName="address-form-list">
|
|
|
|
|
{contactForm.phones.map((phone) => (
|
|
|
|
|
<div className="address-form-row address-form-row-phone" key={phone.rowId}>
|
|
|
|
|
<label className="address-primary-choice"><input type="radio" name="address-primary-phone" checked={phone.is_primary} onChange={() => setPrimaryPhoneRow(phone.rowId)} /> Primary</label>
|
|
|
|
@@ -3215,15 +3326,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Button type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</FormSection>
|
|
|
|
|
|
|
|
|
|
<section className="address-form-section">
|
|
|
|
|
<div className="address-form-section-heading">
|
|
|
|
|
<strong>Postal addresses</strong>
|
|
|
|
|
<Button type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="address-form-list">
|
|
|
|
|
<FormSection
|
|
|
|
|
className="address-form-section"
|
|
|
|
|
title="Postal addresses"
|
|
|
|
|
actions={<Button type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</Button>}
|
|
|
|
|
contentClassName="address-form-list">
|
|
|
|
|
{contactForm.postal_addresses.map((address) => (
|
|
|
|
|
<div className="address-form-row address-form-row-postal" key={address.rowId}>
|
|
|
|
|
<label className="address-primary-choice"><input type="radio" name="address-primary-postal" checked={address.is_primary} onChange={() => setPrimaryPostalAddressRow(address.rowId)} /> Primary</label>
|
|
|
|
@@ -3236,11 +3345,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Button type="button" variant="danger" title="Remove postal address" aria-label="Remove postal address" disabledReason={removePostalAddressRowReason} onClick={() => removePostalAddressRow(address.rowId)}><Trash2 size={15} /></Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</FormSection>
|
|
|
|
|
<FormField label="Tags"><input value={contactForm.tags} placeholder="Comma-separated tags" onChange={(event) => setContactForm((current) => ({ ...current, tags: event.target.value }))} /></FormField>
|
|
|
|
|
<FormField label="Note"><textarea rows={3} value={contactForm.note} onChange={(event) => setContactForm((current) => ({ ...current, note: event.target.value }))} /></FormField>
|
|
|
|
|
</form>
|
|
|
|
|
</DialogForm>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
@@ -3299,7 +3407,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{canWriteGovernance &&
|
|
|
|
|
<form className="address-dialog-form address-form-section" onSubmit={(event) => void submitChannelRule(event)}>
|
|
|
|
|
<DialogForm className="address-dialog-form address-form-section" onSubmit={(event) => void submitChannelRule(event)}>
|
|
|
|
|
<div className="address-form-section-heading">
|
|
|
|
|
<div>
|
|
|
|
|
<strong>Record a fact</strong>
|
|
|
|
@@ -3307,7 +3415,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
</div>
|
|
|
|
|
<Button type="submit" variant="primary" disabledReason={channelRuleSaveReason}><Plus size={15} /> Add</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="form-grid two">
|
|
|
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Channel" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
|
|
|
|
|
<select
|
|
|
|
|
value={channelRuleForm.channel}
|
|
|
|
@@ -3331,8 +3439,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
{governancePointOptions.map((item) => <option value={item.id} key={item.id}>{item.label}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</FormField>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="form-grid two">
|
|
|
|
|
</FormGrid>
|
|
|
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Decision" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
|
|
|
|
|
<select value={channelRuleForm.decision} onChange={(event) => setChannelRuleForm((current) => ({ ...current, decision: event.target.value as AddressChannelDecision }))}>
|
|
|
|
|
<option value="allowed">Allowed</option>
|
|
|
|
@@ -3348,19 +3456,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<FormField label="Communication purpose" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
|
|
|
|
|
<input value={channelRuleForm.purpose} placeholder="All purposes" onChange={(event) => setChannelRuleForm((current) => ({ ...current, purpose: event.target.value }))} />
|
|
|
|
|
</FormField>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="form-grid three">
|
|
|
|
|
</FormGrid>
|
|
|
|
|
<FormGrid columns={3} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Legal basis"><input value={channelRuleForm.legal_basis} onChange={(event) => setChannelRuleForm((current) => ({ ...current, legal_basis: event.target.value }))} /></FormField>
|
|
|
|
|
<FormField label="Evidence reference"><input value={channelRuleForm.evidence_ref} onChange={(event) => setChannelRuleForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField>
|
|
|
|
|
<FormField label="Locale"><input value={channelRuleForm.locale} placeholder="de-DE" onChange={(event) => setChannelRuleForm((current) => ({ ...current, locale: event.target.value }))} /></FormField>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="form-grid three">
|
|
|
|
|
</FormGrid>
|
|
|
|
|
<FormGrid columns={3} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Effective from"><input type="datetime-local" value={channelRuleForm.effective_from} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_from: event.target.value }))} /></FormField>
|
|
|
|
|
<FormField label="Effective until"><input type="datetime-local" value={channelRuleForm.effective_until} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_until: event.target.value }))} /></FormField>
|
|
|
|
|
<FormField label="Preference rank"><input type="number" min="0" max="10000" value={channelRuleForm.preference_rank} onChange={(event) => setChannelRuleForm((current) => ({ ...current, preference_rank: event.target.value }))} /></FormField>
|
|
|
|
|
</div>
|
|
|
|
|
</FormGrid>
|
|
|
|
|
<FormField label="Reason" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}><textarea rows={2} value={channelRuleForm.reason} onChange={(event) => setChannelRuleForm((current) => ({ ...current, reason: event.target.value }))} /></FormField>
|
|
|
|
|
</form>
|
|
|
|
|
</DialogForm>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
</Dialog>
|
|
|
|
@@ -3425,12 +3533,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
}>
|
|
|
|
|
<LoadingFrame loading={qualityLoading} label="Loading address quality...">
|
|
|
|
|
<div className="address-quality-layout">
|
|
|
|
|
{qualitySummary && <div className="metric-grid inside address-quality-metrics">
|
|
|
|
|
{qualitySummary && <MetricGrid spacing="inset" className="address-quality-metrics">
|
|
|
|
|
<MetricCard label="Contacts" value={qualitySummary.contact_count} tone="info" detail={`${qualitySummary.contact_point_count} contact points`} />
|
|
|
|
|
<MetricCard label="Corrections" value={qualitySummary.correction_count} tone={qualitySummary.correction_count > 0 ? "warning" : "good"} detail="Current non-valid states" />
|
|
|
|
|
<MetricCard label="Duplicates" value={qualitySummary.duplicate_suggestion_count} tone={qualitySummary.duplicate_suggestion_count > 0 ? "warning" : "good"} detail="Explainable suggestions" />
|
|
|
|
|
<MetricCard label="Undeliverable" value={(qualitySummary.quality_counts.undeliverable ?? 0) + (qualitySummary.quality_counts.returned ?? 0)} tone={(qualitySummary.quality_counts.undeliverable ?? 0) + (qualitySummary.quality_counts.returned ?? 0) > 0 ? "danger" : "good"} detail="Returned or undeliverable" />
|
|
|
|
|
</div>}
|
|
|
|
|
</MetricGrid>}
|
|
|
|
|
|
|
|
|
|
<section className="address-quality-section">
|
|
|
|
|
<div className="address-form-section-heading">
|
|
|
|
@@ -3531,9 +3639,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Button type="submit" form="address-quality-point-form" variant="primary" disabledReason={disabledReason([!qualityPointTarget, "Select a contact point."], [!canWriteGovernance, "You need permission to manage address quality."], [saving, savingReason])}><Save size={15} /> Record</Button>
|
|
|
|
|
</>
|
|
|
|
|
}>
|
|
|
|
|
<form id="address-quality-point-form" className="address-dialog-form" onSubmit={(event) => void submitQualityDecision(event)}>
|
|
|
|
|
<DialogForm id="address-quality-point-form" className="address-dialog-form" onSubmit={(event) => void submitQualityDecision(event)}>
|
|
|
|
|
<FormField label="Contact point"><input value={qualityPointTarget?.label ?? ""} readOnly disabled /></FormField>
|
|
|
|
|
<div className="form-grid two">
|
|
|
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Quality state">
|
|
|
|
|
<select value={qualityForm.state} onChange={(event) => setQualityForm((current) => ({ ...current, state: event.target.value as ContactPointQualityState }))}>
|
|
|
|
|
<option value="valid">Valid</option>
|
|
|
|
@@ -3544,10 +3652,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
</select>
|
|
|
|
|
</FormField>
|
|
|
|
|
<FormField label="Reason code"><input value={qualityForm.reason_code} placeholder={`addresses.quality.${qualityForm.state}`} onChange={(event) => setQualityForm((current) => ({ ...current, reason_code: event.target.value }))} /></FormField>
|
|
|
|
|
</div>
|
|
|
|
|
</FormGrid>
|
|
|
|
|
<FormField label="Reason"><textarea rows={3} value={qualityForm.reason} onChange={(event) => setQualityForm((current) => ({ ...current, reason: event.target.value }))} /></FormField>
|
|
|
|
|
<FormField label="Evidence reference"><input value={qualityForm.evidence_ref} placeholder="mail:delivery:..." onChange={(event) => setQualityForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField>
|
|
|
|
|
</form>
|
|
|
|
|
</DialogForm>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
@@ -3562,7 +3670,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Button type="submit" form="address-merge-form" variant="primary" disabledReason={disabledReason([!mergeDialog || mergeDialog.reason.trim().length < 3, "Record why these contacts are being merged."], [saving, savingReason])}><GitMerge size={15} /> Merge</Button>
|
|
|
|
|
</>
|
|
|
|
|
}>
|
|
|
|
|
{mergeDialog && <form id="address-merge-form" className="address-dialog-form" onSubmit={(event) => void submitContactMerge(event)}>
|
|
|
|
|
{mergeDialog && <DialogForm id="address-merge-form" className="address-dialog-form" onSubmit={(event) => void submitContactMerge(event)}>
|
|
|
|
|
<DismissibleAlert tone="warning" dismissible={false}>The other contact is archived and redirected to the survivor. Address-list memberships and matching contact points are repaired transactionally.</DismissibleAlert>
|
|
|
|
|
<FormField label="Surviving contact">
|
|
|
|
|
<select value={mergeDialog.winnerId} onChange={(event) => changeMergeWinner(event.target.value)}>
|
|
|
|
@@ -3570,7 +3678,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<option value={mergeDialog.suggestion.right.id}>{mergeDialog.suggestion.right.display_name} · {primaryEmail(mergeDialog.suggestion.right) || "no email"}</option>
|
|
|
|
|
</select>
|
|
|
|
|
</FormField>
|
|
|
|
|
<div className="form-grid two address-merge-field-sources">
|
|
|
|
|
<FormGrid columns={2} collapseAt="standard" className="address-merge-field-sources">
|
|
|
|
|
{MERGE_SCALAR_FIELDS.map((field) => (
|
|
|
|
|
<FormField label={`${field.label} source`} key={field.id}>
|
|
|
|
|
<select
|
|
|
|
@@ -3584,7 +3692,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
</select>
|
|
|
|
|
</FormField>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</FormGrid>
|
|
|
|
|
<FormField label="Contact points">
|
|
|
|
|
<SegmentedControl<"union" | "winner_only">
|
|
|
|
|
role="group"
|
|
|
|
@@ -3596,7 +3704,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
<FormField label="Reason"><textarea rows={3} value={mergeDialog.reason} onChange={(event) => setMergeDialog((current) => current ? { ...current, reason: event.target.value } : null)} /></FormField>
|
|
|
|
|
</form>}
|
|
|
|
|
</DialogForm>}
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
@@ -3611,10 +3719,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Button type="submit" form="address-merge-recovery-form" variant="primary" disabledReason={disabledReason([!mergeRecoveryDialog || mergeRecoveryDialog.reason.trim().length < 3, "Record why the merge is being recovered."], [saving, savingReason])}><RotateCcw size={15} /> {mergeRecoveryDialog?.action === "split" ? "Split" : "Undo"}</Button>
|
|
|
|
|
</>
|
|
|
|
|
}>
|
|
|
|
|
{mergeRecoveryDialog && <form id="address-merge-recovery-form" className="address-dialog-form" onSubmit={(event) => void submitMergeRecovery(event)}>
|
|
|
|
|
{mergeRecoveryDialog && <DialogForm id="address-merge-recovery-form" className="address-dialog-form" onSubmit={(event) => void submitMergeRecovery(event)}>
|
|
|
|
|
<p className="muted">Recovery restores the recorded pre-merge values and list memberships. It is rejected if either contact changed after the merge.</p>
|
|
|
|
|
<FormField label="Reason"><textarea rows={3} value={mergeRecoveryDialog.reason} onChange={(event) => setMergeRecoveryDialog((current) => current ? { ...current, reason: event.target.value } : null)} autoFocus /></FormField>
|
|
|
|
|
</form>}
|
|
|
|
|
</DialogForm>}
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
@@ -3626,9 +3734,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
footerClassName="button-row compact-actions"
|
|
|
|
|
footer={<Button type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</Button>}>
|
|
|
|
|
<div className="address-member-picker">
|
|
|
|
|
<div className="address-contact-toolbar address-member-search">
|
|
|
|
|
<ActionToolbar className="address-contact-toolbar address-member-search">
|
|
|
|
|
<input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus />
|
|
|
|
|
</div>
|
|
|
|
|
</ActionToolbar>
|
|
|
|
|
<div className="address-member-candidate-list" role="list">
|
|
|
|
|
{memberCandidateContacts.length === 0 ?
|
|
|
|
|
<div className="address-empty-note">No contacts available to add.</div> :
|
|
|
|
@@ -3666,13 +3774,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Dialog
|
|
|
|
|
open={importOpen}
|
|
|
|
|
title="Import contacts"
|
|
|
|
|
onClose={() => setImportOpen(false)}
|
|
|
|
|
onClose={closeImportDialog}
|
|
|
|
|
closeDisabled={saving}
|
|
|
|
|
className="address-import-dialog"
|
|
|
|
|
footerClassName="button-row compact-actions"
|
|
|
|
|
footer={
|
|
|
|
|
<>
|
|
|
|
|
<Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
|
|
|
|
|
<Button type="button" onClick={closeImportDialog} disabledReason={dialogCancelReason}>Close</Button>
|
|
|
|
|
{importMode === "vcard" &&
|
|
|
|
|
<Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>}
|
|
|
|
|
{importMode === "tabular" && creatingImportProfile &&
|
|
|
|
@@ -3681,19 +3789,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<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>}
|
|
|
|
|
{importMode === "tabular" && importLifecycle?.canRollback &&
|
|
|
|
|
<Button type="button" variant="danger" onClick={() => setImportRollbackOpen(true)} disabledReason={savingReason}><RotateCcw size={16} /> Roll back</Button>}
|
|
|
|
|
</>
|
|
|
|
|
}>
|
|
|
|
|
<div className="address-dialog-form">
|
|
|
|
|
<DialogSection 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); }}
|
|
|
|
|
onChange={(mode) => { setImportMode(mode); clearRetainedImportRun(); }}
|
|
|
|
|
/>
|
|
|
|
|
{importMode === "vcard" &&
|
|
|
|
|
<form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
|
|
|
|
|
<DialogForm 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
|
|
|
|
@@ -3704,15 +3814,32 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
placeholder={"BEGIN:VCARD\nVERSION:4.0\nFN:Ada Lovelace\nEMAIL;TYPE=work:ada@example.local\nEND:VCARD"}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
</form>}
|
|
|
|
|
</DialogForm>}
|
|
|
|
|
{importMode === "tabular" &&
|
|
|
|
|
<div className="address-import-workspace">
|
|
|
|
|
<div className="form-grid two">
|
|
|
|
|
{(requestedImportRunId || importRun) &&
|
|
|
|
|
<div className="address-import-run-state">
|
|
|
|
|
<div>
|
|
|
|
|
<strong>Persisted import run</strong>
|
|
|
|
|
<span className="muted block">{importRun?.id ?? requestedImportRunId}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{importRun && importLifecycle &&
|
|
|
|
|
<>
|
|
|
|
|
<StatusBadge status={importLifecycle.tone} label={importLifecycle.label} />
|
|
|
|
|
<p className="muted">{importLifecycle.guidance}</p>
|
|
|
|
|
</>}
|
|
|
|
|
<Button type="button" onClick={() => void reloadImportRun()} disabledReason={importRunLoading ? "The import run is loading." : undefined}>
|
|
|
|
|
<RefreshCw size={15} /> {importRunLoading ? "Loading…" : "Reload run"}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>}
|
|
|
|
|
{importRunUnavailable &&
|
|
|
|
|
<DismissibleAlert tone="warning" resetKey={importRunUnavailable}>{importRunUnavailable}</DismissibleAlert>}
|
|
|
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Mapping profile">
|
|
|
|
|
<select
|
|
|
|
|
value={selectedImportProfileId}
|
|
|
|
|
disabled={creatingImportProfile}
|
|
|
|
|
onChange={(event) => { setSelectedImportProfileId(event.target.value); setImportRun(null); }}>
|
|
|
|
|
onChange={(event) => { setSelectedImportProfileId(event.target.value); clearRetainedImportRun(); }}>
|
|
|
|
|
<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>
|
|
|
|
@@ -3728,10 +3855,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
{creatingImportProfile ? "Cancel editor" : "New mapping"}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</FormGrid>
|
|
|
|
|
{creatingImportProfile ?
|
|
|
|
|
<div className="address-import-profile-editor">
|
|
|
|
|
<div className="form-grid two">
|
|
|
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Profile name"><input value={importProfileForm.name} onChange={(event) => setImportProfileForm((current) => ({ ...current, name: event.target.value }))} /></FormField>
|
|
|
|
|
<FormField label="Format">
|
|
|
|
|
<select value={importProfileForm.source_format} disabled={Boolean(editingImportProfileId)} onChange={(event) => setImportProfileForm((current) => ({ ...current, source_format: event.target.value as "csv" | "xlsx" }))}>
|
|
|
|
@@ -3771,7 +3898,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<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>
|
|
|
|
|
</FormGrid>
|
|
|
|
|
<div className="address-import-mapping-grid">
|
|
|
|
|
{IMPORT_MAPPING_FIELDS.map(([target, label]) =>
|
|
|
|
|
<FormField label={label} key={target}>
|
|
|
|
@@ -3788,11 +3915,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<input
|
|
|
|
|
type="file"
|
|
|
|
|
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
|
|
|
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); setImportRun(null); }}
|
|
|
|
|
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); clearRetainedImportRun(); }}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
{importRun &&
|
|
|
|
|
<div className="address-import-preview">
|
|
|
|
|
<p className="muted small-text">
|
|
|
|
|
{importRun.source_filename} · plan {importRun.plan_hash.slice(0, 12)}… · updated {formatDateTime(importRun.updated_at)}
|
|
|
|
|
</p>
|
|
|
|
|
<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>)}
|
|
|
|
@@ -3813,7 +3943,35 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
</div>}
|
|
|
|
|
</>}
|
|
|
|
|
</div>}
|
|
|
|
|
</div>
|
|
|
|
|
</DialogSection>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
|
open={importRollbackOpen}
|
|
|
|
|
title="Roll back contact import"
|
|
|
|
|
onClose={() => setImportRollbackOpen(false)}
|
|
|
|
|
closeDisabled={saving}
|
|
|
|
|
footerClassName="button-row compact-actions"
|
|
|
|
|
footer={
|
|
|
|
|
<>
|
|
|
|
|
<Button type="button" onClick={() => setImportRollbackOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
|
|
|
|
|
<Button type="button" variant="danger" onClick={() => void rollbackTabularImport()} disabledReason={tabularRollbackReason}><RotateCcw size={16} /> Roll back import</Button>
|
|
|
|
|
</>
|
|
|
|
|
}>
|
|
|
|
|
<DialogSection className="address-dialog-form">
|
|
|
|
|
<p className="muted">
|
|
|
|
|
Rollback is accepted only while every affected contact still matches the evidence recorded for plan {importRun?.plan_hash.slice(0, 12) ?? "-"}….
|
|
|
|
|
</p>
|
|
|
|
|
<FormField label="Reason">
|
|
|
|
|
<textarea
|
|
|
|
|
rows={3}
|
|
|
|
|
value={importRollbackReason}
|
|
|
|
|
onChange={(event) => setImportRollbackReason(event.target.value)}
|
|
|
|
|
placeholder="Why should this import be rolled back?"
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
</DialogSection>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
@@ -3830,14 +3988,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<Button type="submit" form="address-carddav-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV address-book URL before saving."])}><Save size={16} /> Connect</Button>
|
|
|
|
|
</>
|
|
|
|
|
}>
|
|
|
|
|
<form id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}>
|
|
|
|
|
<DialogForm id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}>
|
|
|
|
|
<FormField label="Address book">
|
|
|
|
|
<input value={selectedBook?.name ?? ""} disabled readOnly />
|
|
|
|
|
</FormField>
|
|
|
|
|
<FormField label="CardDAV URL">
|
|
|
|
|
<input value={cardDavForm.collection_url} onChange={(event) => setCardDavForm((current) => ({ ...current, collection_url: event.target.value }))} autoFocus />
|
|
|
|
|
</FormField>
|
|
|
|
|
<div className="form-grid three">
|
|
|
|
|
<FormGrid columns={3} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Authentication">
|
|
|
|
|
<select value={cardDavForm.auth_type} onChange={(event) => setCardDavForm((current) => ({ ...current, auth_type: event.target.value as CardDavFormState["auth_type"] }))}>
|
|
|
|
|
<option value="none">None</option>
|
|
|
|
@@ -3855,7 +4013,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<FormField label="Display name">
|
|
|
|
|
<input value={cardDavForm.display_name} onChange={(event) => setCardDavForm((current) => ({ ...current, display_name: event.target.value }))} placeholder={selectedBook?.name ?? "CardDAV"} />
|
|
|
|
|
</FormField>
|
|
|
|
|
</div>
|
|
|
|
|
</FormGrid>
|
|
|
|
|
{cardDavForm.auth_type !== "none" &&
|
|
|
|
|
<FormField label="Reusable credential">
|
|
|
|
|
<select
|
|
|
|
@@ -3883,7 +4041,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
}
|
|
|
|
|
{cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>}
|
|
|
|
|
{cardDavForm.auth_type === "basic" &&
|
|
|
|
|
<div className="form-grid two">
|
|
|
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Username">
|
|
|
|
|
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} disabled={Boolean(cardDavForm.credential_envelope_id)} />
|
|
|
|
|
</FormField>
|
|
|
|
@@ -3892,7 +4050,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
|
|
|
|
|
</FormField>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
</FormGrid>
|
|
|
|
|
}
|
|
|
|
|
{cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id &&
|
|
|
|
|
<FormField label="Bearer token">
|
|
|
|
@@ -3913,7 +4071,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
))}
|
|
|
|
|
</SelectionList>
|
|
|
|
|
}
|
|
|
|
|
</form>
|
|
|
|
|
</DialogForm>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
@@ -3930,8 +4088,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<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">
|
|
|
|
|
<DialogForm id="address-ldap-form" className="address-dialog-form" onSubmit={(event) => void submitLdapSource(event)}>
|
|
|
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
|
|
|
<FormField label="Directory URL">
|
|
|
|
|
<input value={ldapForm.url} onChange={(event) => setLdapForm((current) => ({ ...current, url: event.target.value }))} placeholder="ldaps://directory.example.org" autoFocus />
|
|
|
|
|
</FormField>
|
|
|
|
@@ -3954,7 +4112,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
<FormField label="Bind DN / username">
|
|
|
|
|
<input value={ldapForm.bind_dn} onChange={(event) => setLdapForm((current) => ({ ...current, bind_dn: event.target.value }))} />
|
|
|
|
|
</FormField>
|
|
|
|
|
</div>
|
|
|
|
|
</FormGrid>
|
|
|
|
|
<ToggleSwitch
|
|
|
|
|
label="Require StartTLS for ldap://"
|
|
|
|
|
checked={ldapForm.start_tls}
|
|
|
|
@@ -3962,7 +4120,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
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">
|
|
|
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
|
|
|
<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>
|
|
|
|
@@ -3972,7 +4130,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
</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>
|
|
|
|
|
</FormGrid>
|
|
|
|
|
<div className="address-import-mapping-grid">
|
|
|
|
|
{LDAP_MAPPING_FIELDS.map(([target, label]) =>
|
|
|
|
|
<FormField label={label} key={target}>
|
|
|
|
@@ -3980,7 +4138,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|
|
|
|
</FormField>)}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="muted">The directory remains authoritative and read-only. Preview the first refresh before applying it.</p>
|
|
|
|
|
</form>
|
|
|
|
|
</DialogForm>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
|