7 Commits
Author SHA1 Message Date
zemion ec32ad2c37 feat(imports): resume persisted address runs 2026-08-19 20:39:21 +02:00
zemion 7e5c1cf10c feat: align addresses with shared UI foundations 2026-08-18 21:32:34 +02:00
zemion 63d59a7eaa Adopt shared WebUI layout primitives 2026-08-18 11:30:39 +02:00
zemion e6165b4d26 Adopt shared WebUI layout primitives 2026-08-18 10:42:50 +02:00
zemion aa390a69b9 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 21:07:51 +02:00
zemion aab4019612 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:12 +02:00
zemion 3b7f17f862 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:27 +02:00
15 changed files with 501 additions and 118 deletions
+2
View File
@@ -142,11 +142,13 @@ dist
.policy-test-build/
.template-preview-test-build/
.import-test-build/
.import-run-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
webui/.import-run-test-build/
# ---> Python
# Byte-compiled / optimized / DLL files
+8
View File
@@ -207,6 +207,14 @@ matches its recorded post-apply hash. Arbitrary transforms remain Dataflow's
responsibility; Files and Datasources are optional origins, not prerequisites
for direct upload.
Persisted import runs can be resumed through `/address-book?import_run=<id>`.
The WebUI reloads the bounded run projection, selects its address book and
mapping version, and restores statistics, diagnostics, effects, and lifecycle
state. Apply and rollback both carry the reviewed plan hash. The normal read
projection never includes uploaded bytes or private before-images, and an
unknown, expired, hidden, or cross-tenant id is presented as one unavailable
state so the deep link cannot enumerate another tenant's imports.
## Quality, Deduplication, And Recovery
Quality is evidence about a concrete contact point, separate from communication
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.15",
"version": "0.1.18",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"README.md"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-addresses"
version = "0.1.15"
version = "0.1.18"
description = "GovOPlaN reusable address and recipient-source module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"defusedxml>=0.7.1",
"govoplan-core>=0.1.15",
"govoplan-core>=0.1.18",
"ldap3>=2.9.1,<3",
"openpyxl>=3.1.5,<4",
]
@@ -151,6 +151,7 @@ class AddressImportCommitRequest(BaseModel):
class AddressImportRollbackRequest(BaseModel):
expected_plan_hash: str = Field(min_length=64, max_length=64)
reason: str = Field(min_length=3, max_length=2000)
@@ -336,6 +336,8 @@ def rollback_address_import(
payload: AddressImportRollbackRequest,
) -> AddressImportRun:
run = get_import_run(session, principal, run_id)
if run.plan_hash != payload.expected_plan_hash:
raise AddressBookError("The reviewed import plan changed; reload the import run.")
if run.status == "rolled_back":
return run
if run.status != "applied":
+18 -3
View File
@@ -26,6 +26,7 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.provider_governance import (
@@ -292,7 +293,7 @@ LDAP_PROVIDER = ExternalProviderDeclaration(
manifest = ModuleManifest(
id="addresses",
name="Addresses",
version="0.1.15",
version="0.1.18",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox", "connectors"),
provides_interfaces=(
@@ -313,6 +314,17 @@ manifest = ModuleManifest(
package_name="@govoplan/addresses-webui",
routes=(FrontendRoute(path="/address-book", component="AddressBookPage", required_any=("addresses:contact:read",), order=80),),
nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),),
product_areas=(
ProductAreaContribution(
id="people-responsibility",
module_id="addresses",
label="i18n:govoplan-core.product_area.people_responsibility",
icon="users",
description="i18n:govoplan-core.product_area.people_responsibility_description",
surface_ids=("addresses.nav.address.book", "addresses.route.address.book"),
order=70,
),
),
view_surfaces=(
ViewSurface(id="addresses.page", module_id="addresses", kind="route", label="Address Book", order=80),
ViewSurface(id="addresses.sources", module_id="addresses", kind="section", label="Address sources", order=10),
@@ -434,8 +446,11 @@ manifest = ModuleManifest(
"CSV and XLSX files can be mapped with scoped, reusable profile versions. Each preview validates headers, "
"encodings, source keys, duplicates, blank values, workbook limits, and contact identity before any mutation. "
"The reviewed input hash and plan hash are retained with row-level effects and diagnostics. Apply is idempotent, "
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. XLSX formulas, "
"macros, and legacy workbook formats are never executed or imported."
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. A persisted run "
"can be reopened with its run link after navigation or reload; previewed, applied, rolled-back, expired, and "
"unavailable states remain explicit. Both apply and rollback submit the reviewed plan hash. Missing, expired, "
"hidden, and cross-tenant runs disclose no source payload. XLSX formulas, macros, and legacy workbook formats "
"are never executed or imported."
),
layer="configured",
documentation_types=("admin", "user"),
+66 -1
View File
@@ -19,6 +19,7 @@ from govoplan_addresses.backend.import_schemas import (
from govoplan_addresses.backend.imports import (
apply_address_import,
create_import_profile,
get_import_run,
import_run_payload,
preview_address_import,
rollback_address_import,
@@ -44,6 +45,12 @@ class Principal:
}
class OtherTenantPrincipal(Principal):
@property
def tenant_id(self) -> str:
return "tenant-2"
def encoded(value: str) -> str:
return base64.b64encode(value.encode()).decode()
@@ -121,11 +128,69 @@ class AddressTabularImportTests(unittest.TestCase):
self.session,
self.principal,
run.id,
AddressImportRollbackRequest(reason="The operator selected the wrong monthly file."),
AddressImportRollbackRequest(
expected_plan_hash=run.plan_hash,
reason="The operator selected the wrong monthly file.",
),
)
self.assertEqual("rolled_back", rolled_back.status)
self.assertEqual(0, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
def test_rollback_rejects_a_stale_review_hash(self) -> None:
run = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="contacts.csv",
content_base64=encoded(
"id;first;last;email;organization\n"
"1;Ada;Lovelace;ada@example.test;Analysis Office\n"
),
),
)
apply_address_import(
self.session,
self.principal,
run.id,
expected_plan_hash=run.plan_hash,
)
with self.assertRaisesRegex(ValueError, "reviewed import plan changed"):
rollback_address_import(
self.session,
self.principal,
run.id,
AddressImportRollbackRequest(
expected_plan_hash="0" * 64,
reason="The operator selected the wrong monthly file.",
),
)
def test_persisted_run_read_is_tenant_bounded_and_source_safe(self) -> None:
source = "id;first;last;email;organization\n1;Ada;Lovelace;ada@example.test;Analysis Office\n"
run = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="contacts.csv",
content_base64=encoded(source),
),
)
self.session.flush()
payload = import_run_payload(get_import_run(self.session, self.principal, run.id))
self.assertEqual("previewed", payload["status"])
self.assertEqual(run.plan_hash, payload["plan_hash"])
self.assertNotIn("plan_data", payload)
self.assertNotIn(source, repr(payload))
with self.assertRaisesRegex(ValueError, "not found"):
get_import_run(self.session, OtherTenantPrincipal(), run.id)
def test_duplicate_keys_and_changed_targets_block_apply(self) -> None:
duplicate = preview_address_import(
self.session,
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.15",
"version": "0.1.18",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,10 +14,11 @@
"./styles/addresses.css": "./src/styles/addresses.css"
},
"scripts": {
"test:ui-structure": "node scripts/test-selection-list-structure.mjs"
"test:ui-structure": "node scripts/test-selection-list-structure.mjs",
"test:import-run": "rm -rf .import-run-test-build && mkdir -p .import-run-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-run-test-build/package.json && ../../govoplan-core/webui/node_modules/.bin/tsc -p tsconfig.import-run-tests.json && node .import-run-test-build/tests/import-run-state.test.js"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+8 -4
View File
@@ -1100,17 +1100,21 @@ export function previewAddressImport(
});
}
export function getAddressImportRun(settings: ApiSettings, runId: string): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${encodeURIComponent(runId)}`);
}
export function applyAddressImport(settings: ApiSettings, run: AddressImportRun): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${run.id}/apply`, {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${encodeURIComponent(run.id)}/apply`, {
method: "POST",
body: JSON.stringify({ expected_plan_hash: run.plan_hash })
});
}
export function rollbackAddressImport(settings: ApiSettings, runId: string, reason: string): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${runId}/rollback`, {
export function rollbackAddressImport(settings: ApiSettings, run: AddressImportRun, reason: string): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${encodeURIComponent(run.id)}/rollback`, {
method: "POST",
body: JSON.stringify({ reason })
body: JSON.stringify({ expected_plan_hash: run.plan_hash, reason })
});
}
@@ -1,6 +1,8 @@
import { MetricGrid } from "@govoplan/core-webui";
import { Download, Edit3, GitMerge, History, Link2, Network, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import {
import { useSearchParams } from "react-router";
import { DialogSection, DialogForm, FormGrid, ActionToolbar,
ApiError,
ActionBlockerHint,
Button,
@@ -13,6 +15,7 @@ import {
fetchAuthGroups,
formatDateTime,
FormField,
FormSection,
LoadingFrame,
MetricCard,
PasswordField,
@@ -49,6 +52,7 @@ import {
endContactChannelRule,
exportAddressBookVcards,
exportContactVcard,
getAddressImportRun,
importAddressBookVcards,
applyAddressImport,
getAddressQualitySummary,
@@ -69,6 +73,7 @@ import {
listContactProvenance,
previewAddressSyncSource,
previewAddressImport,
rollbackAddressImport,
mergeContacts,
recoverContactMerge,
restoreAddressBook,
@@ -112,6 +117,12 @@ import {
ADDRESSES_DOCUMENTATION,
ADDRESSES_I18N
} from "./interfacePatterns";
import {
importRunIdFromSearch,
importRunLifecycle,
unavailableImportRunMessage,
withImportRunSearch
} from "./importRunState";
type Props = {
settings: ApiSettings;
@@ -920,6 +931,8 @@ function formKey(value: unknown): string {
}
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
const [searchParams, setSearchParams] = useSearchParams();
const requestedImportRunId = importRunIdFromSearch(searchParams);
const [books, setBooks] = useState<AddressBook[]>([]);
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
@@ -978,6 +991,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [importProfileForm, setImportProfileForm] = useState<ImportProfileFormState>(EMPTY_IMPORT_PROFILE_FORM);
const [importFile, setImportFile] = useState<File | null>(null);
const [importRun, setImportRun] = useState<AddressImportRun | null>(null);
const [importRunLoading, setImportRunLoading] = useState(false);
const [importRunUnavailable, setImportRunUnavailable] = useState("");
const [importRollbackOpen, setImportRollbackOpen] = useState(false);
const [importRollbackReason, setImportRollbackReason] = useState("");
const [cardDavOpen, setCardDavOpen] = useState(false);
const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM);
const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]);
@@ -1075,13 +1092,41 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
if (!active) return;
setImportProfiles(profiles);
setSelectedImportProfileId((current) => current || profiles[0]?.id || "");
setCreatingImportProfile(profiles.length === 0);
setCreatingImportProfile(profiles.length === 0 && !requestedImportRunId);
})
.catch((err) => {
if (active) setError(errorMessage(err));
});
return () => { active = false; };
}, [importMode, importOpen, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
}, [importMode, importOpen, requestedImportRunId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (!requestedImportRunId) return;
let active = true;
setImportOpen(true);
setImportMode("tabular");
setCreatingImportProfile(false);
setImportRunLoading(true);
setImportRunUnavailable("");
getAddressImportRun(settings, requestedImportRunId)
.then((run) => {
if (!active) return;
setImportRun(run);
setSelectedBookId(run.address_book_id);
setSelectedImportProfileId(run.profile_id);
})
.catch((err) => {
if (!active) return;
setImportRun(null);
setImportRunUnavailable(
unavailableImportRunMessage(err instanceof ApiError ? err.status : undefined)
);
})
.finally(() => {
if (active) setImportRunLoading(false);
});
return () => { active = false; };
}, [requestedImportRunId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (auth.groups_loaded || !onAuthChange) return;
@@ -1093,6 +1138,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}, [auth.groups_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
const selectedBook = books.find((book) => book.id === selectedBookId) ?? books[0] ?? null;
const importLifecycle = importRun ? importRunLifecycle(importRun.status) : null;
const selectedList = addressLists.find((list) => list.id === selectedListId) ?? null;
const selectedBookSyncSources = useMemo(
() => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [],
@@ -1259,7 +1305,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[saving, savingReason],
[!importRun, "Preview the import first."],
[!importRun?.can_apply, "Resolve all import diagnostics before applying."],
[importRun?.status !== "previewed", "This import plan is no longer pending."]
[!importLifecycle?.canApply, "This import plan is no longer pending."]
);
const tabularRollbackReason = disabledReason(
[saving, savingReason],
[!importRun, "Load an applied import run before rolling it back."],
[!importLifecycle?.canRollback, "Only an applied import run can be rolled back."],
[importRollbackReason.trim().length < 3, "Record why this import is being rolled back."]
);
const connectLdapReason = disabledReason(
[!selectedBook, "Select an address book before connecting LDAP."],
@@ -2261,8 +2313,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}
function openImportDialog() {
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
setImportMode("vcard");
setImportRun(null);
setImportRunUnavailable("");
setImportFile(null);
setCreatingImportProfile(false);
setEditingImportProfileId("");
@@ -2270,6 +2324,46 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
setImportOpen(true);
}
function closeImportDialog() {
setImportOpen(false);
setImportRun(null);
setImportRunUnavailable("");
setImportRollbackOpen(false);
setImportRollbackReason("");
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
}
function retainImportRun(run: AddressImportRun) {
setImportRun(run);
setImportRunUnavailable("");
setSearchParams(withImportRunSearch(searchParams, run.id), { replace: true });
}
function clearRetainedImportRun() {
setImportRun(null);
setImportRunUnavailable("");
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
}
async function reloadImportRun() {
if (!importRun?.id && !requestedImportRunId) return;
setImportRunLoading(true);
setImportRunUnavailable("");
try {
const run = await getAddressImportRun(settings, importRun?.id || requestedImportRunId);
retainImportRun(run);
setSelectedBookId(run.address_book_id);
setSelectedImportProfileId(run.profile_id);
} catch (err) {
setImportRun(null);
setImportRunUnavailable(
unavailableImportRunMessage(err instanceof ApiError ? err.status : undefined)
);
} finally {
setImportRunLoading(false);
}
}
async function saveImportProfile() {
if (!selectedBook) return;
setSaving(true);
@@ -2313,7 +2407,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
setSelectedImportProfileId(profile.id);
setCreatingImportProfile(false);
setEditingImportProfileId("");
setImportRun(null);
clearRetainedImportRun();
setNotice(`Saved import mapping "${profile.name}" version ${profile.version}.`);
} catch (err) {
setError(errorMessage(err));
@@ -2326,7 +2420,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
setEditingImportProfileId("");
setImportProfileForm(EMPTY_IMPORT_PROFILE_FORM);
setCreatingImportProfile(true);
setImportRun(null);
clearRetainedImportRun();
}
function editSelectedImportProfile() {
@@ -2350,7 +2444,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
field_mappings: { ...config.field_mappings }
});
setCreatingImportProfile(true);
setImportRun(null);
clearRetainedImportRun();
}
function cancelImportProfileEditor() {
@@ -2381,7 +2475,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
filename: importFile.name,
content_base64: content
});
setImportRun(run);
retainImportRun(run);
setNotice(`Previewed ${run.row_count} row${run.row_count === 1 ? "" : "s"}.`);
} catch (err) {
setError(errorMessage(err));
@@ -2397,7 +2491,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
setNotice("");
try {
const run = await applyAddressImport(settings, importRun);
setImportRun(run);
retainImportRun(run);
setNotice(`Applied import plan: ${run.statistics.create ?? 0} created, ${run.statistics.update ?? 0} updated.`);
await refreshBooks();
await refreshContacts(selectedBook.id, query);
@@ -2408,6 +2502,26 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}
}
async function rollbackTabularImport() {
if (!importRun) return;
setSaving(true);
setError("");
setNotice("");
try {
const run = await rollbackAddressImport(settings, importRun, importRollbackReason.trim());
retainImportRun(run);
setImportRollbackOpen(false);
setImportRollbackReason("");
setNotice("Rolled back the persisted import run.");
await refreshBooks();
await refreshContacts(run.address_book_id, query);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
function openCardDavDialog() {
setCardDavForm(EMPTY_CARDDAV_FORM);
setCardDavDiscovery([]);
@@ -3059,7 +3173,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</Button>
</div>
</header>
<div className="address-contact-toolbar">
<ActionToolbar className="address-contact-toolbar">
<input
value={query}
onChange={(event) => {
@@ -3069,7 +3183,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
placeholder="Search contacts" />
{selectedBook?.read_only && <StatusBadge status="read-only" />}
{selectedBook?.deleted_at && <StatusBadge status="archived" />}
</div>
</ActionToolbar>
<div className="address-contact-list">
{visibleContacts.length === 0 ?
<div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> :
@@ -3111,8 +3225,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</Button>
</>
}>
<form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
<div className="form-grid two">
<DialogForm id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Scope" documentation={ADDRESS_FIELDS_DOCUMENTATION}>
<select value={bookForm.scope_type} disabled={bookDialog?.mode === "edit"} onChange={(event) => setBookForm((current) => ({ ...current, scope_type: event.target.value as AddressBookScope }))}>
<option value="user">Personal</option>
@@ -3129,10 +3243,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</select>
</FormField>
}
</div>
</FormGrid>
<FormField label="Name" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={bookForm.name} onChange={(event) => setBookForm((current) => ({ ...current, name: event.target.value }))} autoFocus /></FormField>
<FormField label="Description"><textarea value={bookForm.description} onChange={(event) => setBookForm((current) => ({ ...current, description: event.target.value }))} rows={3} /></FormField>
</form>
</DialogForm>
</Dialog>
<Dialog
@@ -3147,7 +3261,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</Button>
</>
}>
<form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
<DialogForm id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
<p className="muted">Lists group contacts inside the selected address book.</p>
<FormField label="Address book" documentation={ADDRESS_FIELDS_DOCUMENTATION}>
<input value={selectedBook?.name ?? ""} disabled readOnly />
@@ -3158,7 +3272,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Description">
<textarea value={listForm.description} onChange={(event) => setListForm((current) => ({ ...current, description: event.target.value }))} rows={3} />
</FormField>
</form>
</DialogForm>
</Dialog>
<Dialog
@@ -3174,22 +3288,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</Button>
</>
}>
<form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
<div className="form-grid three">
<DialogForm id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
<FormGrid columns={3} collapseAt="standard" className="">
<FormField label="Display name" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={contactForm.display_name} onChange={(event) => setContactForm((current) => ({ ...current, display_name: event.target.value }))} autoFocus /></FormField>
<FormField label="Given name"><input value={contactForm.given_name} onChange={(event) => setContactForm((current) => ({ ...current, given_name: event.target.value }))} /></FormField>
<FormField label="Family name"><input value={contactForm.family_name} onChange={(event) => setContactForm((current) => ({ ...current, family_name: event.target.value }))} /></FormField>
</div>
<div className="form-grid two">
</FormGrid>
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Organization" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={contactForm.organization} onChange={(event) => setContactForm((current) => ({ ...current, organization: event.target.value }))} /></FormField>
<FormField label="Role title"><input value={contactForm.role_title} onChange={(event) => setContactForm((current) => ({ ...current, role_title: event.target.value }))} /></FormField>
</div>
<section className="address-form-section">
<div className="address-form-section-heading">
<strong>Email addresses</strong>
<Button type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</Button>
</div>
<div className="address-form-list">
</FormGrid>
<FormSection
className="address-form-section"
title="Email addresses"
actions={<Button type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</Button>}
contentClassName="address-form-list">
{contactForm.emails.map((email) => (
<div className="address-form-row address-form-row-email" key={email.rowId}>
<label className="address-primary-choice"><input type="radio" name="address-primary-email" checked={email.is_primary} onChange={() => setPrimaryEmailRow(email.rowId)} /> Primary</label>
@@ -3198,15 +3311,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></Button>
</div>
))}
</div>
</section>
</FormSection>
<section className="address-form-section">
<div className="address-form-section-heading">
<strong>Phone numbers</strong>
<Button type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</Button>
</div>
<div className="address-form-list">
<FormSection
className="address-form-section"
title="Phone numbers"
actions={<Button type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</Button>}
contentClassName="address-form-list">
{contactForm.phones.map((phone) => (
<div className="address-form-row address-form-row-phone" key={phone.rowId}>
<label className="address-primary-choice"><input type="radio" name="address-primary-phone" checked={phone.is_primary} onChange={() => setPrimaryPhoneRow(phone.rowId)} /> Primary</label>
@@ -3215,15 +3326,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></Button>
</div>
))}
</div>
</section>
</FormSection>
<section className="address-form-section">
<div className="address-form-section-heading">
<strong>Postal addresses</strong>
<Button type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</Button>
</div>
<div className="address-form-list">
<FormSection
className="address-form-section"
title="Postal addresses"
actions={<Button type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</Button>}
contentClassName="address-form-list">
{contactForm.postal_addresses.map((address) => (
<div className="address-form-row address-form-row-postal" key={address.rowId}>
<label className="address-primary-choice"><input type="radio" name="address-primary-postal" checked={address.is_primary} onChange={() => setPrimaryPostalAddressRow(address.rowId)} /> Primary</label>
@@ -3236,11 +3345,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="button" variant="danger" title="Remove postal address" aria-label="Remove postal address" disabledReason={removePostalAddressRowReason} onClick={() => removePostalAddressRow(address.rowId)}><Trash2 size={15} /></Button>
</div>
))}
</div>
</section>
</FormSection>
<FormField label="Tags"><input value={contactForm.tags} placeholder="Comma-separated tags" onChange={(event) => setContactForm((current) => ({ ...current, tags: event.target.value }))} /></FormField>
<FormField label="Note"><textarea rows={3} value={contactForm.note} onChange={(event) => setContactForm((current) => ({ ...current, note: event.target.value }))} /></FormField>
</form>
</DialogForm>
</Dialog>
<Dialog
@@ -3299,7 +3407,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</section>
{canWriteGovernance &&
<form className="address-dialog-form address-form-section" onSubmit={(event) => void submitChannelRule(event)}>
<DialogForm className="address-dialog-form address-form-section" onSubmit={(event) => void submitChannelRule(event)}>
<div className="address-form-section-heading">
<div>
<strong>Record a fact</strong>
@@ -3307,7 +3415,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div>
<Button type="submit" variant="primary" disabledReason={channelRuleSaveReason}><Plus size={15} /> Add</Button>
</div>
<div className="form-grid two">
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Channel" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
<select
value={channelRuleForm.channel}
@@ -3331,8 +3439,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{governancePointOptions.map((item) => <option value={item.id} key={item.id}>{item.label}</option>)}
</select>
</FormField>
</div>
<div className="form-grid two">
</FormGrid>
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Decision" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
<select value={channelRuleForm.decision} onChange={(event) => setChannelRuleForm((current) => ({ ...current, decision: event.target.value as AddressChannelDecision }))}>
<option value="allowed">Allowed</option>
@@ -3348,19 +3456,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Communication purpose" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
<input value={channelRuleForm.purpose} placeholder="All purposes" onChange={(event) => setChannelRuleForm((current) => ({ ...current, purpose: event.target.value }))} />
</FormField>
</div>
<div className="form-grid three">
</FormGrid>
<FormGrid columns={3} collapseAt="standard" className="">
<FormField label="Legal basis"><input value={channelRuleForm.legal_basis} onChange={(event) => setChannelRuleForm((current) => ({ ...current, legal_basis: event.target.value }))} /></FormField>
<FormField label="Evidence reference"><input value={channelRuleForm.evidence_ref} onChange={(event) => setChannelRuleForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField>
<FormField label="Locale"><input value={channelRuleForm.locale} placeholder="de-DE" onChange={(event) => setChannelRuleForm((current) => ({ ...current, locale: event.target.value }))} /></FormField>
</div>
<div className="form-grid three">
</FormGrid>
<FormGrid columns={3} collapseAt="standard" className="">
<FormField label="Effective from"><input type="datetime-local" value={channelRuleForm.effective_from} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_from: event.target.value }))} /></FormField>
<FormField label="Effective until"><input type="datetime-local" value={channelRuleForm.effective_until} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_until: event.target.value }))} /></FormField>
<FormField label="Preference rank"><input type="number" min="0" max="10000" value={channelRuleForm.preference_rank} onChange={(event) => setChannelRuleForm((current) => ({ ...current, preference_rank: event.target.value }))} /></FormField>
</div>
</FormGrid>
<FormField label="Reason" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}><textarea rows={2} value={channelRuleForm.reason} onChange={(event) => setChannelRuleForm((current) => ({ ...current, reason: event.target.value }))} /></FormField>
</form>
</DialogForm>
}
</div>
</Dialog>
@@ -3425,12 +3533,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}>
<LoadingFrame loading={qualityLoading} label="Loading address quality...">
<div className="address-quality-layout">
{qualitySummary && <div className="metric-grid inside address-quality-metrics">
{qualitySummary && <MetricGrid spacing="inset" className="address-quality-metrics">
<MetricCard label="Contacts" value={qualitySummary.contact_count} tone="info" detail={`${qualitySummary.contact_point_count} contact points`} />
<MetricCard label="Corrections" value={qualitySummary.correction_count} tone={qualitySummary.correction_count > 0 ? "warning" : "good"} detail="Current non-valid states" />
<MetricCard label="Duplicates" value={qualitySummary.duplicate_suggestion_count} tone={qualitySummary.duplicate_suggestion_count > 0 ? "warning" : "good"} detail="Explainable suggestions" />
<MetricCard label="Undeliverable" value={(qualitySummary.quality_counts.undeliverable ?? 0) + (qualitySummary.quality_counts.returned ?? 0)} tone={(qualitySummary.quality_counts.undeliverable ?? 0) + (qualitySummary.quality_counts.returned ?? 0) > 0 ? "danger" : "good"} detail="Returned or undeliverable" />
</div>}
</MetricGrid>}
<section className="address-quality-section">
<div className="address-form-section-heading">
@@ -3531,9 +3639,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-quality-point-form" variant="primary" disabledReason={disabledReason([!qualityPointTarget, "Select a contact point."], [!canWriteGovernance, "You need permission to manage address quality."], [saving, savingReason])}><Save size={15} /> Record</Button>
</>
}>
<form id="address-quality-point-form" className="address-dialog-form" onSubmit={(event) => void submitQualityDecision(event)}>
<DialogForm id="address-quality-point-form" className="address-dialog-form" onSubmit={(event) => void submitQualityDecision(event)}>
<FormField label="Contact point"><input value={qualityPointTarget?.label ?? ""} readOnly disabled /></FormField>
<div className="form-grid two">
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Quality state">
<select value={qualityForm.state} onChange={(event) => setQualityForm((current) => ({ ...current, state: event.target.value as ContactPointQualityState }))}>
<option value="valid">Valid</option>
@@ -3544,10 +3652,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</select>
</FormField>
<FormField label="Reason code"><input value={qualityForm.reason_code} placeholder={`addresses.quality.${qualityForm.state}`} onChange={(event) => setQualityForm((current) => ({ ...current, reason_code: event.target.value }))} /></FormField>
</div>
</FormGrid>
<FormField label="Reason"><textarea rows={3} value={qualityForm.reason} onChange={(event) => setQualityForm((current) => ({ ...current, reason: event.target.value }))} /></FormField>
<FormField label="Evidence reference"><input value={qualityForm.evidence_ref} placeholder="mail:delivery:..." onChange={(event) => setQualityForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField>
</form>
</DialogForm>
</Dialog>
<Dialog
@@ -3562,7 +3670,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-merge-form" variant="primary" disabledReason={disabledReason([!mergeDialog || mergeDialog.reason.trim().length < 3, "Record why these contacts are being merged."], [saving, savingReason])}><GitMerge size={15} /> Merge</Button>
</>
}>
{mergeDialog && <form id="address-merge-form" className="address-dialog-form" onSubmit={(event) => void submitContactMerge(event)}>
{mergeDialog && <DialogForm id="address-merge-form" className="address-dialog-form" onSubmit={(event) => void submitContactMerge(event)}>
<DismissibleAlert tone="warning" dismissible={false}>The other contact is archived and redirected to the survivor. Address-list memberships and matching contact points are repaired transactionally.</DismissibleAlert>
<FormField label="Surviving contact">
<select value={mergeDialog.winnerId} onChange={(event) => changeMergeWinner(event.target.value)}>
@@ -3570,7 +3678,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<option value={mergeDialog.suggestion.right.id}>{mergeDialog.suggestion.right.display_name} · {primaryEmail(mergeDialog.suggestion.right) || "no email"}</option>
</select>
</FormField>
<div className="form-grid two address-merge-field-sources">
<FormGrid columns={2} collapseAt="standard" className="address-merge-field-sources">
{MERGE_SCALAR_FIELDS.map((field) => (
<FormField label={`${field.label} source`} key={field.id}>
<select
@@ -3584,7 +3692,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</select>
</FormField>
))}
</div>
</FormGrid>
<FormField label="Contact points">
<SegmentedControl<"union" | "winner_only">
role="group"
@@ -3596,7 +3704,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
/>
</FormField>
<FormField label="Reason"><textarea rows={3} value={mergeDialog.reason} onChange={(event) => setMergeDialog((current) => current ? { ...current, reason: event.target.value } : null)} /></FormField>
</form>}
</DialogForm>}
</Dialog>
<Dialog
@@ -3611,10 +3719,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-merge-recovery-form" variant="primary" disabledReason={disabledReason([!mergeRecoveryDialog || mergeRecoveryDialog.reason.trim().length < 3, "Record why the merge is being recovered."], [saving, savingReason])}><RotateCcw size={15} /> {mergeRecoveryDialog?.action === "split" ? "Split" : "Undo"}</Button>
</>
}>
{mergeRecoveryDialog && <form id="address-merge-recovery-form" className="address-dialog-form" onSubmit={(event) => void submitMergeRecovery(event)}>
{mergeRecoveryDialog && <DialogForm id="address-merge-recovery-form" className="address-dialog-form" onSubmit={(event) => void submitMergeRecovery(event)}>
<p className="muted">Recovery restores the recorded pre-merge values and list memberships. It is rejected if either contact changed after the merge.</p>
<FormField label="Reason"><textarea rows={3} value={mergeRecoveryDialog.reason} onChange={(event) => setMergeRecoveryDialog((current) => current ? { ...current, reason: event.target.value } : null)} autoFocus /></FormField>
</form>}
</DialogForm>}
</Dialog>
<Dialog
@@ -3626,9 +3734,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={<Button type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</Button>}>
<div className="address-member-picker">
<div className="address-contact-toolbar address-member-search">
<ActionToolbar className="address-contact-toolbar address-member-search">
<input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus />
</div>
</ActionToolbar>
<div className="address-member-candidate-list" role="list">
{memberCandidateContacts.length === 0 ?
<div className="address-empty-note">No contacts available to add.</div> :
@@ -3666,13 +3774,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Dialog
open={importOpen}
title="Import contacts"
onClose={() => setImportOpen(false)}
onClose={closeImportDialog}
closeDisabled={saving}
className="address-import-dialog"
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="button" onClick={closeImportDialog} disabledReason={dialogCancelReason}>Close</Button>
{importMode === "vcard" &&
<Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>}
{importMode === "tabular" && creatingImportProfile &&
@@ -3681,19 +3789,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="button" onClick={() => void previewTabularImport()} disabledReason={tabularPreviewReason}><Search size={16} /> Preview</Button>}
{importMode === "tabular" && !creatingImportProfile && importRun &&
<Button type="button" variant="primary" onClick={() => void applyTabularImport()} disabledReason={tabularApplyReason}><Upload size={16} /> Apply import</Button>}
{importMode === "tabular" && importLifecycle?.canRollback &&
<Button type="button" variant="danger" onClick={() => setImportRollbackOpen(true)} disabledReason={savingReason}><RotateCcw size={16} /> Roll back</Button>}
</>
}>
<div className="address-dialog-form">
<DialogSection className="address-dialog-form">
<SegmentedControl<ImportMode>
role="group"
size="equal"
ariaLabel="Contact import format"
options={[{ id: "vcard", label: "vCard" }, { id: "tabular", label: "CSV / XLSX" }]}
value={importMode}
onChange={(mode) => { setImportMode(mode); setImportRun(null); }}
onChange={(mode) => { setImportMode(mode); clearRetainedImportRun(); }}
/>
{importMode === "vcard" &&
<form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
<DialogForm id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
<p className="muted">Paste one or more vCard entries into the selected address book.</p>
<FormField label="vCard content">
<textarea
@@ -3704,15 +3814,32 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
placeholder={"BEGIN:VCARD\nVERSION:4.0\nFN:Ada Lovelace\nEMAIL;TYPE=work:ada@example.local\nEND:VCARD"}
/>
</FormField>
</form>}
</DialogForm>}
{importMode === "tabular" &&
<div className="address-import-workspace">
<div className="form-grid two">
{(requestedImportRunId || importRun) &&
<div className="address-import-run-state">
<div>
<strong>Persisted import run</strong>
<span className="muted block">{importRun?.id ?? requestedImportRunId}</span>
</div>
{importRun && importLifecycle &&
<>
<StatusBadge status={importLifecycle.tone} label={importLifecycle.label} />
<p className="muted">{importLifecycle.guidance}</p>
</>}
<Button type="button" onClick={() => void reloadImportRun()} disabledReason={importRunLoading ? "The import run is loading." : undefined}>
<RefreshCw size={15} /> {importRunLoading ? "Loading…" : "Reload run"}
</Button>
</div>}
{importRunUnavailable &&
<DismissibleAlert tone="warning" resetKey={importRunUnavailable}>{importRunUnavailable}</DismissibleAlert>}
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Mapping profile">
<select
value={selectedImportProfileId}
disabled={creatingImportProfile}
onChange={(event) => { setSelectedImportProfileId(event.target.value); setImportRun(null); }}>
onChange={(event) => { setSelectedImportProfileId(event.target.value); clearRetainedImportRun(); }}>
<option value="">Select a saved mapping</option>
{importProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} · {profile.source_format.toUpperCase()} · v{profile.version}</option>)}
</select>
@@ -3728,10 +3855,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{creatingImportProfile ? "Cancel editor" : "New mapping"}
</Button>
</div>
</div>
</FormGrid>
{creatingImportProfile ?
<div className="address-import-profile-editor">
<div className="form-grid two">
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Profile name"><input value={importProfileForm.name} onChange={(event) => setImportProfileForm((current) => ({ ...current, name: event.target.value }))} /></FormField>
<FormField label="Format">
<select value={importProfileForm.source_format} disabled={Boolean(editingImportProfileId)} onChange={(event) => setImportProfileForm((current) => ({ ...current, source_format: event.target.value as "csv" | "xlsx" }))}>
@@ -3771,7 +3898,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Locale"><input value={importProfileForm.locale} onChange={(event) => setImportProfileForm((current) => ({ ...current, locale: event.target.value }))} placeholder="de-DE" /></FormField>
<FormField label="Default tags"><input value={importProfileForm.default_tags} onChange={(event) => setImportProfileForm((current) => ({ ...current, default_tags: event.target.value }))} placeholder="monthly, imported" /></FormField>
<FormField label="Maximum rows"><input type="number" min="1" max="10000" value={importProfileForm.max_rows} onChange={(event) => setImportProfileForm((current) => ({ ...current, max_rows: event.target.value }))} /></FormField>
</div>
</FormGrid>
<div className="address-import-mapping-grid">
{IMPORT_MAPPING_FIELDS.map(([target, label]) =>
<FormField label={label} key={target}>
@@ -3788,11 +3915,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<input
type="file"
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); setImportRun(null); }}
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); clearRetainedImportRun(); }}
/>
</FormField>
{importRun &&
<div className="address-import-preview">
<p className="muted small-text">
{importRun.source_filename} · plan {importRun.plan_hash.slice(0, 12)} · updated {formatDateTime(importRun.updated_at)}
</p>
<div className="address-sync-plan-grid">
{(["create", "update", "unchanged", "ignored", "conflict", "errors"] as const).map((key) =>
<div key={key}><strong>{importRun.statistics[key] ?? 0}</strong><small>{key}</small></div>)}
@@ -3813,7 +3943,35 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div>}
</>}
</div>}
</div>
</DialogSection>
</Dialog>
<Dialog
open={importRollbackOpen}
title="Roll back contact import"
onClose={() => setImportRollbackOpen(false)}
closeDisabled={saving}
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setImportRollbackOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="button" variant="danger" onClick={() => void rollbackTabularImport()} disabledReason={tabularRollbackReason}><RotateCcw size={16} /> Roll back import</Button>
</>
}>
<DialogSection className="address-dialog-form">
<p className="muted">
Rollback is accepted only while every affected contact still matches the evidence recorded for plan {importRun?.plan_hash.slice(0, 12) ?? "-"}.
</p>
<FormField label="Reason">
<textarea
rows={3}
value={importRollbackReason}
onChange={(event) => setImportRollbackReason(event.target.value)}
placeholder="Why should this import be rolled back?"
autoFocus
/>
</FormField>
</DialogSection>
</Dialog>
<Dialog
@@ -3830,14 +3988,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-carddav-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV address-book URL before saving."])}><Save size={16} /> Connect</Button>
</>
}>
<form id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}>
<DialogForm id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}>
<FormField label="Address book">
<input value={selectedBook?.name ?? ""} disabled readOnly />
</FormField>
<FormField label="CardDAV URL">
<input value={cardDavForm.collection_url} onChange={(event) => setCardDavForm((current) => ({ ...current, collection_url: event.target.value }))} autoFocus />
</FormField>
<div className="form-grid three">
<FormGrid columns={3} collapseAt="standard" className="">
<FormField label="Authentication">
<select value={cardDavForm.auth_type} onChange={(event) => setCardDavForm((current) => ({ ...current, auth_type: event.target.value as CardDavFormState["auth_type"] }))}>
<option value="none">None</option>
@@ -3855,7 +4013,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Display name">
<input value={cardDavForm.display_name} onChange={(event) => setCardDavForm((current) => ({ ...current, display_name: event.target.value }))} placeholder={selectedBook?.name ?? "CardDAV"} />
</FormField>
</div>
</FormGrid>
{cardDavForm.auth_type !== "none" &&
<FormField label="Reusable credential">
<select
@@ -3883,7 +4041,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}
{cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>}
{cardDavForm.auth_type === "basic" &&
<div className="form-grid two">
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Username">
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} disabled={Boolean(cardDavForm.credential_envelope_id)} />
</FormField>
@@ -3892,7 +4050,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
</FormField>
}
</div>
</FormGrid>
}
{cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id &&
<FormField label="Bearer token">
@@ -3913,7 +4071,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
))}
</SelectionList>
}
</form>
</DialogForm>
</Dialog>
<Dialog
@@ -3930,8 +4088,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-ldap-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!ldapForm.url.trim(), "Enter an LDAP URL."], [!ldapForm.base_dn.trim(), "Select or enter a base DN."], [!ldapForm.attribute_map.source_key?.trim(), "Map a stable source-key attribute."])}><Save size={16} /> Connect</Button>
</>
}>
<form id="address-ldap-form" className="address-dialog-form" onSubmit={(event) => void submitLdapSource(event)}>
<div className="form-grid two">
<DialogForm id="address-ldap-form" className="address-dialog-form" onSubmit={(event) => void submitLdapSource(event)}>
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Directory URL">
<input value={ldapForm.url} onChange={(event) => setLdapForm((current) => ({ ...current, url: event.target.value }))} placeholder="ldaps://directory.example.org" autoFocus />
</FormField>
@@ -3954,7 +4112,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Bind DN / username">
<input value={ldapForm.bind_dn} onChange={(event) => setLdapForm((current) => ({ ...current, bind_dn: event.target.value }))} />
</FormField>
</div>
</FormGrid>
<ToggleSwitch
label="Require StartTLS for ldap://"
checked={ldapForm.start_tls}
@@ -3962,7 +4120,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
help="LDAPS always uses TLS. Plain ldap:// endpoints are accepted only when StartTLS is enabled."
/>
{cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>}
<div className="form-grid two">
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Base DN">
<input list="address-ldap-base-dns" value={ldapForm.base_dn} onChange={(event) => setLdapForm((current) => ({ ...current, base_dn: event.target.value }))} placeholder="ou=people,dc=example,dc=org" />
<datalist id="address-ldap-base-dns">{ldapBaseDns.map((baseDn) => <option value={baseDn} key={baseDn} />)}</datalist>
@@ -3972,7 +4130,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</FormField>
<FormField label="Page size"><input type="number" min="1" max="1000" value={ldapForm.page_size} onChange={(event) => setLdapForm((current) => ({ ...current, page_size: event.target.value }))} /></FormField>
<FormField label="Maximum entries"><input type="number" min="1" max="10000" value={ldapForm.max_entries} onChange={(event) => setLdapForm((current) => ({ ...current, max_entries: event.target.value }))} /></FormField>
</div>
</FormGrid>
<div className="address-import-mapping-grid">
{LDAP_MAPPING_FIELDS.map(([target, label]) =>
<FormField label={label} key={target}>
@@ -3980,7 +4138,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</FormField>)}
</div>
<p className="muted">The directory remains authoritative and read-only. Preview the first refresh before applying it.</p>
</form>
</DialogForm>
</Dialog>
<Dialog
@@ -0,0 +1,72 @@
export type ImportRunLifecycle = {
label: string;
tone: "info" | "success" | "warning" | "inactive";
canApply: boolean;
canRollback: boolean;
guidance: string;
};
export function importRunIdFromSearch(search: URLSearchParams): string {
return search.get("import_run")?.trim() ?? "";
}
export function withImportRunSearch(search: URLSearchParams, runId?: string | null): URLSearchParams {
const next = new URLSearchParams(search);
const normalized = runId?.trim();
if (normalized) next.set("import_run", normalized);
else next.delete("import_run");
return next;
}
export function importRunLifecycle(status: string): ImportRunLifecycle {
if (status === "previewed") {
return {
label: "Ready for review",
tone: "info",
canApply: true,
canRollback: false,
guidance: "Review the persisted effects and diagnostics before applying this plan."
};
}
if (status === "applied") {
return {
label: "Applied",
tone: "success",
canApply: false,
canRollback: true,
guidance: "The plan has already been applied. Rollback remains guarded by its recorded plan hash and contact evidence."
};
}
if (status === "rolled_back") {
return {
label: "Rolled back",
tone: "inactive",
canApply: false,
canRollback: false,
guidance: "This run was rolled back and cannot be applied again. Create a new preview to import the source again."
};
}
if (status === "expired") {
return {
label: "Expired",
tone: "warning",
canApply: false,
canRollback: false,
guidance: "This preview is no longer actionable. Create a new preview from the original source."
};
}
return {
label: status || "Unavailable",
tone: "warning",
canApply: false,
canRollback: false,
guidance: "This run is not actionable in its current lifecycle state."
};
}
export function unavailableImportRunMessage(status?: number): string {
if (status === 404 || status === 410) {
return "This import run is missing, expired, or not available to your tenant. No source data was loaded.";
}
return "The persisted import run could not be loaded. Reload it after the service becomes available.";
}
+23 -7
View File
@@ -9,7 +9,7 @@
justify-content: space-between;
gap: 16px;
border: var(--border-line);
border-radius: 6px;
border-radius: var(--radius-compact);
background: var(--panel-soft);
padding: 14px;
}
@@ -163,6 +163,21 @@
min-height: 0;
}
.address-import-run-state {
align-items: center;
background: var(--panel-soft);
border: var(--border-line);
border-radius: var(--radius-compact);
display: grid;
gap: 10px 14px;
grid-template-columns: minmax(180px, 1fr) auto minmax(220px, 1.4fr) auto;
padding: 10px 12px;
}
.address-import-run-state p {
margin: 0;
}
.address-import-profile-actions {
align-items: end;
justify-content: flex-start;
@@ -189,7 +204,7 @@
.address-import-preview > .address-sync-plan-grid > div {
background: var(--panel-soft);
border: var(--border-line);
border-radius: 6px;
border-radius: var(--radius-compact);
display: grid;
gap: 2px;
padding: 9px 10px;
@@ -406,7 +421,7 @@
.address-tag {
background: var(--panel-soft);
border: var(--border-line);
border-radius: 999px;
border-radius: var(--radius-pill);
color: var(--muted);
display: inline-flex;
font-size: 0.75rem;
@@ -553,7 +568,7 @@
.address-member-candidate-list {
border: var(--border-line);
border-radius: 6px;
border-radius: var(--radius-compact);
display: grid;
max-height: min(460px, calc(100vh - 300px));
overflow: auto;
@@ -598,7 +613,7 @@
.address-form-section {
border: var(--border-line);
border-radius: 6px;
border-radius: var(--radius-compact);
display: grid;
gap: 10px;
padding: 12px;
@@ -678,7 +693,7 @@
.address-quality-list {
border: var(--border-line);
border-radius: 6px;
border-radius: var(--radius-compact);
max-height: 300px;
overflow: auto;
}
@@ -809,8 +824,9 @@
opacity: 0.72;
}
@media (max-width: 980px) {
@media (max-width: 1100px) {
.address-book-workspace,
.address-import-run-state,
.address-form-row-email,
.address-form-row-phone,
.address-form-row-postal {
+22
View File
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import {
importRunIdFromSearch,
importRunLifecycle,
unavailableImportRunMessage,
withImportRunSearch
} from "../src/features/addressbook/importRunState";
const initial = new URLSearchParams("q=Ada&import_run=run-123");
assert.equal(importRunIdFromSearch(initial), "run-123");
assert.equal(withImportRunSearch(initial, "run-456").toString(), "q=Ada&import_run=run-456");
assert.equal(withImportRunSearch(initial, null).toString(), "q=Ada");
assert.equal(importRunLifecycle("previewed").canApply, true);
assert.equal(importRunLifecycle("previewed").canRollback, false);
assert.equal(importRunLifecycle("applied").canApply, false);
assert.equal(importRunLifecycle("applied").canRollback, true);
assert.equal(importRunLifecycle("rolled_back").canRollback, false);
assert.equal(importRunLifecycle("expired").label, "Expired");
assert.match(unavailableImportRunMessage(404), /missing, expired, or not available to your tenant/);
console.log("Address import-run deep-link and lifecycle state tests passed.");
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"typeRoots": ["../../govoplan-core/webui/node_modules/@types"],
"types": ["node"],
"outDir": ".import-run-test-build"
},
"include": [
"src/features/addressbook/importRunState.ts",
"tests/import-run-state.test.ts"
]
}