Files
govoplan-addresses/webui/src/features/addressbook/AddressBookPage.tsx

2620 lines
110 KiB
TypeScript

import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import {
ApiError,
Button,
ConfirmDialog,
DataGridPaginationBar,
Dialog,
DismissibleAlert,
ExplorerTree,
fetchAuthGroups,
formatDateTime,
FormField,
LoadingFrame,
PasswordField,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
ToggleSwitch,
hasScope,
type ApiSettings,
type AuthInfo,
type AuthUpdate,
type FormatDateTimeOptions
} from "@govoplan/core-webui";
import {
createAddressBook,
createAddressList,
createAddressListEntry,
createCardDavSyncSource,
createContact,
deleteAddressBook,
deleteAddressList,
deleteAddressListEntry,
deleteContact,
deleteAddressSyncSource,
discoverCardDavAddressBooks,
exportAddressBookVcards,
exportContactVcard,
importAddressBookVcards,
listAddressBooks,
listAddressCredentials,
listAddressListEntries,
listAddressLists,
listAddressSyncConflicts,
listAddressSyncDiagnostics,
listAddressSyncSources,
listAddressSyncTombstones,
listContacts,
listContactsPage,
previewAddressSyncSource,
restoreAddressBook,
restoreAddressList,
restoreContact,
resolveAddressSyncConflict,
runAddressSyncSource,
updateAddressBook,
updateAddressList,
updateAddressSyncSource,
updateContact,
type AddressCardDavAddressBook,
type AddressBook,
type AddressBookScope,
type AddressCredentialEnvelope,
type AddressList,
type AddressListEntry,
type AddressSyncConflict,
type AddressSyncDiagnostic,
type AddressSyncPlan,
type AddressSyncSource,
type AddressSyncTombstone,
type Contact
} from "../../api/addresses";
type Props = {
settings: ApiSettings;
auth: AuthInfo;
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
};
type BookDialogState = {
mode: "create" | "edit";
book?: AddressBook;
};
type BookFormState = {
scope_type: AddressBookScope;
group_id: string;
name: string;
description: string;
};
type ContactDialogState = {
mode: "create" | "edit";
contact?: Contact;
};
type ListDialogState = {
mode: "create" | "edit";
list?: AddressList;
};
type ContactEmailRow = {
rowId: string;
label: string;
email: string;
is_primary: boolean;
};
type ContactPhoneRow = {
rowId: string;
label: string;
phone: string;
is_primary: boolean;
};
type ContactPostalAddressRow = {
rowId: string;
label: string;
street: string;
postal_code: string;
locality: string;
region: string;
country: string;
is_primary: boolean;
};
type ContactFormState = {
display_name: string;
given_name: string;
family_name: string;
organization: string;
role_title: string;
emails: ContactEmailRow[];
phones: ContactPhoneRow[];
postal_addresses: ContactPostalAddressRow[];
tags: string;
note: string;
};
type ListFormState = {
name: string;
description: string;
};
type CardDavFormState = {
collection_url: string;
display_name: string;
auth_type: "none" | "basic" | "bearer";
credential_envelope_id: string;
username: string;
password: string;
bearer_token: string;
sync_direction: "read_only" | "import" | "export" | "two_way";
};
type SyncInspectorState = {
source: AddressSyncSource;
} | null;
type AddressListTargetOption = {
key: string;
label: string;
payload: {
contact_email_id?: string | null;
contact_postal_address_id?: string | null;
label?: string | null;
};
};
type ConfirmState =
| { kind: "book"; book: AddressBook }
| { kind: "list"; list: AddressList }
| { kind: "contact"; contact: Contact }
| { kind: "sync-source"; source: AddressSyncSource }
| null;
type AddressTreeNode = {
id: string;
kind: "source" | "scope" | "book" | "list";
label: string;
sourceGroup?: "local" | "linked";
scope?: AddressBookScope;
book?: AddressBook;
list?: AddressList;
children: AddressTreeNode[];
};
type ConflictMergeChoice = "local" | "remote";
const EMPTY_BOOK_FORM: BookFormState = {
scope_type: "user",
group_id: "",
name: "",
description: ""
};
const EMPTY_LIST_FORM: ListFormState = {
name: "",
description: ""
};
const EMPTY_CARDDAV_FORM: CardDavFormState = {
collection_url: "",
display_name: "",
auth_type: "basic",
credential_envelope_id: "",
username: "",
password: "",
bearer_token: "",
sync_direction: "read_only"
};
const ADDRESS_CONTACT_DRAG_TYPE = "application/x-govoplan-address-contact-id";
const CONFLICT_PAYLOAD_FIELDS = ["display_name", "given_name", "family_name", "organization", "role_title", "emails", "phones", "postal_addresses", "tags", "note"] as const;
let contactRowSequence = 0;
function nextContactRowId(prefix: string): string {
contactRowSequence += 1;
return `${prefix}-${contactRowSequence}`;
}
function emptyEmailRow(isPrimary = true): ContactEmailRow {
return { rowId: nextContactRowId("email"), label: "work", email: "", is_primary: isPrimary };
}
function emptyPhoneRow(isPrimary = true): ContactPhoneRow {
return { rowId: nextContactRowId("phone"), label: "work", phone: "", is_primary: isPrimary };
}
function emptyPostalAddressRow(isPrimary = true): ContactPostalAddressRow {
return {
rowId: nextContactRowId("address"),
label: "work",
street: "",
postal_code: "",
locality: "",
region: "",
country: "",
is_primary: isPrimary
};
}
function emptyContactForm(): ContactFormState {
return {
display_name: "",
given_name: "",
family_name: "",
organization: "",
role_title: "",
emails: [emptyEmailRow()],
phones: [emptyPhoneRow()],
postal_addresses: [emptyPostalAddressRow()],
tags: "",
note: ""
};
}
const EMPTY_CONTACT_FORM: ContactFormState = emptyContactForm();
function errorMessage(error: unknown): string {
if (error instanceof ApiError) {
try {
const parsed = JSON.parse(error.body) as { detail?: unknown };
if (typeof parsed.detail === "string") return parsed.detail;
} catch {
return error.message;
}
}
return error instanceof Error ? error.message : String(error);
}
function scopeLabel(scope: AddressBookScope): string {
if (scope === "user") return "Personal";
if (scope === "group") return "Group";
if (scope === "tenant") return "Tenant";
return "System";
}
function primaryEmail(contact: Contact): string {
return contact.emails.find((email) => email.is_primary)?.email ?? contact.emails[0]?.email ?? "";
}
function primaryPhone(contact: Contact): string {
return contact.phones.find((phone) => phone.is_primary)?.phone ?? contact.phones[0]?.phone ?? "";
}
function sourceGroupForBook(book: AddressBook): "local" | "linked" {
return book.source_kind === "local" ? "local" : "linked";
}
function sourceGroupLabel(sourceGroup: "local" | "linked"): string {
return sourceGroup === "local" ? "Local" : "Linked";
}
function syncSourceLabel(source: AddressSyncSource): string {
return source.display_name || source.external_address_book_ref || source.connector_type;
}
const ADDRESS_DATE_TIME_OPTIONS: FormatDateTimeOptions = {
fallback: "Never",
year: undefined,
month: undefined,
day: undefined,
hour: undefined,
minute: undefined,
timeZoneName: undefined,
dateStyle: "short",
timeStyle: "short"
};
function planSummary(plan: AddressSyncPlan | null): string {
if (!plan) return "No preview loaded.";
const { stats } = plan;
return `${stats.created} create, ${stats.updated} update, ${stats.deleted} delete, ${stats.conflicts} conflict, ${stats.unchanged} unchanged, ${stats.errors} error`;
}
function syncActionLabel(action: string): string {
if (action === "remote_create") return "Remote create";
if (action === "remote_update") return "Remote update";
if (action === "remote_delete") return "Remote delete";
return action;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function conflictPayload(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
if (!isRecord(value)) return null;
return isRecord(value.payload) ? value.payload : null;
}
function conflictRemotePayload(conflict: AddressSyncConflict): Record<string, unknown> | null {
const remoteValuePayload = conflictPayload(conflict.remote_value);
if (remoteValuePayload) return remoteValuePayload;
const metadataPayload = conflict.metadata?.remote_payload;
return isRecord(metadataPayload) ? metadataPayload : null;
}
function canApplyRemoteConflict(conflict: AddressSyncConflict): boolean {
return Boolean(conflictRemotePayload(conflict));
}
function canMergeConflict(conflict: AddressSyncConflict): boolean {
return Boolean(conflictPayload(conflict.local_value) && conflictRemotePayload(conflict));
}
function defaultConflictMergeChoices(conflict: AddressSyncConflict): Record<string, ConflictMergeChoice> {
return Object.fromEntries(CONFLICT_PAYLOAD_FIELDS.map((field) => [field, "local" as ConflictMergeChoice]));
}
function buildMergedConflictPayload(conflict: AddressSyncConflict, choices: Record<string, ConflictMergeChoice>): Record<string, unknown> | null {
const localPayload = conflictPayload(conflict.local_value);
const remotePayload = conflictRemotePayload(conflict);
if (!localPayload || !remotePayload) return null;
const merged: Record<string, unknown> = { ...localPayload };
for (const field of CONFLICT_PAYLOAD_FIELDS) {
merged[field] = choices[field] === "remote" ? remotePayload[field] : localPayload[field];
}
return merged;
}
function shortJson(value: unknown): string {
if (value === null || value === undefined || value === "") return "—";
if (Array.isArray(value)) {
if (value.length === 0) return "—";
return value.map((item) => shortJson(item)).join("; ");
}
if (isRecord(value)) {
const entries = Object.entries(value)
.filter(([, entryValue]) => entryValue !== null && entryValue !== undefined && entryValue !== "")
.map(([key, entryValue]) => `${key}: ${shortJson(entryValue)}`);
return entries.length ? entries.join(", ") : "—";
}
return String(value);
}
function conflictFieldRows(conflict: AddressSyncConflict): Array<{ field: string; local: string; remote: string; differs: boolean }> {
const localPayload = conflictPayload(conflict.local_value);
const remotePayload = conflictRemotePayload(conflict);
if (!localPayload && !remotePayload) {
return [
{
field: conflict.field_path,
local: shortJson(conflict.local_value),
remote: shortJson(conflict.remote_value),
differs: shortJson(conflict.local_value) !== shortJson(conflict.remote_value)
}
];
}
return CONFLICT_PAYLOAD_FIELDS.map((field) => {
const local = shortJson(localPayload?.[field]);
const remote = shortJson(remotePayload?.[field]);
return { field, local, remote, differs: local !== remote };
});
}
function buildAddressTree(books: AddressBook[], addressLists: AddressList[]): AddressTreeNode[] {
const listsByBook = new Map<string, AddressList[]>();
for (const list of addressLists) {
const current = listsByBook.get(list.address_book_id) ?? [];
current.push(list);
listsByBook.set(list.address_book_id, current);
}
return (["local", "linked"] as const).map((sourceGroup) => {
const sourceBooks = books.filter((book) => sourceGroupForBook(book) === sourceGroup);
const scopeNodes = (["user", "group", "tenant", "system"] as const).map((scope) => {
const scopeBooks = sourceBooks.filter((book) => book.scope_type === scope);
return {
id: `scope:${sourceGroup}:${scope}`,
kind: "scope" as const,
label: scopeLabel(scope),
sourceGroup,
scope,
children: scopeBooks.map((book) => ({
id: `book:${book.id}`,
kind: "book" as const,
label: book.name,
sourceGroup,
scope: book.scope_type,
book,
children: (listsByBook.get(book.id) ?? []).map((list) => ({
id: `list:${list.id}`,
kind: "list" as const,
label: list.name,
sourceGroup,
scope: book.scope_type,
list,
children: []
}))
}))
};
}).filter((node) => node.children.length > 0);
return {
id: `source:${sourceGroup}`,
kind: "source" as const,
label: sourceGroupLabel(sourceGroup),
sourceGroup,
children: scopeNodes
};
}).filter((node) => node.children.length > 0);
}
function expandedAddressTreeIds(nodes: AddressTreeNode[]): Set<string> {
const expanded = new Set<string>();
function visit(node: AddressTreeNode) {
if (node.children.length > 0) expanded.add(node.id);
node.children.forEach(visit);
}
nodes.forEach(visit);
return expanded;
}
function addressListEntryKey(entry: Pick<AddressListEntry, "contact_id" | "contact_email_id" | "contact_postal_address_id">): string {
if (entry.contact_email_id) return `${entry.contact_id}:email:${entry.contact_email_id}`;
if (entry.contact_postal_address_id) return `${entry.contact_id}:postal:${entry.contact_postal_address_id}`;
return `${entry.contact_id}:contact`;
}
function listEntryContactIds(entries: AddressListEntry[]): Set<string> {
return new Set(entries.map((entry) => entry.contact_id));
}
function contactListEntries(entries: AddressListEntry[], contactId: string): AddressListEntry[] {
return entries.filter((entry) => entry.contact_id === contactId);
}
function listFormFromList(list?: AddressList): ListFormState {
if (!list) return EMPTY_LIST_FORM;
return {
name: list.name,
description: list.description ?? ""
};
}
function formatPostalAddress(address: Contact["postal_addresses"][number]): string {
return [address.street, [address.postal_code, address.locality].filter(Boolean).join(" "), address.region, address.country]
.filter((part) => part && part.trim())
.join(", ");
}
function addressListTargetOptions(contact: Contact): AddressListTargetOption[] {
const options: AddressListTargetOption[] = [{
key: "contact",
label: "Whole contact",
payload: { label: null }
}];
for (const email of contact.emails) {
if (!email.id) continue;
const label = `${email.label || "email"}${email.is_primary ? " primary" : ""}: ${email.email}`;
options.push({
key: `email:${email.id}`,
label,
payload: { contact_email_id: email.id, label: email.label || null }
});
}
for (const address of contact.postal_addresses) {
if (!address.id) continue;
const formatted = formatPostalAddress(address) || "Postal address";
const label = `${address.label || "address"}${address.is_primary ? " primary" : ""}: ${formatted}`;
options.push({
key: `postal:${address.id}`,
label,
payload: { contact_postal_address_id: address.id, label: address.label || null }
});
}
return options;
}
function entryTargetLabel(entry: AddressListEntry): string {
if (entry.target_kind === "email") return entry.email ? `Email: ${entry.email}` : "Email target";
if (entry.target_kind === "postal_address") return entry.postal_address ? `Postal: ${entry.postal_address}` : "Postal address target";
return "Whole contact";
}
function bookFormFromBook(book?: AddressBook): BookFormState {
if (!book) return EMPTY_BOOK_FORM;
return {
scope_type: book.scope_type,
group_id: book.scope_type === "group" ? book.scope_id ?? "" : "",
name: book.name,
description: book.description ?? ""
};
}
function contactFormFromContact(contact?: Contact): ContactFormState {
if (!contact) return emptyContactForm();
return {
display_name: contact.display_name,
given_name: contact.given_name ?? "",
family_name: contact.family_name ?? "",
organization: contact.organization ?? "",
role_title: contact.role_title ?? "",
emails: normalizePrimaryRows(contact.emails.map((email) => ({
rowId: nextContactRowId("email"),
label: email.label ?? "work",
email: email.email,
is_primary: email.is_primary
})), emptyEmailRow),
phones: normalizePrimaryRows(contact.phones.map((phone) => ({
rowId: nextContactRowId("phone"),
label: phone.label ?? "work",
phone: phone.phone,
is_primary: phone.is_primary
})), emptyPhoneRow),
postal_addresses: normalizePrimaryRows(contact.postal_addresses.map((postal) => ({
rowId: nextContactRowId("address"),
label: postal.label ?? "work",
street: postal.street ?? "",
postal_code: postal.postal_code ?? "",
locality: postal.locality ?? "",
region: postal.region ?? "",
country: postal.country ?? "",
is_primary: postal.is_primary
})), emptyPostalAddressRow),
tags: contact.tags.join(", "),
note: contact.note ?? ""
};
}
function normalizePrimaryFlags<T extends { is_primary: boolean }>(rows: T[]): T[] {
if (!rows.length) return rows;
const primaryIndex = rows.findIndex((row) => row.is_primary);
return rows.map((row, index) => ({ ...row, is_primary: primaryIndex >= 0 ? index === primaryIndex : index === 0 }));
}
function normalizePrimaryRows<T extends { is_primary: boolean }>(rows: T[], fallback: () => T): T[] {
const nextRows = rows.length ? rows : [fallback()];
return normalizePrimaryFlags(nextRows);
}
function tagsFromForm(value: string): string[] {
const seen = new Set<string>();
return value.split(",").map((tag) => tag.trim()).filter((tag) => {
const key = tag.toLowerCase();
if (!tag || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function postalRowHasContent(row: ContactPostalAddressRow): boolean {
return [row.street, row.postal_code, row.locality, row.region, row.country].some((value) => value.trim());
}
function contactFormHasIdentity(form: ContactFormState): boolean {
return Boolean(
form.display_name.trim() ||
form.given_name.trim() ||
form.family_name.trim() ||
form.emails.some((email) => email.email.trim())
);
}
function safeFilename(value: string): string {
return (value.trim().replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "address-book") + ".vcf";
}
function downloadText(filename: string, content: string, type = "text/vcard;charset=utf-8") {
const blob = new Blob([content], { type });
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
window.URL.revokeObjectURL(url);
}
function disabledReason(...conditions: Array<[boolean, string]>): string {
return conditions.find(([applies]) => applies)?.[1] ?? "";
}
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
const [books, setBooks] = useState<AddressBook[]>([]);
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
const [syncSources, setSyncSources] = useState<AddressSyncSource[]>([]);
const [contacts, setContacts] = useState<Contact[]>([]);
const [contactTotal, setContactTotal] = useState(0);
const [contactPage, setContactPage] = useState(1);
const [contactPageSize, setContactPageSize] = useState(50);
const [selectedBookId, setSelectedBookId] = useState("");
const [selectedListId, setSelectedListId] = useState("");
const [selectedContactId, setSelectedContactId] = useState("");
const [expandedTreeIds, setExpandedTreeIds] = useState<Set<string>>(() => new Set());
const [query, setQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [bookDialog, setBookDialog] = useState<BookDialogState | null>(null);
const [bookForm, setBookForm] = useState<BookFormState>(EMPTY_BOOK_FORM);
const [listDialog, setListDialog] = useState<ListDialogState | null>(null);
const [listForm, setListForm] = useState<ListFormState>(EMPTY_LIST_FORM);
const [contactDialog, setContactDialog] = useState<ContactDialogState | null>(null);
const [contactForm, setContactForm] = useState<ContactFormState>(EMPTY_CONTACT_FORM);
const [memberDialogOpen, setMemberDialogOpen] = useState(false);
const [memberCandidates, setMemberCandidates] = useState<Contact[]>([]);
const [memberQuery, setMemberQuery] = useState("");
const [memberTargetByContactId, setMemberTargetByContactId] = useState<Record<string, string>>({});
const [dropTargetListId, setDropTargetListId] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [vcardContent, setVcardContent] = useState("");
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 [syncInspector, setSyncInspector] = useState<SyncInspectorState>(null);
const [syncPlan, setSyncPlan] = useState<AddressSyncPlan | null>(null);
const [syncDiagnostics, setSyncDiagnostics] = useState<AddressSyncDiagnostic[]>([]);
const [syncTombstones, setSyncTombstones] = useState<AddressSyncTombstone[]>([]);
const [syncConflicts, setSyncConflicts] = useState<AddressSyncConflict[]>([]);
const [conflictDialog, setConflictDialog] = useState<AddressSyncConflict | null>(null);
const [conflictMergeChoices, setConflictMergeChoices] = useState<Record<string, ConflictMergeChoice>>({});
const [confirmState, setConfirmState] = useState<ConfirmState>(null);
const canWriteBooks = hasScope(auth, "addresses:address_book:write");
const canDeleteBooks = hasScope(auth, "addresses:address_book:delete");
const canAdminBooks = hasScope(auth, "addresses:address_book:admin");
const canWriteLists = hasScope(auth, "addresses:address_list:write");
const canDeleteLists = hasScope(auth, "addresses:address_list:delete");
const canWriteContacts = hasScope(auth, "addresses:contact:write");
const canDeleteContacts = hasScope(auth, "addresses:contact:delete");
const canReadSync = hasScope(auth, "addresses:sync:read");
const canWriteSync = hasScope(auth, "addresses:sync:write");
useEffect(() => {
if (!cardDavOpen || !canWriteSync) {
setCardDavCredentials([]);
setCardDavCredentialsError("");
return;
}
let active = true;
listAddressCredentials(settings)
.then((credentials) => {
if (active) {
setCardDavCredentials(credentials);
setCardDavCredentialsError("");
}
})
.catch((err) => {
if (active) {
setCardDavCredentials([]);
setCardDavCredentialsError(errorMessage(err));
}
});
return () => {
active = false;
};
}, [
canWriteSync,
cardDavOpen,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
useEffect(() => {
if (auth.groups_loaded || !onAuthChange) return;
let active = true;
fetchAuthGroups(settings)
.then((next) => {if (active) onAuthChange(next);})
.catch(() => undefined);
return () => {active = false;};
}, [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 selectedList = addressLists.find((list) => list.id === selectedListId) ?? null;
const selectedBookSyncSources = useMemo(
() => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [],
[selectedBook, syncSources]
);
const selectedSyncSource = selectedBookSyncSources[0] ?? null;
const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]);
const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]);
const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]);
const visibleContacts = contacts;
const memberCandidateContacts = useMemo(() => {
const normalizedQuery = memberQuery.trim().toLowerCase();
return memberCandidates
.filter((contact) => !contact.deleted_at)
.filter((contact) => addressListTargetOptions(contact).some((option) => !selectedListEntryKeys.has(`${contact.id}:${option.key}`)))
.filter((contact) => {
if (!normalizedQuery) return true;
return [
contact.display_name,
contact.given_name,
contact.family_name,
contact.organization,
contact.role_title,
primaryEmail(contact),
primaryPhone(contact),
contact.tags.join(" ")
].some((value) => (value ?? "").toLowerCase().includes(normalizedQuery));
});
}, [memberCandidates, memberQuery, selectedListContactIds]);
const selectedContact = visibleContacts.find((contact) => contact.id === selectedContactId) ?? null;
const selectedContactListEntries = selectedContact && selectedList ? contactListEntries(addressListEntries, selectedContact.id) : [];
const activeTreeId = selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : "";
const loadingReason = loading ? "Address books are loading." : "";
const savingReason = saving ? "An address-book action is already in progress." : "";
const createBookReason = disabledReason(
[!canWriteBooks, "You need permission to manage address books."],
[saving, savingReason]
);
const refreshReason = disabledReason(
[loading, loadingReason],
[saving, savingReason]
);
const toggleArchiveReason = disabledReason(
[loading, loadingReason],
[saving, savingReason]
);
const exportBookReason = disabledReason(
[!selectedBook, "Select an address book before exporting vCards."],
[saving, savingReason]
);
const importBookReason = disabledReason(
[!selectedBook, "Select an address book before importing vCards."],
[!canWriteContacts, "You need permission to manage contacts."],
[Boolean(selectedBook?.read_only), "This address book is read-only."],
[Boolean(selectedBook?.deleted_at), "Restore this address book before importing contacts."],
[saving, savingReason]
);
const connectCardDavReason = disabledReason(
[!selectedBook, "Select an address book before connecting CardDAV."],
[!canWriteSync, "You need permission to manage address sync."],
[Boolean(selectedBook?.deleted_at), "Restore this address book before connecting sync."],
[saving, savingReason]
);
const inspectSyncReason = disabledReason(
[!selectedSyncSource, "This address book has no sync source."],
[!canReadSync, "You need permission to view address sync."],
[saving, savingReason]
);
const previewSyncReason = disabledReason(
[!selectedSyncSource, "This address book has no sync source."],
[!canReadSync, "You need permission to view address sync."],
[saving, savingReason]
);
const runSyncReason = disabledReason(
[!selectedSyncSource, "This address book has no sync source."],
[!canWriteSync, "You need permission to run address sync."],
[saving, savingReason]
);
const createContactReason = disabledReason(
[!selectedBook, "Select an address book before adding a contact."],
[!canWriteContacts, "You need permission to manage contacts."],
[Boolean(selectedBook?.read_only), "This address book is read-only."],
[Boolean(selectedBook?.deleted_at), "Restore this address book before adding contacts."],
[saving, savingReason]
);
const createListReason = disabledReason(
[!selectedBook, "Select an address book before adding a list."],
[!canWriteLists, "You need permission to manage address lists."],
[Boolean(selectedBook?.read_only), "This address book is read-only."],
[Boolean(selectedBook?.deleted_at), "Restore this address book before adding lists."],
[saving, savingReason]
);
const listSaveReason = disabledReason(
[saving, savingReason],
[!listForm.name.trim(), "Enter an address-list name before saving."]
);
const addMembersReason = disabledReason(
[!selectedList, "Select an address list before adding contacts."],
[!canWriteLists, "You need permission to manage address lists."],
[Boolean(selectedList?.read_only), "This address list is read-only."],
[Boolean(selectedList?.deleted_at), "Restore this address list before adding contacts."],
[Boolean(selectedBook?.read_only), "This address book is read-only."],
[Boolean(selectedBook?.deleted_at), "Restore this address book before adding contacts to lists."],
[saving, savingReason]
);
const bookSaveReason = disabledReason(
[saving, savingReason],
[!bookForm.name.trim(), "Enter an address book name before saving."]
);
const contactSaveReason = disabledReason(
[saving, savingReason],
[!contactFormHasIdentity(contactForm), "Enter a display name, name component, or email address before saving."]
);
const dialogCancelReason = disabledReason([saving, savingReason]);
const vcardImportReason = disabledReason(
[saving, savingReason],
[!selectedBook, "Select an address book before importing vCards."],
[!vcardContent.trim(), "Paste vCard content before importing."]
);
const addContactRowReason = disabledReason([saving, savingReason]);
const removeEmailRowReason = disabledReason(
[saving, savingReason],
[contactForm.emails.length === 1, "Keep at least one email row. Empty rows are ignored on save."]
);
const removePhoneRowReason = disabledReason(
[saving, savingReason],
[contactForm.phones.length === 1, "Keep at least one phone row. Empty rows are ignored on save."]
);
const removePostalAddressRowReason = disabledReason(
[saving, savingReason],
[contactForm.postal_addresses.length === 1, "Keep at least one postal address row. Empty rows are ignored on save."]
);
function restoreBookReason(_book: AddressBook): string {
return disabledReason(
[!canWriteBooks, "You need permission to manage address books."],
[saving, savingReason]
);
}
function editBookReason(book: AddressBook): string {
return disabledReason(
[!canWriteBooks, "You need permission to manage address books."],
[book.read_only, "This address book is read-only."],
[saving, savingReason]
);
}
function deleteBookReason(book: AddressBook): string {
return disabledReason(
[!canDeleteBooks, "You need permission to delete address books."],
[book.read_only, "This address book is read-only."],
[saving, savingReason]
);
}
function editListReason(list: AddressList): string {
return disabledReason(
[!canWriteLists, "You need permission to manage address lists."],
[list.read_only, "This address list is read-only."],
[Boolean(list.deleted_at), "Restore this address list before editing it."],
[saving, savingReason]
);
}
function deleteListReason(list: AddressList): string {
return disabledReason(
[!canDeleteLists, "You need permission to delete address lists."],
[list.read_only, "This address list is read-only."],
[Boolean(list.deleted_at), "This address list is already archived."],
[saving, savingReason]
);
}
function restoreListReason(list: AddressList): string {
return disabledReason(
[!canWriteLists, "You need permission to manage address lists."],
[list.read_only, "This address list is read-only."],
[Boolean(selectedBook?.read_only), "This address book is read-only."],
[saving, savingReason]
);
}
function addContactToListReason(list: AddressList | null, contact: Contact | null, targetKey = ""): string {
return disabledReason(
[!list, "Select an address list before adding contacts."],
[!contact, "Select a contact before adding it to a list."],
[!canWriteLists, "You need permission to manage address lists."],
[Boolean(list?.read_only), "This address list is read-only."],
[Boolean(list?.deleted_at), "Restore this address list before adding contacts."],
[Boolean(selectedBook?.read_only), "This address book is read-only."],
[Boolean(selectedBook?.deleted_at), "Restore this address book before adding contacts to lists."],
[Boolean(contact?.deleted_at), "Restore this contact before adding it to a list."],
[Boolean(list && contact && list.address_book_id !== contact.address_book_id), "Address-list entries must use contacts from the same address book."],
[Boolean(contact && targetKey && selectedListEntryKeys.has(`${contact.id}:${targetKey}`) && list?.id === selectedList?.id), "This target is already in the selected address list."],
[saving, savingReason]
);
}
function removeContactFromListReason(): string {
return disabledReason(
[!selectedList, "Select an address list before removing contacts."],
[selectedContactListEntries.length === 0, "This contact is not in the selected address list."],
[!canWriteLists, "You need permission to manage address lists."],
[Boolean(selectedList?.read_only), "This address list is read-only."],
[Boolean(selectedList?.deleted_at), "Restore this address list before removing contacts."],
[saving, savingReason]
);
}
function restoreContactReason(): string {
return disabledReason(
[!canWriteContacts, "You need permission to manage contacts."],
[Boolean(selectedBook?.read_only), "This address book is read-only."],
[Boolean(selectedBook?.deleted_at), "Restore this address book before restoring contacts."],
[saving, savingReason]
);
}
function exportContactReason(): string {
return disabledReason([saving, savingReason]);
}
function editContactReason(): string {
return disabledReason(
[!canWriteContacts, "You need permission to manage contacts."],
[Boolean(selectedBook?.read_only), "This address book is read-only."],
[Boolean(selectedBook?.deleted_at), "Restore this address book before editing contacts."],
[saving, savingReason]
);
}
function deleteContactReason(): string {
return disabledReason(
[!canDeleteContacts, "You need permission to delete contacts."],
[Boolean(selectedBook?.read_only), "This address book is read-only."],
[Boolean(selectedBook?.deleted_at), "Restore this address book before deleting contacts."],
[saving, savingReason]
);
}
function availableTargetOptionsForContact(contact: Contact): AddressListTargetOption[] {
return addressListTargetOptions(contact).filter((option) => !selectedListEntryKeys.has(`${contact.id}:${option.key}`));
}
function selectedTargetOptionForContact(contact: Contact): AddressListTargetOption | null {
const options = availableTargetOptionsForContact(contact);
if (options.length === 0) return null;
const selectedKey = memberTargetByContactId[contact.id];
return options.find((option) => option.key === selectedKey) ?? options[0];
}
function setMemberTarget(contactId: string, targetKey: string) {
setMemberTargetByContactId((current) => ({ ...current, [contactId]: targetKey }));
}
const refreshBooks = useCallback(async () => {
const [nextBooks, nextAddressLists, nextSyncSources] = await Promise.all([
listAddressBooks(settings, { includeDeleted: showArchived }),
listAddressLists(settings, { includeDeleted: showArchived }),
canReadSync ? listAddressSyncSources(settings, { includeDisabled: true }) : Promise.resolve([])
]);
setBooks(nextBooks);
setAddressLists(nextAddressLists);
setSyncSources(nextSyncSources);
setSelectedBookId((current) => nextBooks.some((book) => book.id === current) ? current : nextBooks[0]?.id ?? "");
setSelectedListId((current) => nextAddressLists.some((list) => list.id === current) ? current : "");
return nextBooks;
}, [canReadSync, settings, showArchived]);
const refreshContacts = useCallback(async (bookId: string, search: string) => {
if (!bookId) {
setContacts([]);
setContactTotal(0);
return;
}
const response = await listContactsPage(settings, {
addressBookId: bookId,
addressListId: selectedListId || undefined,
query: search,
limit: contactPageSize,
offset: (contactPage - 1) * contactPageSize,
includeDeleted: showArchived
});
const pageCount = Math.max(1, Math.ceil(response.total / contactPageSize));
if (contactPage > pageCount) {
setContactPage(pageCount);
return;
}
setContacts(response.contacts);
setContactTotal(response.total);
}, [contactPage, contactPageSize, selectedListId, settings, showArchived]);
const refreshListEntries = useCallback(async (listId: string) => {
if (!listId) {
setAddressListEntries([]);
return [];
}
const nextEntries = await listAddressListEntries(settings, listId);
setAddressListEntries(nextEntries);
return nextEntries;
}, [settings]);
const refreshAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const nextBooks = await refreshBooks();
const nextSelected = nextBooks.some((book) => book.id === selectedBookId) ? selectedBookId : nextBooks[0]?.id ?? "";
await refreshContacts(nextSelected, query);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, [query, refreshBooks, refreshContacts, selectedBookId]);
useEffect(() => {
let active = true;
async function loadInitial() {
setLoading(true);
setError("");
try {
await refreshBooks();
} catch (err) {
if (active) setError(errorMessage(err));
} finally {
if (active) setLoading(false);
}
}
void loadInitial();
return () => {active = false;};
}, [refreshBooks]);
useEffect(() => {
void refreshContacts(selectedBookId, query).catch((err) => setError(errorMessage(err)));
}, [query, refreshContacts, selectedBookId]);
useEffect(() => {
const defaultExpanded = expandedAddressTreeIds(addressTreeNodes);
if (defaultExpanded.size === 0) return;
setExpandedTreeIds((current) => {
const next = new Set(current);
for (const id of defaultExpanded) next.add(id);
return next;
});
}, [addressTreeNodes]);
useEffect(() => {
let active = true;
async function loadListEntries() {
if (!selectedListId || selectedList?.deleted_at) {
setAddressListEntries([]);
return;
}
try {
const nextEntries = await listAddressListEntries(settings, selectedListId);
if (active) setAddressListEntries(nextEntries);
} catch (err) {
if (active) {
setAddressListEntries([]);
setError(errorMessage(err));
}
}
}
void loadListEntries();
return () => {active = false;};
}, [selectedList?.deleted_at, selectedListId, settings]);
useEffect(() => {
if (!selectedContactId) return;
if (!visibleContacts.some((contact) => contact.id === selectedContactId)) setSelectedContactId("");
}, [selectedContactId, visibleContacts]);
function openCreateBookDialog() {
setBookForm({
...EMPTY_BOOK_FORM,
group_id: auth.groups[0]?.id ?? ""
});
setBookDialog({ mode: "create" });
}
function openEditBookDialog(book: AddressBook) {
setBookForm(bookFormFromBook(book));
setBookDialog({ mode: "edit", book });
}
function openCreateListDialog() {
setListForm(EMPTY_LIST_FORM);
setListDialog({ mode: "create" });
}
function openEditListDialog(list: AddressList) {
setListForm(listFormFromList(list));
setListDialog({ mode: "edit", list });
}
function openCreateContactDialog() {
setContactForm(emptyContactForm());
setContactDialog({ mode: "create" });
}
function openEditContactDialog(contact: Contact) {
setContactForm(contactFormFromContact(contact));
setContactDialog({ mode: "edit", contact });
}
function updateEmailRow(rowId: string, patch: Partial<Omit<ContactEmailRow, "rowId">>) {
setContactForm((current) => ({
...current,
emails: current.emails.map((row) => row.rowId === rowId ? { ...row, ...patch } : row)
}));
}
function updatePhoneRow(rowId: string, patch: Partial<Omit<ContactPhoneRow, "rowId">>) {
setContactForm((current) => ({
...current,
phones: current.phones.map((row) => row.rowId === rowId ? { ...row, ...patch } : row)
}));
}
function updatePostalAddressRow(rowId: string, patch: Partial<Omit<ContactPostalAddressRow, "rowId">>) {
setContactForm((current) => ({
...current,
postal_addresses: current.postal_addresses.map((row) => row.rowId === rowId ? { ...row, ...patch } : row)
}));
}
function setPrimaryEmailRow(rowId: string) {
setContactForm((current) => ({ ...current, emails: current.emails.map((row) => ({ ...row, is_primary: row.rowId === rowId })) }));
}
function setPrimaryPhoneRow(rowId: string) {
setContactForm((current) => ({ ...current, phones: current.phones.map((row) => ({ ...row, is_primary: row.rowId === rowId })) }));
}
function setPrimaryPostalAddressRow(rowId: string) {
setContactForm((current) => ({ ...current, postal_addresses: current.postal_addresses.map((row) => ({ ...row, is_primary: row.rowId === rowId })) }));
}
function addEmailRow() {
setContactForm((current) => ({ ...current, emails: [...current.emails, emptyEmailRow(false)] }));
}
function addPhoneRow() {
setContactForm((current) => ({ ...current, phones: [...current.phones, emptyPhoneRow(false)] }));
}
function addPostalAddressRow() {
setContactForm((current) => ({ ...current, postal_addresses: [...current.postal_addresses, emptyPostalAddressRow(false)] }));
}
function removeEmailRow(rowId: string) {
setContactForm((current) => ({ ...current, emails: normalizePrimaryRows(current.emails.filter((row) => row.rowId !== rowId), emptyEmailRow) }));
}
function removePhoneRow(rowId: string) {
setContactForm((current) => ({ ...current, phones: normalizePrimaryRows(current.phones.filter((row) => row.rowId !== rowId), emptyPhoneRow) }));
}
function removePostalAddressRow(rowId: string) {
setContactForm((current) => ({ ...current, postal_addresses: normalizePrimaryRows(current.postal_addresses.filter((row) => row.rowId !== rowId), emptyPostalAddressRow) }));
}
function openTreeNode(node: AddressTreeNode) {
if (node.kind === "book" && node.book) {
setContactPage(1);
setSelectedBookId(node.book.id);
setSelectedListId("");
setSelectedContactId("");
return;
}
if (node.kind === "list" && node.list) {
setContactPage(1);
setSelectedBookId(node.list.address_book_id);
setSelectedListId(node.list.id);
setSelectedContactId("");
return;
}
toggleTreeNode(node);
}
function toggleTreeNode(node: AddressTreeNode) {
if (node.children.length === 0) return;
setExpandedTreeIds((current) => {
const next = new Set(current);
if (next.has(node.id)) next.delete(node.id);
else next.add(node.id);
return next;
});
}
async function submitBook(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
setError("");
setNotice("");
try {
if (bookDialog?.mode === "edit" && bookDialog.book) {
await updateAddressBook(settings, bookDialog.book.id, { name: bookForm.name, description: bookForm.description || null });
} else {
await createAddressBook(settings, {
scope_type: bookForm.scope_type,
group_id: bookForm.scope_type === "group" ? bookForm.group_id : null,
name: bookForm.name,
description: bookForm.description || null
});
}
setBookDialog(null);
await refreshAll();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function submitList(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedBook) return;
setSaving(true);
setError("");
setNotice("");
try {
let savedList: AddressList;
if (listDialog?.mode === "edit" && listDialog.list) {
savedList = await updateAddressList(settings, listDialog.list.id, {
name: listForm.name,
description: listForm.description || null
});
} else {
savedList = await createAddressList(settings, selectedBook.id, {
name: listForm.name,
description: listForm.description || null
});
}
setListDialog(null);
setSelectedBookId(savedList.address_book_id);
setSelectedListId(savedList.id);
setExpandedTreeIds((current) => {
const next = new Set(current);
next.add(`book:${savedList.address_book_id}`);
return next;
});
await refreshBooks();
await refreshListEntries(savedList.id);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function submitContact(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedBook) return;
setSaving(true);
setError("");
setNotice("");
const emails = normalizePrimaryFlags(contactForm.emails
.filter((email) => email.email.trim())
.map((email) => ({ label: email.label || null, email: email.email.trim(), is_primary: email.is_primary })));
const phones = normalizePrimaryFlags(contactForm.phones
.filter((phone) => phone.phone.trim())
.map((phone) => ({ label: phone.label || null, phone: phone.phone.trim(), is_primary: phone.is_primary })));
const postal_addresses = normalizePrimaryFlags(contactForm.postal_addresses
.filter(postalRowHasContent)
.map((address) => ({
label: address.label || null,
street: address.street || null,
postal_code: address.postal_code || null,
locality: address.locality || null,
region: address.region || null,
country: address.country || null,
is_primary: address.is_primary
})));
const payload = {
display_name: contactForm.display_name || null,
given_name: contactForm.given_name || null,
family_name: contactForm.family_name || null,
organization: contactForm.organization || null,
role_title: contactForm.role_title || null,
note: contactForm.note || null,
tags: tagsFromForm(contactForm.tags),
emails,
phones,
postal_addresses,
provenance: {}
};
try {
let savedContact: Contact;
if (contactDialog?.mode === "edit" && contactDialog.contact) {
savedContact = await updateContact(settings, contactDialog.contact.id, payload);
} else {
savedContact = await createContact(settings, selectedBook.id, payload);
}
setContactDialog(null);
setSelectedContactId(savedContact.id);
await refreshBooks();
await refreshContacts(selectedBook.id, query);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function confirmDelete() {
if (!confirmState) return;
setSaving(true);
setError("");
setNotice("");
try {
if (confirmState.kind === "book") {
await deleteAddressBook(settings, confirmState.book.id);
setConfirmState(null);
await refreshAll();
} else if (confirmState.kind === "list") {
await deleteAddressList(settings, confirmState.list.id);
setConfirmState(null);
if (!showArchived && selectedListId === confirmState.list.id) {
setSelectedListId("");
setAddressListEntries([]);
}
await refreshBooks();
} else if (confirmState.kind === "contact") {
await deleteContact(settings, confirmState.contact.id);
if (selectedContactId === confirmState.contact.id) setSelectedContactId("");
setConfirmState(null);
await refreshBooks();
await refreshContacts(selectedBookId, query);
} else {
await deleteAddressSyncSource(settings, confirmState.source.id);
if (syncInspector?.source.id === confirmState.source.id) {
setSyncInspector(null);
setSyncPlan(null);
setSyncDiagnostics([]);
setSyncTombstones([]);
setSyncConflicts([]);
setConflictDialog(null);
}
setConfirmState(null);
setNotice("Sync source disconnected. Local contacts were kept.");
await refreshBooks();
await refreshContacts(selectedBookId, query);
}
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function restoreDeletedList(list: AddressList) {
setSaving(true);
setError("");
setNotice("");
try {
const restored = await restoreAddressList(settings, list.id);
setNotice(`Restored "${list.name}".`);
setSelectedBookId(restored.address_book_id);
setSelectedListId(restored.id);
await refreshBooks();
await refreshListEntries(restored.id);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function restoreBook(book: AddressBook) {
setSaving(true);
setError("");
setNotice("");
try {
await restoreAddressBook(settings, book.id);
setNotice(`Restored "${book.name}".`);
await refreshAll();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function restoreDeletedContact(contact: Contact) {
setSaving(true);
setError("");
setNotice("");
try {
await restoreContact(settings, contact.id);
setNotice(`Restored "${contact.display_name}".`);
await refreshBooks();
await refreshContacts(selectedBookId, query);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function openAddMembersDialog() {
if (!selectedBook || !selectedList) return;
setMemberQuery("");
setMemberTargetByContactId({});
setMemberDialogOpen(true);
setError("");
try {
setMemberCandidates(await listContacts(settings, { addressBookId: selectedBook.id, limit: 500, includeDeleted: showArchived }));
} catch (err) {
setError(errorMessage(err));
}
}
async function addContactToAddressList(
list: AddressList,
contact: Contact,
targetOption: AddressListTargetOption | null = null,
successMessage = `Added "${contact.display_name}" to "${list.name}".`
) {
const option = targetOption ?? selectedTargetOptionForContact(contact);
const reason = addContactToListReason(list, contact, option?.key ?? "");
if (reason) {
setError(reason);
return;
}
if (!option) {
setError("This contact has no available target left for the selected list.");
return;
}
setSaving(true);
setError("");
setNotice("");
try {
const currentEntries = list.id === selectedListId ? addressListEntries : await listAddressListEntries(settings, list.id);
if (currentEntries.some((entry) => addressListEntryKey(entry) === `${contact.id}:${option.key}`)) {
setNotice(`"${option.label}" is already in "${list.name}".`);
return;
}
await createAddressListEntry(settings, list.id, { contact_id: contact.id, ...option.payload });
setNotice(successMessage);
await refreshBooks();
if (list.id === selectedListId) await refreshListEntries(list.id);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function removeAddressListEntry(entry: AddressListEntry) {
if (!selectedList || !selectedContact) return;
setSaving(true);
setError("");
setNotice("");
try {
await deleteAddressListEntry(settings, entry.id);
setNotice(`Removed "${entryTargetLabel(entry)}" for "${selectedContact.display_name}" from "${selectedList.name}".`);
await refreshBooks();
await refreshListEntries(selectedList.id);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
function handleContactDragStart(event: ReactDragEvent<HTMLButtonElement>, contact: Contact) {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(ADDRESS_CONTACT_DRAG_TYPE, contact.id);
event.dataTransfer.setData("text/plain", contact.display_name);
}
function dragContactFromEvent(event: ReactDragEvent<HTMLElement>): Contact | null {
const contactId = event.dataTransfer.getData(ADDRESS_CONTACT_DRAG_TYPE);
if (!contactId) return null;
return contacts.find((contact) => contact.id === contactId) ?? memberCandidates.find((contact) => contact.id === contactId) ?? null;
}
function handleTreeDragOver(event: ReactDragEvent<HTMLElement>, node: AddressTreeNode) {
if (node.kind !== "list" || !node.list) return;
const hasContact = Array.from(event.dataTransfer.types).includes(ADDRESS_CONTACT_DRAG_TYPE);
if (!hasContact) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setDropTargetListId(node.list.id);
}
function handleTreeDragLeave(_event: ReactDragEvent<HTMLElement>, node: AddressTreeNode) {
if (node.kind === "list" && node.list?.id === dropTargetListId) setDropTargetListId("");
}
async function handleTreeDrop(event: ReactDragEvent<HTMLElement>, node: AddressTreeNode) {
if (node.kind !== "list" || !node.list) return;
event.preventDefault();
setDropTargetListId("");
const contact = dragContactFromEvent(event);
if (!contact) return;
await addContactToAddressList(node.list, contact, null, `Added "${contact.display_name}" to "${node.list.name}" by drag and drop.`);
}
async function exportSelectedBook() {
if (!selectedBook) return;
setSaving(true);
setError("");
setNotice("");
try {
const content = await exportAddressBookVcards(settings, selectedBook.id);
downloadText(safeFilename(selectedBook.name), content);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function exportOneContact(contact: Contact) {
setSaving(true);
setError("");
setNotice("");
try {
const content = await exportContactVcard(settings, contact.id);
downloadText(safeFilename(contact.display_name), content);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function submitVcardImport(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedBook) return;
setSaving(true);
setError("");
setNotice("");
try {
const result = await importAddressBookVcards(settings, selectedBook.id, vcardContent);
setImportOpen(false);
setVcardContent("");
const issueCount = result.issues.length;
setNotice(`Imported ${result.imported} contact${result.imported === 1 ? "" : "s"}${result.skipped ? `, skipped ${result.skipped}` : ""}${issueCount ? ` (${issueCount} import issue${issueCount === 1 ? "" : "s"})` : ""}.`);
await refreshBooks();
await refreshContacts(selectedBook.id, query);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
function openCardDavDialog() {
setCardDavForm(EMPTY_CARDDAV_FORM);
setCardDavDiscovery([]);
setCardDavCredentialsError("");
setCardDavOpen(true);
}
function cardDavPayload() {
const credentialRef = cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id
? `credential-envelope:${cardDavForm.credential_envelope_id}`
: null;
return {
url: cardDavForm.collection_url,
auth_type: cardDavForm.auth_type,
username: cardDavForm.username || null,
password: credentialRef ? null : cardDavForm.password || null,
bearer_token: credentialRef ? null : cardDavForm.bearer_token || null,
credential_ref: credentialRef
};
}
async function discoverCardDavSources() {
setSaving(true);
setError("");
setNotice("");
try {
const discovered = await discoverCardDavAddressBooks(settings, cardDavPayload());
setCardDavDiscovery(discovered);
setNotice(`Found ${discovered.length} CardDAV address book${discovered.length === 1 ? "" : "s"}.`);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
function useDiscoveredCardDavBook(item: AddressCardDavAddressBook) {
setCardDavForm((current) => ({
...current,
collection_url: item.collection_url,
display_name: current.display_name || item.display_name || selectedBook?.name || "CardDAV address book"
}));
}
async function submitCardDavSource(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedBook) return;
setSaving(true);
setError("");
setNotice("");
try {
await createCardDavSyncSource(settings, selectedBook.id, {
collection_url: cardDavForm.collection_url,
display_name: cardDavForm.display_name || selectedBook.name,
auth_type: cardDavForm.auth_type,
username: cardDavForm.username || null,
password: cardDavForm.credential_envelope_id ? null : cardDavForm.password || null,
bearer_token: cardDavForm.credential_envelope_id ? null : cardDavForm.bearer_token || null,
credential_ref: cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id
? `credential-envelope:${cardDavForm.credential_envelope_id}`
: null,
sync_direction: cardDavForm.sync_direction,
read_only: cardDavForm.sync_direction === "read_only" || cardDavForm.sync_direction === "import"
});
setCardDavOpen(false);
setCardDavDiscovery([]);
setNotice("CardDAV 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),
listAddressSyncTombstones(settings, source.id),
listAddressSyncConflicts(settings, source.id, "open")
]);
setSyncDiagnostics(diagnostics);
setSyncTombstones(tombstones);
setSyncConflicts(conflicts);
}
async function openSyncInspector(source: AddressSyncSource) {
setSyncInspector({ source });
setSyncPlan(null);
setError("");
try {
await loadSyncDetails(source);
} catch (err) {
setError(errorMessage(err));
}
}
async function previewSelectedSync(source = selectedSyncSource) {
if (!source) return;
setSaving(true);
setError("");
setNotice("");
try {
const plan = await previewAddressSyncSource(settings, source.id);
setSyncPlan(plan);
setSyncInspector({ source: plan.sync_source });
setNotice(`Sync preview: ${planSummary(plan)}.`);
await loadSyncDetails(plan.sync_source);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function runSelectedSync(source = selectedSyncSource) {
if (!source) return;
setSaving(true);
setError("");
setNotice("");
try {
const plan = await runAddressSyncSource(settings, source.id);
setSyncPlan(plan);
setSyncInspector({ source: plan.sync_source });
setNotice(`Sync completed: ${planSummary(plan)}.`);
await refreshBooks();
await refreshContacts(selectedBookId, query);
await loadSyncDetails(plan.sync_source);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function toggleSyncSourceEnabled(source: AddressSyncSource) {
setSaving(true);
setError("");
setNotice("");
try {
const next = await updateAddressSyncSource(settings, source.id, { enabled: !source.enabled });
setSyncInspector({ source: next });
setNotice(next.enabled ? "Sync source enabled." : "Sync source disabled.");
await refreshBooks();
await loadSyncDetails(next);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
function openConflictReview(conflict: AddressSyncConflict) {
setConflictDialog(conflict);
setConflictMergeChoices(defaultConflictMergeChoices(conflict));
}
function setConflictMergeChoice(field: string, choice: ConflictMergeChoice) {
setConflictMergeChoices((current) => ({ ...current, [field]: choice }));
}
async function resolveConflictWith(
conflict: AddressSyncConflict,
resolution: "keep_local" | "use_remote" | "merge" | "manual" | "ignored",
status: "resolved" | "ignored" = "resolved",
mergedPayload: Record<string, unknown> | null = null
) {
setSaving(true);
setError("");
setNotice("");
try {
await resolveAddressSyncConflict(settings, conflict.id, resolution, status, { mergedPayload });
setConflictDialog(null);
setConflictMergeChoices({});
setNotice(status === "ignored" ? "Sync conflict ignored." : "Sync conflict resolved.");
if (syncInspector) await loadSyncDetails(syncInspector.source);
await refreshBooks();
await refreshContacts(selectedBookId, query);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function applyMergedConflict(conflict: AddressSyncConflict) {
const mergedPayload = buildMergedConflictPayload(conflict, conflictMergeChoices);
if (!mergedPayload) {
setError("This conflict does not contain local and remote field payloads that can be merged.");
return;
}
await resolveConflictWith(conflict, "merge", "resolved", mergedPayload);
}
function renderTreeNodeContent(node: AddressTreeNode) {
if (node.kind === "source") {
const bookCount = books.filter((book) => sourceGroupForBook(book) === node.sourceGroup).length;
return <span className="address-tree-node-content"><strong>{node.label}</strong><small>{bookCount} book{bookCount === 1 ? "" : "s"}</small></span>;
}
if (node.kind === "scope" && node.scope) {
const contactCount = books.filter((book) => sourceGroupForBook(book) === node.sourceGroup && book.scope_type === node.scope).reduce((sum, book) => sum + book.contact_count, 0);
return <span className="address-tree-node-content"><strong>{node.label}</strong><small>{contactCount} contact{contactCount === 1 ? "" : "s"}</small></span>;
}
if (node.kind === "book" && node.book) {
return (
<span className="address-tree-node-content">
<strong>{node.book.name}</strong>
<small>{node.book.contact_count} contact{node.book.contact_count === 1 ? "" : "s"} · {node.book.source_kind}</small>
</span>
);
}
if (node.kind === "list" && node.list) {
return (
<span className="address-tree-node-content">
<strong>{node.list.name}</strong>
<small>{node.list.entry_count} entr{node.list.entry_count === 1 ? "y" : "ies"}</small>
</span>
);
}
return <span>{node.label}</span>;
}
function renderSelectedBookPanel() {
if (!selectedBook) return <p className="address-empty-note">No address book selected.</p>;
return (
<div className="address-source-summary">
<div>
<strong>{selectedBook.name}</strong>
<p>{selectedBook.description || "No description."}</p>
</div>
<div className="status-row">
<StatusBadge status={scopeLabel(selectedBook.scope_type)} />
<StatusBadge status={selectedBook.source_kind} />
{selectedBook.sync_status && <StatusBadge status={`sync ${selectedBook.sync_status}`} />}
{selectedBook.read_only && <StatusBadge status="read-only" />}
{selectedBook.deleted_at && <StatusBadge status="archived" />}
</div>
{selectedBookSyncSources.length > 0 &&
<div className="address-source-subsummary">
<strong>Sync</strong>
{selectedBookSyncSources.map((source) => (
<p key={source.id}>
{syncSourceLabel(source)} · {source.connector_type} · {source.status}
{source.last_success_at ? ` · last success ${formatDateTime(source.last_success_at, ADDRESS_DATE_TIME_OPTIONS)}` : ""}
{source.last_error ? ` · ${source.last_error}` : ""}
</p>
))}
</div>
}
{selectedList &&
<div className="address-source-subsummary">
<strong>{selectedList.name}</strong>
<p>{selectedList.description || "No list description."}</p>
<div className="status-row">
<StatusBadge status={`${selectedList.entry_count} entries`} />
{selectedList.read_only && <StatusBadge status="read-only" />}
{selectedList.deleted_at && <StatusBadge status="archived" />}
</div>
</div>
}
</div>
);
}
function renderSelectedBookActions() {
return (
<>
<Button type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw 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="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="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>
{selectedList ?
selectedList.deleted_at ?
<Button type="button" title="Restore address list" aria-label="Restore address list" disabledReason={restoreListReason(selectedList)} onClick={() => void restoreDeletedList(selectedList)}><RotateCcw size={15} /></Button> :
<>
<Button type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></Button>
<Button type="button" variant="danger" title="Delete address list" aria-label="Delete address list" disabledReason={deleteListReason(selectedList)} onClick={() => setConfirmState({ kind: "list", list: selectedList })}><Trash2 size={15} /></Button>
</> :
selectedBook?.deleted_at ?
<Button type="button" title="Restore address book" aria-label="Restore address book" disabledReason={restoreBookReason(selectedBook)} onClick={() => void restoreBook(selectedBook)}><RotateCcw size={15} /></Button> :
<>
<Button type="button" title="Edit address book" aria-label="Edit address book" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && openEditBookDialog(selectedBook)}><Edit3 size={15} /></Button>
<Button type="button" variant="danger" title="Delete address book" aria-label="Delete address book" disabledReason={selectedBook ? deleteBookReason(selectedBook) : "Select an address book before deleting."} onClick={() => selectedBook && setConfirmState({ kind: "book", book: selectedBook })}><Trash2 size={15} /></Button>
</>
}
</>
);
}
function renderContactRow(contact: Contact) {
const selected = selectedContactId === contact.id;
return (
<SelectionListItem
key={contact.id}
selected={selected}
className={`address-contact-row ${contact.deleted_at ? "is-archived-row" : ""}`}
draggable={!contact.deleted_at && !saving}
onClick={() => setSelectedContactId(contact.id)}
onDragStart={(event) => handleContactDragStart(event, contact)}>
<span className="address-contact-row-main">
<strong>{contact.display_name}</strong>
<small>{primaryEmail(contact) || contact.organization || "No primary email"}</small>
</span>
<span className="address-contact-row-meta">
{contact.deleted_at ? <StatusBadge status="archived" /> : contact.tags.slice(0, 2).map((tag) => <span key={tag} className="address-tag">{tag}</span>)}
</span>
</SelectionListItem>
);
}
function renderContactDetail() {
if (!selectedContact) {
return (
<div className="address-detail-empty">
<strong>No contact selected</strong>
<p>Select a contact from the middle list to view details here.</p>
</div>
);
}
return (
<div className="address-contact-detail">
<header className="address-detail-header">
<div>
<h2>{selectedContact.display_name}</h2>
<p>{[selectedContact.role_title, selectedContact.organization].filter(Boolean).join(" · ") || "Contact"}</p>
</div>
<div className="button-row compact-actions">
{selectedContact.deleted_at ?
<Button type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</Button> :
<>
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
</>
}
</div>
</header>
<div className="status-row">
{selectedContact.deleted_at && <StatusBadge status="archived" />}
{selectedContact.source_kind && <StatusBadge status={selectedContact.source_kind} />}
{selectedContact.tags.map((tag) => <span key={tag} className="address-tag">{tag}</span>)}
</div>
{selectedList &&
<section className="address-detail-section">
<h3>List membership</h3>
<div className="address-membership-list">
{selectedContactListEntries.map((entry) => (
<div className="address-membership-row" key={entry.id}>
<p className="muted">{entryTargetLabel(entry)}</p>
<Button
type="button"
variant="danger"
onClick={() => void removeAddressListEntry(entry)}
disabledReason={removeContactFromListReason()}>
<X size={15} /> Remove
</Button>
</div>
))}
</div>
</section>
}
<section className="address-detail-section">
<h3>Email</h3>
{selectedContact.emails.length === 0 ? <p className="muted">No email addresses.</p> :
<dl className="address-detail-list">
{selectedContact.emails.map((email, index) => (
<div key={email.id ?? `${email.email}-${index}`}>
<dt>{email.label || "email"}{email.is_primary ? " · primary" : ""}</dt>
<dd>{email.email}</dd>
</div>
))}
</dl>
}
</section>
<section className="address-detail-section">
<h3>Phone</h3>
{selectedContact.phones.length === 0 ? <p className="muted">No phone numbers.</p> :
<dl className="address-detail-list">
{selectedContact.phones.map((phone, index) => (
<div key={phone.id ?? `${phone.phone}-${index}`}>
<dt>{phone.label || "phone"}{phone.is_primary ? " · primary" : ""}</dt>
<dd>{phone.phone}</dd>
</div>
))}
</dl>
}
</section>
<section className="address-detail-section">
<h3>Postal addresses</h3>
{selectedContact.postal_addresses.length === 0 ? <p className="muted">No postal addresses.</p> :
<dl className="address-detail-list">
{selectedContact.postal_addresses.map((address, index) => (
<div key={address.id ?? `${address.label}-${index}`}>
<dt>{address.label || "address"}{address.is_primary ? " · primary" : ""}</dt>
<dd>{formatPostalAddress(address) || "No formatted address."}</dd>
</div>
))}
</dl>
}
</section>
{selectedContact.note &&
<section className="address-detail-section">
<h3>Note</h3>
<p>{selectedContact.note}</p>
</section>
}
</div>
);
}
return (
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading address books..." className="address-workspace-frame">
<div className="address-book-workspace">
<aside className="file-tree-panel address-tree-panel" aria-label="Address sources">
<header className="address-panel-header address-tree-header">
<div>
<h2>Address books</h2>
<p>{books.length} book{books.length === 1 ? "" : "s"}</p>
</div>
<div className="button-row compact-actions address-icon-actions">
{renderSelectedBookActions()}
</div>
</header>
<div className="address-tree-filter-row">
<ToggleSwitch
label="Show archived"
checked={showArchived}
disabled={Boolean(toggleArchiveReason)}
onChange={() => {
setContactPage(1);
setShowArchived((current) => !current);
}}
help="Include archived address books, lists, and contacts."
/>
</div>
<div className="file-tree-list address-tree-list">
{addressTreeNodes.length === 0 ?
<div className="address-empty-note">No address books found.</div> :
<ExplorerTree
nodes={addressTreeNodes}
getNodeId={(node) => node.id}
getNodeLabel={(node) => node.label}
getNodeChildren={(node) => node.children}
activeId={activeTreeId}
expandedIds={expandedTreeIds}
onOpen={(node) => openTreeNode(node)}
onToggle={(node) => toggleTreeNode(node)}
renderNodeContent={(node) => renderTreeNodeContent(node)}
getNodeWrapClassName={(node) => node.kind === "list" && node.list?.id === dropTargetListId ? "is-drop-target" : undefined}
onDragOver={handleTreeDragOver}
onDragLeave={handleTreeDragLeave}
onDrop={(event, node) => void handleTreeDrop(event, node)}
/>
}
</div>
<div className="address-tree-summary">
{renderSelectedBookPanel()}
</div>
</aside>
<section className="address-list-panel" aria-label="Contacts">
<header className="address-panel-header">
<div>
<h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2>
<p>
{contactTotal} contact{contactTotal === 1 ? "" : "s"}
{selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}
</p>
</div>
<div className="button-row compact-actions">
{selectedList &&
<Button type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</Button>
}
<Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</Button>
</div>
</header>
<div className="address-contact-toolbar">
<input
value={query}
onChange={(event) => {
setContactPage(1);
setQuery(event.target.value);
}}
placeholder="Search contacts" />
{selectedBook?.read_only && <StatusBadge status="read-only" />}
{selectedBook?.deleted_at && <StatusBadge status="archived" />}
</div>
<div className="address-contact-list">
{visibleContacts.length === 0 ?
<div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> :
<SelectionList label="Contacts" className="address-contact-selection-list">
{visibleContacts.map(renderContactRow)}
</SelectionList>
}
</div>
<DataGridPaginationBar
page={contactPage}
pageSize={contactPageSize}
totalRows={contactTotal}
pageSizeOptions={[25, 50, 100, 200]}
disabled={loading || saving || !selectedBook}
className="address-contact-pagination"
ariaLabel="Contact pagination"
onPageChange={setContactPage}
onPageSizeChange={(pageSize) => {
setContactPageSize(pageSize);
setContactPage(1);
}} />
</section>
<section className="address-detail-panel" aria-label="Contact detail">
{renderContactDetail()}
</section>
</div>
</LoadingFrame>
<Dialog
open={Boolean(bookDialog)}
title={bookDialog?.mode === "edit" ? "Edit address book" : "Add address book"}
onClose={() => setBookDialog(null)}
closeDisabled={saving}
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<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">
<FormField label="Scope">
<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>
<option value="group" disabled={auth.groups.length === 0}>Group</option>
<option value="tenant">Tenant</option>
{canAdminBooks && <option value="system">System</option>}
</select>
</FormField>
{bookForm.scope_type === "group" &&
<FormField label="Group">
<select value={bookForm.group_id} disabled={bookDialog?.mode === "edit"} onChange={(event) => setBookForm((current) => ({ ...current, group_id: event.target.value }))}>
{auth.groups.length === 0 && <option value="">No groups available</option>}
{auth.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
</select>
</FormField>
}
</div>
<FormField label="Name"><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>
</Dialog>
<Dialog
open={Boolean(listDialog)}
title={listDialog?.mode === "edit" ? "Edit address list" : "Add address list"}
onClose={() => setListDialog(null)}
closeDisabled={saving}
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<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)}>
<p className="muted">Lists group contacts inside the selected address book.</p>
<FormField label="Address book">
<input value={selectedBook?.name ?? ""} disabled readOnly />
</FormField>
<FormField label="Name">
<input value={listForm.name} onChange={(event) => setListForm((current) => ({ ...current, name: event.target.value }))} autoFocus />
</FormField>
<FormField label="Description">
<textarea value={listForm.description} onChange={(event) => setListForm((current) => ({ ...current, description: event.target.value }))} rows={3} />
</FormField>
</form>
</Dialog>
<Dialog
open={Boolean(contactDialog)}
title={contactDialog?.mode === "edit" ? "Edit contact" : "Add contact"}
onClose={() => setContactDialog(null)}
closeDisabled={saving}
className="address-contact-dialog"
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<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">
<FormField label="Display name"><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">
<FormField label="Organization"><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">
{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>
<input value={email.label} placeholder="Label" onChange={(event) => updateEmailRow(email.rowId, { label: event.target.value })} />
<input type="email" value={email.email} placeholder="Email address" onChange={(event) => updateEmailRow(email.rowId, { email: event.target.value })} />
<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>
<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">
{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>
<input value={phone.label} placeholder="Label" onChange={(event) => updatePhoneRow(phone.rowId, { label: event.target.value })} />
<input value={phone.phone} placeholder="Phone number" onChange={(event) => updatePhoneRow(phone.rowId, { phone: event.target.value })} />
<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>
<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">
{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>
<input value={address.label} placeholder="Label" onChange={(event) => updatePostalAddressRow(address.rowId, { label: event.target.value })} />
<input value={address.street} placeholder="Street" onChange={(event) => updatePostalAddressRow(address.rowId, { street: event.target.value })} />
<input value={address.postal_code} placeholder="Postal code" onChange={(event) => updatePostalAddressRow(address.rowId, { postal_code: event.target.value })} />
<input value={address.locality} placeholder="Locality" onChange={(event) => updatePostalAddressRow(address.rowId, { locality: event.target.value })} />
<input value={address.region} placeholder="Region" onChange={(event) => updatePostalAddressRow(address.rowId, { region: event.target.value })} />
<input value={address.country} placeholder="Country" onChange={(event) => updatePostalAddressRow(address.rowId, { country: event.target.value })} />
<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>
<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>
</Dialog>
<Dialog
open={memberDialogOpen}
title={selectedList ? `Add contacts to ${selectedList.name}` : "Add contacts to list"}
onClose={() => setMemberDialogOpen(false)}
closeDisabled={saving}
className="address-member-dialog"
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">
<input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus />
</div>
<div className="address-member-candidate-list" role="list">
{memberCandidateContacts.length === 0 ?
<div className="address-empty-note">No contacts available to add.</div> :
memberCandidateContacts.map((contact) => {
const targetOptions = availableTargetOptionsForContact(contact);
const selectedTarget = selectedTargetOptionForContact(contact);
return (
<div className="address-member-candidate" key={contact.id}>
<span className="address-contact-row-main">
<strong>{contact.display_name}</strong>
<small>{primaryEmail(contact) || contact.organization || "No primary email"}</small>
</span>
<select
value={selectedTarget?.key ?? ""}
aria-label={`Target for ${contact.display_name}`}
onChange={(event) => setMemberTarget(contact.id, event.target.value)}
disabled={saving || targetOptions.length === 0}>
{targetOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
</select>
<Button
type="button"
onClick={() => selectedList && void addContactToAddressList(selectedList, contact, selectedTarget)}
disabledReason={addContactToListReason(selectedList, contact, selectedTarget?.key ?? "")}>
<Plus size={15} /> Add
</Button>
</div>
);
})
}
</div>
<p className="muted small-text">Tip: drag and drop adds the first available target. Use this dialog for a specific email or postal address.</p>
</div>
</Dialog>
<Dialog
open={importOpen}
title="Import vCard"
onClose={() => setImportOpen(false)}
closeDisabled={saving}
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>
</>
}>
<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>
</Dialog>
<Dialog
open={cardDavOpen}
title="Connect CardDAV"
onClose={() => setCardDavOpen(false)}
closeDisabled={saving}
className="address-sync-dialog"
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="button" onClick={() => void discoverCardDavSources()} disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV URL before discovery."])}><Search size={16} /> Discover</Button>
<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)}>
<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">
<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>
<option value="basic">Basic</option>
<option value="bearer">Bearer token</option>
</select>
</FormField>
<FormField label="Sync direction">
<select value={cardDavForm.sync_direction} onChange={(event) => setCardDavForm((current) => ({ ...current, sync_direction: event.target.value as CardDavFormState["sync_direction"] }))}>
<option value="read_only">Read-only</option>
<option value="import">One-way import</option>
<option value="two_way">Two-way marker</option>
</select>
</FormField>
<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>
{cardDavForm.auth_type !== "none" &&
<FormField label="Reusable credential">
<select
value={cardDavForm.credential_envelope_id}
onChange={(event) => {
const credentialId = event.target.value;
const credential = cardDavCredentials.find((item) => item.id === credentialId);
const username = credential?.public_data?.username;
setCardDavForm((current) => ({
...current,
credential_envelope_id: credentialId,
username: credentialId && username ? String(username) : "",
password: "",
bearer_token: ""
}));
}}>
<option value="">Enter source-specific credentials below</option>
{cardDavCredentials.map((credential) => (
<option key={credential.id} value={credential.id}>
{credential.name}{credential.public_data.username ? ` (${String(credential.public_data.username)})` : ""}
</option>
))}
</select>
</FormField>
}
{cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>}
{cardDavForm.auth_type === "basic" &&
<div className="form-grid two">
<FormField label="Username">
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} disabled={Boolean(cardDavForm.credential_envelope_id)} />
</FormField>
{!cardDavForm.credential_envelope_id &&
<FormField label="Password">
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
</FormField>
}
</div>
}
{cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id &&
<FormField label="Bearer token">
<PasswordField value={cardDavForm.bearer_token} onValueChange={(value) => setCardDavForm((current) => ({ ...current, bearer_token: value }))} autoComplete="new-password" />
</FormField>
}
{cardDavDiscovery.length > 0 &&
<SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">
{cardDavDiscovery.map((item) => (
<SelectionListItem
selected={cardDavForm.collection_url === item.collection_url}
className="address-sync-result-row"
key={item.collection_url}
onClick={() => useDiscoveredCardDavBook(item)}>
<strong>{item.display_name || item.collection_url}</strong>
<small>{item.collection_url}</small>
</SelectionListItem>
))}
</SelectionList>
}
</form>
</Dialog>
<Dialog
open={Boolean(syncInspector)}
title={syncInspector ? `Sync: ${syncSourceLabel(syncInspector.source)}` : "Sync"}
onClose={() => setSyncInspector(null)}
closeDisabled={saving}
className="address-sync-dialog"
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</Button>
<Button type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</Button>
<Button type="button" variant="primary" onClick={() => syncInspector && void runSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [!canWriteSync, "You need permission to run address sync."], [saving, savingReason])}>Run sync</Button>
</>
}>
{syncInspector &&
<div className="address-sync-inspector">
<div className="address-sync-source-card">
<div>
<strong>{syncSourceLabel(syncInspector.source)}</strong>
<p>{syncInspector.source.external_address_book_ref || "No external reference."}</p>
</div>
<div className="status-row">
<StatusBadge status={syncInspector.source.connector_type} />
<StatusBadge status={syncInspector.source.status} />
{syncInspector.source.read_only && <StatusBadge status="read-only" />}
</div>
<p className="muted">Last attempt: {formatDateTime(syncInspector.source.last_attempted_at, ADDRESS_DATE_TIME_OPTIONS)} · Last success: {formatDateTime(syncInspector.source.last_success_at, ADDRESS_DATE_TIME_OPTIONS)}</p>
{syncInspector.source.last_error && <DismissibleAlert tone="danger" resetKey={syncInspector.source.last_error}>{syncInspector.source.last_error}</DismissibleAlert>}
<div className="button-row compact-actions">
<Button
type="button"
onClick={() => void toggleSyncSourceEnabled(syncInspector.source)}
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
{syncInspector.source.enabled ? "Disable" : "Enable"}
</Button>
<Button
type="button"
variant="danger"
onClick={() => setConfirmState({ kind: "sync-source", source: syncInspector.source })}
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
Disconnect
</Button>
</div>
</div>
<section className="address-detail-section">
<h3>Preview</h3>
<p className="muted">{planSummary(syncPlan)}</p>
{syncPlan &&
<div className="address-sync-plan-grid">
{syncPlan.items.slice(0, 100).map((item, index) => (
<div className="address-sync-plan-row" key={`${item.href ?? item.message ?? "item"}-${index}`}>
<StatusBadge status={item.action} label={syncActionLabel(item.action)} />
<span>{item.display_name || item.href || item.message || "Sync item"}</span>
{item.message && <small>{item.message}</small>}
</div>
))}
</div>
}
</section>
<section className="address-detail-section">
<h3>Conflicts</h3>
{syncConflicts.length === 0 ? <p className="muted">No open conflicts.</p> :
<div className="address-sync-record-list">
{syncConflicts.map((conflict) => (
<div className="address-sync-record-row" key={conflict.id}>
<span><strong>{conflict.field_path}</strong><small>{conflict.resource_href || conflict.remote_uid || conflict.contact_id}</small></span>
<Button type="button" onClick={() => openConflictReview(conflict)} disabledReason={disabledReason([saving, savingReason])}>Review</Button>
</div>
))}
</div>
}
</section>
<section className="address-detail-section">
<h3>Diagnostics</h3>
{syncDiagnostics.length === 0 ? <p className="muted">No diagnostics recorded.</p> :
<div className="address-sync-record-list">
{syncDiagnostics.slice(0, 20).map((diagnostic) => (
<div className="address-sync-record-row" key={diagnostic.id}>
<StatusBadge status={diagnostic.severity} />
<span><strong>{diagnostic.code}</strong><small>{diagnostic.message}</small></span>
</div>
))}
</div>
}
</section>
<section className="address-detail-section">
<h3>Tombstones</h3>
{syncTombstones.length === 0 ? <p className="muted">No tombstones recorded.</p> :
<div className="address-sync-record-list">
{syncTombstones.slice(0, 20).map((tombstone) => (
<div className="address-sync-record-row" key={tombstone.id}>
<span><strong>{tombstone.remote_uid || tombstone.resource_href || tombstone.contact_id}</strong><small>{formatDateTime(tombstone.synced_at || tombstone.remote_deleted_at || tombstone.local_deleted_at, ADDRESS_DATE_TIME_OPTIONS)}</small></span>
</div>
))}
</div>
}
</section>
</div>
}
</Dialog>
<Dialog
open={Boolean(conflictDialog)}
title="Review sync conflict"
onClose={() => { setConflictDialog(null); setConflictMergeChoices({}); }}
className="address-sync-dialog"
footerClassName="button-row compact-actions"
footer={conflictDialog && <>
<Button type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</Button>
<Button
type="button"
onClick={() => void resolveConflictWith(conflictDialog, "ignored", "ignored")}
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
Ignore
</Button>
<Button
type="button"
onClick={() => void resolveConflictWith(conflictDialog, "keep_local")}
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
Keep local
</Button>
<Button
type="button"
onClick={() => void applyMergedConflict(conflictDialog)}
disabledReason={disabledReason(
[!canWriteSync, "You need permission to resolve sync conflicts."],
[!canMergeConflict(conflictDialog), "This conflict does not contain both local and remote field payloads."],
[saving, savingReason]
)}>
Apply merged
</Button>
<Button
type="button"
variant="primary"
onClick={() => void resolveConflictWith(conflictDialog, "use_remote")}
disabledReason={disabledReason(
[!canWriteSync, "You need permission to resolve sync conflicts."],
[!canApplyRemoteConflict(conflictDialog), "This conflict does not contain a stored remote payload that can be applied automatically."],
[saving, savingReason]
)}>
Use remote
</Button>
</>}>
{conflictDialog &&
<div className="address-conflict-review">
<div className="address-sync-source-card">
<div>
<strong>{conflictDialog.field_path}</strong>
<p>{conflictDialog.resource_href || conflictDialog.remote_uid || conflictDialog.contact_id || "No remote identifier."}</p>
</div>
<div className="status-row">
<StatusBadge status={conflictDialog.status} />
{conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />}
</div>
{conflictDialog.metadata?.message && <p className="muted">{String(conflictDialog.metadata.message)}</p>}
{!canApplyRemoteConflict(conflictDialog) && <DismissibleAlert tone="warning" dismissible={false}>This conflict predates stored field payloads or came from a stale write. It can be marked resolved or ignored, but the remote value cannot be applied automatically.</DismissibleAlert>}
</div>
<div className="address-conflict-grid">
<div className="address-conflict-grid-header">Field</div>
<div className="address-conflict-grid-header">Local</div>
<div className="address-conflict-grid-header">Remote</div>
<div className="address-conflict-grid-header">Use</div>
{conflictFieldRows(conflictDialog).map((row) => (
<div className={`address-conflict-row ${row.differs ? "has-difference" : ""}`} key={row.field}>
<strong>{row.field}</strong>
<span>{row.local}</span>
<span>{row.remote}</span>
<div>
{canMergeConflict(conflictDialog) ?
<SegmentedControl<ConflictMergeChoice>
className="address-conflict-choice"
role="group"
size="equal"
ariaLabel={`Source for ${row.field}`}
options={[{ id: "local", label: "Local" }, { id: "remote", label: "Remote" }]}
value={conflictMergeChoices[row.field] ?? "local"}
onChange={(choice) => setConflictMergeChoice(row.field, choice)}
/> :
"—"
}
</div>
</div>
))}
</div>
</div>
}
</Dialog>
<ConfirmDialog
open={Boolean(confirmState)}
title={confirmState?.kind === "book" ? "Delete address book" : confirmState?.kind === "list" ? "Delete address list" : confirmState?.kind === "sync-source" ? "Disconnect sync source" : "Delete contact"}
message={
confirmState?.kind === "book" ? `Delete "${confirmState.book.name}" and hide its contacts?` :
confirmState?.kind === "list" ? `Delete "${confirmState.list.name}" and hide its entries?` :
confirmState?.kind === "sync-source" ? `Disconnect "${confirmState.source.display_name}" from this address book? Local contacts are kept; source diagnostics, tombstones, and conflicts are removed.` :
`Delete "${confirmState?.contact.display_name}"?`
}
confirmLabel={confirmState?.kind === "sync-source" ? "Disconnect" : "Delete"}
tone="danger"
busy={saving}
onCancel={() => setConfirmState(null)}
onConfirm={() => void confirmDelete()}
/>
</div>
);
}