Compare commits

..

7 Commits

12 changed files with 738 additions and 370 deletions

View File

@@ -18,7 +18,7 @@
"README.md" "README.md"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -11,7 +11,7 @@ requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"defusedxml>=0.7.1", "defusedxml>=0.7.1",
"govoplan-core>=0.1.8", "govoplan-core>=0.1.9",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@@ -9,6 +9,12 @@ from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol from typing import Any, Mapping, Protocol
from defusedxml import ElementTree as SafeElementTree from defusedxml import ElementTree as SafeElementTree
from govoplan_core.security.outbound_http import (
OutboundHttpError,
bounded_response_bytes,
build_outbound_http_opener,
validate_outbound_http_url,
)
class AddressCardDAVError(RuntimeError): class AddressCardDAVError(RuntimeError):
@@ -326,20 +332,31 @@ class AddressCardDAVClient:
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]: def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
url = validate_http_url(url) url = validate_http_url(url)
try: try:
url = validate_outbound_http_url(url, label="CardDAV URL")
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined. request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
url, url,
data=body, data=body,
headers=dict(headers), headers=dict(headers),
method=method, method=method,
) )
opener = urllib.request.build_opener(_SameOriginRedirectHandler(url)) opener = build_outbound_http_opener(_SameOriginRedirectHandler(url))
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CardDAV URL; redirects remain on origin. # nosec B310 with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CardDAV URL; redirects remain on origin. # nosec B310
return response.status, dict(response.headers.items()), response.read() response_headers = dict(response.headers.items())
return response.status, response_headers, bounded_response_bytes(
response,
headers=response_headers,
label="CardDAV response",
)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
return exc.code, dict(exc.headers.items()), exc.read() response_headers = dict(exc.headers.items())
try:
payload = bounded_response_bytes(exc, headers=response_headers, label="CardDAV error response")
except OutboundHttpError as policy_exc:
raise AddressCardDAVError(f"{method} {url} failed: {policy_exc}") from policy_exc
return exc.code, response_headers, payload
except urllib.error.URLError as exc: except urllib.error.URLError as exc:
raise AddressCardDAVError(f"{method} {url} failed: {exc.reason}") from exc raise AddressCardDAVError(f"{method} {url} failed: {exc.reason}") from exc
except ValueError as exc: except (OutboundHttpError, ValueError) as exc:
raise AddressCardDAVError(f"{method} {url} failed: {exc}") from exc raise AddressCardDAVError(f"{method} {url} failed: {exc}") from exc
@@ -493,7 +510,8 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
del fp, msg, headers del fp, msg, headers
try: try:
candidate = validate_http_url(newurl) candidate = validate_http_url(newurl)
except AddressCardDAVError: candidate = validate_outbound_http_url(candidate, label="CardDAV redirect URL")
except (AddressCardDAVError, OutboundHttpError):
return None return None
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin: if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
return None return None

View File

@@ -103,6 +103,7 @@ from govoplan_addresses.backend.service import (
restore_contact, restore_contact,
resolve_sync_conflict, resolve_sync_conflict,
preview_sync_source, preview_sync_source,
public_address_sync_metadata,
run_sync_source, run_sync_source,
start_sync_attempt, start_sync_attempt,
update_address_book, update_address_book,
@@ -217,7 +218,7 @@ def _sync_source_response(sync_source: AddressSyncSource) -> AddressSyncSourceRe
"last_success_at": sync_source.last_success_at, "last_success_at": sync_source.last_success_at,
"last_error": sync_source.last_error, "last_error": sync_source.last_error,
"last_diagnostic": sync_source.last_diagnostic, "last_diagnostic": sync_source.last_diagnostic,
"metadata": sync_source.metadata_ or {}, "metadata": public_address_sync_metadata(sync_source.metadata_),
"created_at": sync_source.created_at, "created_at": sync_source.created_at,
"updated_at": sync_source.updated_at, "updated_at": sync_source.updated_at,
} }

View File

@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import copy
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass, field from dataclasses import dataclass, field
import os import os
import re
import urllib.parse import urllib.parse
from typing import Any from typing import Any
@@ -17,6 +19,7 @@ from govoplan_addresses.backend.carddav import (
AddressCardDAVAddressBook, AddressCardDAVAddressBook,
AddressCardDAVClient, AddressCardDAVClient,
AddressCardDAVError, AddressCardDAVError,
AddressCardDAVObject,
AddressCardDAVPreconditionFailed, AddressCardDAVPreconditionFailed,
AddressCardDAVReportResult, AddressCardDAVReportResult,
AddressCardDAVSyncUnsupported, AddressCardDAVSyncUnsupported,
@@ -57,7 +60,7 @@ from govoplan_addresses.backend.schemas import (
ContactPostalAddressPayload, ContactPostalAddressPayload,
ContactUpdateRequest, ContactUpdateRequest,
) )
from govoplan_addresses.backend.vcard import ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues from govoplan_addresses.backend.vcard import ParsedVCard, ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues
class AddressBookError(ValueError): class AddressBookError(ValueError):
@@ -256,6 +259,8 @@ def create_sync_source(
principal: ApiPrincipal, principal: ApiPrincipal,
address_book_id: str, address_book_id: str,
payload: AddressSyncSourceCreateRequest, payload: AddressSyncSourceCreateRequest,
*,
trusted_connector_metadata: bool = False,
) -> AddressSyncSource: ) -> AddressSyncSource:
book = get_visible_address_book(session, principal, address_book_id) book = get_visible_address_book(session, principal, address_book_id)
if book.deleted_at is not None: if book.deleted_at is not None:
@@ -266,6 +271,8 @@ def create_sync_source(
raise AddressBookError("Sync connector type is required.") raise AddressBookError("Sync connector type is required.")
if not display_name: if not display_name:
raise AddressBookError("Sync source display name is required.") raise AddressBookError("Sync source display name is required.")
if connector_type.casefold() == "carddav" and not trusted_connector_metadata:
_assert_api_carddav_metadata_safe(payload.metadata)
read_only = _read_only_from_sync_direction(payload.sync_direction, payload.read_only) read_only = _read_only_from_sync_direction(payload.sync_direction, payload.read_only)
sync_source = AddressSyncSource( sync_source = AddressSyncSource(
tenant_id=book.tenant_id, tenant_id=book.tenant_id,
@@ -325,7 +332,11 @@ def update_sync_source(
if "remote_revision" in payload.model_fields_set: if "remote_revision" in payload.model_fields_set:
sync_source.remote_revision = _trim(payload.remote_revision) sync_source.remote_revision = _trim(payload.remote_revision)
if "metadata" in payload.model_fields_set: if "metadata" in payload.model_fields_set:
sync_source.metadata_ = payload.metadata or {} metadata = payload.metadata or {}
if sync_source.connector_type.casefold() == "carddav":
_assert_api_carddav_metadata_safe(metadata)
metadata = _merge_server_owned_carddav_metadata(sync_source.metadata_, metadata)
sync_source.metadata_ = metadata
sync_source.updated_by_account_id = _account_id(principal) sync_source.updated_by_account_id = _account_id(principal)
_apply_sync_source_to_book(sync_source.address_book, sync_source) _apply_sync_source_to_book(sync_source.address_book, sync_source)
return sync_source return sync_source
@@ -588,6 +599,7 @@ def discover_carddav_address_books(
principal: ApiPrincipal, principal: ApiPrincipal,
payload: AddressCardDavDiscoveryRequest, payload: AddressCardDavDiscoveryRequest,
) -> list[AddressCardDAVAddressBook]: ) -> list[AddressCardDAVAddressBook]:
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
source = get_visible_sync_source(session, principal, payload.source_id) if payload.source_id else None source = get_visible_sync_source(session, principal, payload.source_id) if payload.source_id else None
client = _carddav_client_from_payload(session, principal, payload, source=source) client = _carddav_client_from_payload(session, principal, payload, source=source)
return client.discover_addressbooks() return client.discover_addressbooks()
@@ -599,6 +611,7 @@ def create_carddav_sync_source(
address_book_id: str, address_book_id: str,
payload: AddressCardDavSourceCreateRequest, payload: AddressCardDavSourceCreateRequest,
) -> AddressSyncSource: ) -> AddressSyncSource:
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
collection_url = ensure_collection_url(payload.collection_url) collection_url = ensure_collection_url(payload.collection_url)
display_name = _trim(payload.display_name) or "CardDAV address book" display_name = _trim(payload.display_name) or "CardDAV address book"
metadata = _carddav_metadata( metadata = _carddav_metadata(
@@ -606,7 +619,7 @@ def create_carddav_sync_source(
username=payload.username, username=payload.username,
password=_secret_value(payload.password), password=_secret_value(payload.password),
bearer_token=_secret_value(payload.bearer_token), bearer_token=_secret_value(payload.bearer_token),
credential_ref=payload.credential_ref, credential_ref=None,
collection_url=collection_url, collection_url=collection_url,
) )
return create_sync_source( return create_sync_source(
@@ -624,6 +637,7 @@ def create_carddav_sync_source(
remote_revision=payload.remote_revision, remote_revision=payload.remote_revision,
metadata=metadata, metadata=metadata,
), ),
trusted_connector_metadata=True,
) )
@@ -759,15 +773,85 @@ def _plan_carddav_report(
for item in report.objects: for item in report.objects:
href = item.href href = item.href
seen_hrefs.add(href) seen_hrefs.add(href)
local = local_by_href.get(href) _plan_carddav_report_object(
sync_source=sync_source,
client=client,
item=item,
local=local_by_href.get(href),
reads_remote=reads_remote,
plan=plan,
)
if plan.stats.full_sync:
_plan_carddav_full_sync_absences(
sync_source=sync_source,
local_by_href=local_by_href,
seen_hrefs=seen_hrefs,
reads_remote=reads_remote,
plan=plan,
)
_plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan)
def _plan_carddav_report_object(
*,
sync_source: AddressSyncSource,
client: AddressCardDAVClient,
item: AddressCardDAVObject,
local: Contact | None,
reads_remote: bool,
plan: AddressSyncPlan,
) -> None:
if item.deleted: if item.deleted:
if local is not None and local.deleted_at is None: _plan_carddav_remote_deletion(
sync_source=sync_source,
item=item,
local=local,
reads_remote=reads_remote,
plan=plan,
)
return
loaded = _load_carddav_report_vcard(client=client, item=item, plan=plan)
if loaded is None:
return
raw_vcard, parsed = loaded
_plan_carddav_live_object(
sync_source=sync_source,
item=item,
local=local,
reads_remote=reads_remote,
raw_vcard=raw_vcard,
parsed=parsed,
plan=plan,
)
def _plan_carddav_remote_deletion(
*,
sync_source: AddressSyncSource,
item: AddressCardDAVObject,
local: Contact | None,
reads_remote: bool,
plan: AddressSyncPlan,
) -> None:
if local is None or local.deleted_at is not None:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="unchanged",
href=item.href,
etag=item.etag,
message="Remote delete already reflected locally.",
),
)
return
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
_add_sync_plan_item( _add_sync_plan_item(
plan, plan,
AddressSyncPlanItem( AddressSyncPlanItem(
action="conflict", action="conflict",
href=href, href=item.href,
contact_id=local.id, contact_id=local.id,
display_name=local.display_name, display_name=local.display_name,
etag=item.etag, etag=item.etag,
@@ -775,49 +859,75 @@ def _plan_carddav_report(
), ),
) )
elif reads_remote: elif reads_remote:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="delete", href=href, contact_id=local.id, display_name=local.display_name, etag=item.etag))
else:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, etag=item.etag, message="Remote delete already reflected locally."))
continue
raw_vcard = item.address_data
if not raw_vcard:
try:
raw_vcard = client.fetch_object(href)
except AddressCardDAVError as exc:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="error", href=href, etag=item.etag, message=str(exc)))
continue
parsed, error_message = _parse_single_remote_vcard(raw_vcard)
if parsed is None:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="error", href=href, etag=item.etag, message=error_message or "Remote vCard could not be parsed."))
continue
if local is None:
if reads_remote:
_add_sync_plan_item( _add_sync_plan_item(
plan, plan,
AddressSyncPlanItem( AddressSyncPlanItem(
action="create", action="delete",
href=href, href=item.href,
remote_uid=parsed.source_ref, contact_id=local.id,
display_name=parsed.payload.display_name, display_name=local.display_name,
etag=item.etag, etag=item.etag,
raw_vcard=raw_vcard,
parsed_payload=parsed.payload,
source_revision=item.etag or parsed.source_revision,
), ),
) )
else:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, etag=item.etag, message="Remote object ignored by export-only sync source."))
continue
def _load_carddav_report_vcard(
*,
client: AddressCardDAVClient,
item: AddressCardDAVObject,
plan: AddressSyncPlan,
) -> tuple[str, ParsedVCard] | None:
raw_vcard = item.address_data
if not raw_vcard:
try:
raw_vcard = client.fetch_object(item.href)
except AddressCardDAVError as exc:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(action="error", href=item.href, etag=item.etag, message=str(exc)),
)
return None
parsed, error_message = _parse_single_remote_vcard(raw_vcard)
if parsed is None:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="error",
href=item.href,
etag=item.etag,
message=error_message or "Remote vCard could not be parsed.",
),
)
return None
return raw_vcard, parsed
def _plan_carddav_live_object(
*,
sync_source: AddressSyncSource,
item: AddressCardDAVObject,
local: Contact | None,
reads_remote: bool,
raw_vcard: str,
parsed: ParsedVCard,
plan: AddressSyncPlan,
) -> None:
if local is None:
_plan_new_carddav_remote_object(
item=item,
reads_remote=reads_remote,
raw_vcard=raw_vcard,
parsed=parsed,
plan=plan,
)
return
remote_revision = item.etag or parsed.source_revision remote_revision = item.etag or parsed.source_revision
if local.deleted_at is not None and _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): if _carddav_local_delete_needs_push(local, sync_source):
_add_sync_plan_item( _add_sync_plan_item(
plan, plan,
AddressSyncPlanItem( AddressSyncPlanItem(
action="remote_delete", action="remote_delete",
href=href, href=item.href,
remote_uid=parsed.source_ref, remote_uid=parsed.source_ref,
contact_id=local.id, contact_id=local.id,
display_name=local.display_name, display_name=local.display_name,
@@ -825,37 +935,22 @@ def _plan_carddav_report(
message="Local delete will be pushed to CardDAV.", message="Local delete will be pushed to CardDAV.",
), ),
) )
continue return
if local.deleted_at is None and local.source_revision == remote_revision: if local.deleted_at is None and local.source_revision == remote_revision:
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): _plan_matching_carddav_revision(
_add_sync_plan_item( sync_source=sync_source,
plan, item=item,
AddressSyncPlanItem( local=local,
action="remote_update", parsed=parsed,
href=href, plan=plan,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag or local.source_revision,
raw_vcard=_carddav_contact_vcard(local, href=href),
source_revision=item.etag or local.source_revision,
message="Local update will be pushed to CardDAV.",
),
) )
continue return
_add_sync_plan_item(
plan,
AddressSyncPlanItem(action="unchanged", href=href, remote_uid=parsed.source_ref, contact_id=local.id, display_name=local.display_name, etag=item.etag),
)
continue
if _local_contact_changed_after_last_sync(local, sync_source): if _local_contact_changed_after_last_sync(local, sync_source):
_add_sync_plan_item( _add_sync_plan_item(
plan, plan,
AddressSyncPlanItem( AddressSyncPlanItem(
action="conflict", action="conflict",
href=href, href=item.href,
remote_uid=parsed.source_ref, remote_uid=parsed.source_ref,
contact_id=local.id, contact_id=local.id,
display_name=local.display_name, display_name=local.display_name,
@@ -866,14 +961,113 @@ def _plan_carddav_report(
message="Local contact changed since the last successful sync.", message="Local contact changed since the last successful sync.",
), ),
) )
continue return
_plan_carddav_remote_update(
item=item,
local=local,
reads_remote=reads_remote,
raw_vcard=raw_vcard,
parsed=parsed,
remote_revision=remote_revision,
plan=plan,
)
def _plan_new_carddav_remote_object(
*,
item: AddressCardDAVObject,
reads_remote: bool,
raw_vcard: str,
parsed: ParsedVCard,
plan: AddressSyncPlan,
) -> None:
if reads_remote:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="create",
href=item.href,
remote_uid=parsed.source_ref,
display_name=parsed.payload.display_name,
etag=item.etag,
raw_vcard=raw_vcard,
parsed_payload=parsed.payload,
source_revision=item.etag or parsed.source_revision,
),
)
else:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="unchanged",
href=item.href,
etag=item.etag,
message="Remote object ignored by export-only sync source.",
),
)
def _carddav_local_delete_needs_push(local: Contact, sync_source: AddressSyncSource) -> bool:
return bool(
local.deleted_at is not None
and _sync_source_writes_remote(sync_source)
and _local_contact_changed_after_last_sync(local, sync_source)
)
def _plan_matching_carddav_revision(
*,
sync_source: AddressSyncSource,
item: AddressCardDAVObject,
local: Contact,
parsed: ParsedVCard,
plan: AddressSyncPlan,
) -> None:
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="remote_update",
href=item.href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag or local.source_revision,
raw_vcard=_carddav_contact_vcard(local, href=item.href),
source_revision=item.etag or local.source_revision,
message="Local update will be pushed to CardDAV.",
),
)
return
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="unchanged",
href=item.href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag,
),
)
def _plan_carddav_remote_update(
*,
item: AddressCardDAVObject,
local: Contact,
reads_remote: bool,
raw_vcard: str,
parsed: ParsedVCard,
remote_revision: str | None,
plan: AddressSyncPlan,
) -> None:
if reads_remote: if reads_remote:
_add_sync_plan_item( _add_sync_plan_item(
plan, plan,
AddressSyncPlanItem( AddressSyncPlanItem(
action="update", action="update",
href=href, href=item.href,
remote_uid=parsed.source_ref, remote_uid=parsed.source_ref,
contact_id=local.id, contact_id=local.id,
display_name=parsed.payload.display_name or local.display_name, display_name=parsed.payload.display_name or local.display_name,
@@ -884,11 +1078,30 @@ def _plan_carddav_report(
), ),
) )
else: else:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, contact_id=local.id, display_name=local.display_name, etag=item.etag, message="Remote update ignored by export-only sync source.")) _add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="unchanged",
href=item.href,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag,
message="Remote update ignored by export-only sync source.",
),
)
if plan.stats.full_sync:
def _plan_carddav_full_sync_absences(
*,
sync_source: AddressSyncSource,
local_by_href: dict[str, Contact],
seen_hrefs: set[str],
reads_remote: bool,
plan: AddressSyncPlan,
) -> None:
for href, contact in local_by_href.items(): for href, contact in local_by_href.items():
if href not in seen_hrefs and contact.deleted_at is None: if href in seen_hrefs or contact.deleted_at is not None:
continue
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(contact, sync_source): if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(contact, sync_source):
_add_sync_plan_item( _add_sync_plan_item(
plan, plan,
@@ -902,9 +1115,16 @@ def _plan_carddav_report(
), ),
) )
elif reads_remote: elif reads_remote:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="delete", href=href, contact_id=contact.id, display_name=contact.display_name, message="Remote object is absent from full sync.")) _add_sync_plan_item(
plan,
_plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan) AddressSyncPlanItem(
action="delete",
href=href,
contact_id=contact.id,
display_name=contact.display_name,
message="Remote object is absent from full sync.",
),
)
def _plan_carddav_outbound_local_changes( def _plan_carddav_outbound_local_changes(
@@ -1308,6 +1528,7 @@ def _carddav_client_from_payload(
*, *,
source: AddressSyncSource | None = None, source: AddressSyncSource | None = None,
) -> AddressCardDAVClient: ) -> AddressCardDAVClient:
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
url = payload.url or (source.external_address_book_ref if source else "") url = payload.url or (source.external_address_book_ref if source else "")
metadata = dict(source.metadata_ or {}) if source else {} metadata = dict(source.metadata_ or {}) if source else {}
auth = dict(metadata.get("carddav") or {}) auth = dict(metadata.get("carddav") or {})
@@ -1391,13 +1612,83 @@ def _resolve_carddav_secret(
return password return password
if auth_type == "bearer" and bearer_token: if auth_type == "bearer" and bearer_token:
return bearer_token return bearer_token
if credential_ref and credential_ref.startswith(CARDDAV_SECRET_ENV_PREFIX): if credential_ref:
return os.environ.get(credential_ref.removeprefix(CARDDAV_SECRET_ENV_PREFIX)) raise AddressBookError(
"The CardDAV credential reference is not a server-owned credential; provide a replacement password or token"
)
if encrypted: if encrypted:
return decrypt_secret(encrypted) return decrypt_secret(encrypted)
return None return None
def resolve_trusted_deployment_carddav_credential_ref(credential_ref: str) -> str | None:
"""Resolve env-backed credentials only for trusted deployment code.
API-managed discovery and sync-source paths deliberately never call this
function, so a tenant user cannot select arbitrary process environment
variables as connector credentials.
"""
if not credential_ref.startswith(CARDDAV_SECRET_ENV_PREFIX):
raise AddressBookError("Trusted deployment credential references must use the env: prefix")
env_name = credential_ref.removeprefix(CARDDAV_SECRET_ENV_PREFIX)
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_name):
raise AddressBookError("Trusted deployment credential reference contains an invalid environment variable name")
return os.environ.get(env_name)
def public_address_sync_metadata(metadata: object) -> dict[str, Any]:
payload = copy.deepcopy(metadata) if isinstance(metadata, dict) else {}
auth = payload.get("carddav")
if not isinstance(auth, dict):
return payload
had_credential = bool(auth.get("secret_encrypted") or auth.get("credential_ref"))
auth.pop("secret_encrypted", None)
auth.pop("credential_ref", None)
auth["has_credential"] = had_credential
return payload
def _assert_no_caller_carddav_credential_ref(credential_ref: str | None) -> None:
if _trim(credential_ref):
raise AddressBookError(
"Caller-supplied credential references are not accepted; provide a password or bearer token"
)
def _carddav_auth_metadata(metadata: object) -> dict[str, Any]:
if not isinstance(metadata, dict):
return {}
carddav = metadata.get("carddav")
return carddav if isinstance(carddav, dict) else {}
def _assert_api_carddav_metadata_safe(metadata: object) -> None:
auth = _carddav_auth_metadata(metadata)
if auth.get("credential_ref") or auth.get("secret_encrypted"):
raise AddressBookError(
"CardDAV credential references and encrypted secrets are server-managed; provide credentials through the CardDAV source endpoint"
)
def _merge_server_owned_carddav_metadata(existing: object, incoming: dict[str, Any]) -> dict[str, Any]:
merged = copy.deepcopy(incoming)
existing_auth = _carddav_auth_metadata(existing)
server_owned = {
key: existing_auth[key]
for key in ("credential_ref", "secret_encrypted")
if existing_auth.get(key)
}
if not server_owned:
return merged
auth = merged.get("carddav")
if not isinstance(auth, dict):
auth = {}
merged["carddav"] = auth
auth.update(server_owned)
return merged
def _secret_value(value: Any | None) -> str | None: def _secret_value(value: Any | None) -> str | None:
if value is None: if value is None:
return None return None

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from unittest.mock import patch
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -33,6 +34,7 @@ from govoplan_addresses.backend.db.models import (
) )
from govoplan_addresses.backend.schemas import ( from govoplan_addresses.backend.schemas import (
AddressBookCreateRequest, AddressBookCreateRequest,
AddressCardDavDiscoveryRequest,
AddressListCreateRequest, AddressListCreateRequest,
AddressListEntryCreateRequest, AddressListEntryCreateRequest,
AddressCardDavSourceCreateRequest, AddressCardDavSourceCreateRequest,
@@ -48,6 +50,7 @@ from govoplan_addresses.backend.schemas import (
ContactPostalAddressPayload, ContactPostalAddressPayload,
) )
from govoplan_addresses.backend.manifest import manifest from govoplan_addresses.backend.manifest import manifest
from govoplan_addresses.backend.router import _sync_source_response
from govoplan_addresses.backend.service import ( from govoplan_addresses.backend.service import (
AddressBookError, AddressBookError,
address_book_contact_counts, address_book_contact_counts,
@@ -61,6 +64,7 @@ from govoplan_addresses.backend.service import (
delete_address_list_entry, delete_address_list_entry,
delete_contact, delete_contact,
delete_sync_source, delete_sync_source,
discover_carddav_address_books,
export_address_book_vcard, export_address_book_vcard,
import_vcards, import_vcards,
list_address_list_entries, list_address_list_entries,
@@ -81,6 +85,7 @@ from govoplan_addresses.backend.service import (
start_sync_attempt, start_sync_attempt,
finish_sync_attempt, finish_sync_attempt,
update_sync_source, update_sync_source,
resolve_trusted_deployment_carddav_credential_ref,
) )
@@ -787,6 +792,118 @@ END:VCARD
self.assertEqual(contacts[0].display_name, "Ada Remote") self.assertEqual(contacts[0].display_name, "Ada Remote")
self.assertEqual(contacts[0].emails[0].email, "ada.remote@example.local") self.assertEqual(contacts[0].emails[0].email, "ada.remote@example.local")
def test_carddav_credentials_are_server_managed_and_public_metadata_is_redacted(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Secured remote"),
)
self.session.commit()
self.session.refresh(book)
with self.assertRaisesRegex(AddressBookError, "Caller-supplied credential references"):
create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://attacker.example.test/addressbooks/personal/",
auth_type="bearer",
credential_ref="env:MASTER_KEY_B64",
),
)
with self.assertRaisesRegex(AddressBookError, "Caller-supplied credential references"):
discover_carddav_address_books(
self.session,
self.principal,
AddressCardDavDiscoveryRequest(
url="https://attacker.example.test/addressbooks/",
auth_type="bearer",
credential_ref="env:MASTER_KEY_B64",
),
)
with self.assertRaisesRegex(AddressBookError, "server-managed"):
create_sync_source(
self.session,
self.principal,
book.id,
AddressSyncSourceCreateRequest(
connector_type="carddav",
display_name="Injected",
metadata={"carddav": {"auth_type": "bearer", "credential_ref": "env:DATABASE_URL"}},
),
)
source = create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://dav.example.test/addressbooks/personal/",
auth_type="basic",
username="ada",
password="secret",
),
)
stored_auth = dict((source.metadata_ or {})["carddav"])
self.assertIn("secret_encrypted", stored_auth)
self.assertNotEqual(stored_auth["secret_encrypted"], "secret")
response_auth = _sync_source_response(source).metadata["carddav"]
self.assertNotIn("secret_encrypted", response_auth)
self.assertNotIn("credential_ref", response_auth)
self.assertTrue(response_auth["has_credential"])
with self.assertRaisesRegex(AddressBookError, "server-managed"):
update_sync_source(
self.session,
self.principal,
source.id,
AddressSyncSourceUpdateRequest(
metadata={"carddav": {"auth_type": "bearer", "secret_encrypted": "copied-ciphertext"}}
),
)
def test_legacy_carddav_env_reference_is_not_resolved_by_runtime_sync(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Legacy remote"),
)
self.session.commit()
self.session.refresh(book)
source = create_sync_source(
self.session,
self.principal,
book.id,
AddressSyncSourceCreateRequest(
connector_type="carddav",
display_name="Legacy",
external_address_book_ref="https://attacker.example.test/addressbooks/personal/",
),
)
source.metadata_ = {
"carddav": {
"auth_type": "bearer",
"credential_ref": "env:MASTER_KEY_B64",
}
}
with patch.dict("os.environ", {"MASTER_KEY_B64": "must-not-leave-process"}), self.assertRaisesRegex(
AddressBookError,
"not a server-owned credential",
):
run_sync_source(self.session, self.principal, source.id)
def test_trusted_deployment_carddav_env_resolution_is_explicit(self) -> None:
with patch.dict("os.environ", {"CARDDAV_DEPLOYMENT_TOKEN": "trusted-token"}):
self.assertEqual(
resolve_trusted_deployment_carddav_credential_ref("env:CARDDAV_DEPLOYMENT_TOKEN"),
"trusted-token",
)
with self.assertRaisesRegex(AddressBookError, "must use the env: prefix"):
resolve_trusted_deployment_carddav_credential_ref("vault:token")
def test_carddav_two_way_pushes_local_creates_updates_and_deletes(self) -> None: def test_carddav_two_way_pushes_local_creates_updates_and_deletes(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Writable Remote")) book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Writable Remote"))
self.session.commit() self.session.commit()

View File

@@ -37,6 +37,22 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
class CardDAVUrlSecurityTests(unittest.TestCase): class CardDAVUrlSecurityTests(unittest.TestCase):
def test_transport_revalidates_dns_at_connection_time(self) -> None:
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
AddressCardDAVError,
"non-public network",
):
urllib_transport("GET", "https://dav.example.test/contact.vcf", {}, None, 2)
socket_factory.assert_not_called()
def test_discovery_href_must_remain_on_configured_origin(self) -> None: def test_discovery_href_must_remain_on_configured_origin(self) -> None:
base_url = "https://dav.example.test/addressbooks/ada/" base_url = "https://dav.example.test/addressbooks/ada/"

View File

@@ -13,8 +13,11 @@
}, },
"./styles/addresses.css": "./src/styles/addresses.css" "./styles/addresses.css": "./src/styles/addresses.css"
}, },
"scripts": {
"test:ui-structure": "node scripts/test-selection-list-structure.mjs"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const pagePath = fileURLToPath(new URL("../src/features/addressbook/AddressBookPage.tsx", import.meta.url));
const stylesPath = fileURLToPath(new URL("../src/styles/addresses.css", import.meta.url));
const page = readFileSync(pagePath, "utf8");
const styles = readFileSync(stylesPath, "utf8");
assert.match(page, /SegmentedControl,[\s\S]*SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(page, /<SelectionList label="Contacts" className="address-contact-selection-list">/);
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selected\}[\s\S]*className=\{`address-contact-row/);
assert.match(page, /draggable=\{!contact\.deleted_at && !saving\}/);
assert.match(page, /<SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">/);
assert.match(page, /selected=\{cardDavForm\.collection_url === item\.collection_url\}/);
assert.match(page, /<SegmentedControl<ConflictMergeChoice>[\s\S]*role="group"[\s\S]*value=\{conflictMergeChoices\[row\.field\] \?\? "local"\}/);
assert.doesNotMatch(page, /<button[\s\S]{0,160}(?:address-contact-row|address-sync-result-row)/);
assert.doesNotMatch(styles, /\.address-conflict-choice button/);
assert.doesNotMatch(styles, /\.address-contact-row:(?:hover|focus-visible)/);
console.log("Address-book flat selections use central components.");

View File

@@ -450,7 +450,7 @@ export async function listAddressSyncSources(
export function discoverCardDavAddressBooks( export function discoverCardDavAddressBooks(
settings: ApiSettings, settings: ApiSettings,
payload: { url: string; auth_type: "none" | "basic" | "bearer"; username?: string | null; password?: string | null; bearer_token?: string | null; credential_ref?: string | null } payload: { url: string; auth_type: "none" | "basic" | "bearer"; username?: string | null; password?: string | null; bearer_token?: string | null }
): Promise<AddressCardDavAddressBook[]> { ): Promise<AddressCardDavAddressBook[]> {
return apiFetch<AddressCardDavDiscoveryResponse>(settings, "/api/v1/addresses/carddav/discover", { return apiFetch<AddressCardDavDiscoveryResponse>(settings, "/api/v1/addresses/carddav/discover", {
method: "POST", method: "POST",
@@ -468,7 +468,6 @@ export function createCardDavSyncSource(
username?: string | null; username?: string | null;
password?: string | null; password?: string | null;
bearer_token?: string | null; bearer_token?: string | null;
credential_ref?: string | null;
sync_direction: "read_only" | "import" | "export" | "two_way"; sync_direction: "read_only" | "import" | "export" | "two_way";
read_only?: boolean | null; read_only?: boolean | null;
sync_token?: string | null; sync_token?: string | null;

View File

@@ -1,21 +1,27 @@
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react"; import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type ButtonHTMLAttributes, type DragEvent as ReactDragEvent, type FormEvent } from "react"; import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import { import {
ApiError, ApiError,
Button, Button,
ConfirmDialog, ConfirmDialog,
Dialog, Dialog,
DisabledActionTooltip, DismissibleAlert,
ExplorerTree, ExplorerTree,
fetchAuthGroups, fetchAuthGroups,
formatDateTime,
FormField, FormField,
LoadingFrame, LoadingFrame,
PasswordField,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge, StatusBadge,
ToggleSwitch, ToggleSwitch,
hasScope, hasScope,
type ApiSettings, type ApiSettings,
type AuthInfo, type AuthInfo,
type AuthUpdate type AuthUpdate,
type FormatDateTimeOptions
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
createAddressBook, createAddressBook,
@@ -141,7 +147,6 @@ type CardDavFormState = {
username: string; username: string;
password: string; password: string;
bearer_token: string; bearer_token: string;
credential_ref: string;
sync_direction: "read_only" | "import" | "export" | "two_way"; sync_direction: "read_only" | "import" | "export" | "two_way";
}; };
@@ -198,7 +203,6 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = {
username: "", username: "",
password: "", password: "",
bearer_token: "", bearer_token: "",
credential_ref: "",
sync_direction: "read_only" sync_direction: "read_only"
}; };
@@ -289,14 +293,17 @@ function syncSourceLabel(source: AddressSyncSource): string {
return source.display_name || source.external_address_book_ref || source.connector_type; return source.display_name || source.external_address_book_ref || source.connector_type;
} }
function formatDateTime(value?: string | null): string { const ADDRESS_DATE_TIME_OPTIONS: FormatDateTimeOptions = {
if (!value) return "Never"; fallback: "Never",
try { year: undefined,
return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); month: undefined,
} catch { day: undefined,
return value; hour: undefined,
} minute: undefined,
} timeZoneName: undefined,
dateStyle: "short",
timeStyle: "short"
};
function planSummary(plan: AddressSyncPlan | null): string { function planSummary(plan: AddressSyncPlan | null): string {
if (!plan) return "No preview loaded."; if (!plan) return "No preview loaded.";
@@ -604,19 +611,6 @@ function disabledReason(...conditions: Array<[boolean, string]>): string {
return conditions.find(([applies]) => applies)?.[1] ?? ""; return conditions.find(([applies]) => applies)?.[1] ?? "";
} }
type ReasonedButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost" | "danger";
disabledReason: string;
};
function ReasonedButton({ disabledReason, disabled, children, ...props }: ReasonedButtonProps) {
return (
<DisabledActionTooltip reason={disabledReason}>
<Button {...props} disabled={disabled || Boolean(disabledReason)}>{children}</Button>
</DisabledActionTooltip>
);
}
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) { export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
const [books, setBooks] = useState<AddressBook[]>([]); const [books, setBooks] = useState<AddressBook[]>([]);
const [addressLists, setAddressLists] = useState<AddressList[]>([]); const [addressLists, setAddressLists] = useState<AddressList[]>([]);
@@ -1528,8 +1522,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
auth_type: cardDavForm.auth_type, auth_type: cardDavForm.auth_type,
username: cardDavForm.username || null, username: cardDavForm.username || null,
password: cardDavForm.password || null, password: cardDavForm.password || null,
bearer_token: cardDavForm.bearer_token || null, bearer_token: cardDavForm.bearer_token || null
credential_ref: cardDavForm.credential_ref || null
}; };
} }
@@ -1570,7 +1563,6 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
username: cardDavForm.username || null, username: cardDavForm.username || null,
password: cardDavForm.password || null, password: cardDavForm.password || null,
bearer_token: cardDavForm.bearer_token || null, bearer_token: cardDavForm.bearer_token || null,
credential_ref: cardDavForm.credential_ref || null,
sync_direction: cardDavForm.sync_direction, sync_direction: cardDavForm.sync_direction,
read_only: cardDavForm.sync_direction === "read_only" || cardDavForm.sync_direction === "import" read_only: cardDavForm.sync_direction === "read_only" || cardDavForm.sync_direction === "import"
}); });
@@ -1753,7 +1745,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedBookSyncSources.map((source) => ( {selectedBookSyncSources.map((source) => (
<p key={source.id}> <p key={source.id}>
{syncSourceLabel(source)} · {source.connector_type} · {source.status} {syncSourceLabel(source)} · {source.connector_type} · {source.status}
{source.last_success_at ? ` · last success ${formatDateTime(source.last_success_at)}` : ""} {source.last_success_at ? ` · last success ${formatDateTime(source.last_success_at, ADDRESS_DATE_TIME_OPTIONS)}` : ""}
{source.last_error ? ` · ${source.last_error}` : ""} {source.last_error ? ` · ${source.last_error}` : ""}
</p> </p>
))} ))}
@@ -1777,27 +1769,27 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function renderSelectedBookActions() { function renderSelectedBookActions() {
return ( return (
<> <>
<ReasonedButton type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></ReasonedButton> <Button type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></Button>
<ReasonedButton type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></ReasonedButton> <Button type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></Button>
<ReasonedButton type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></ReasonedButton> <Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
<ReasonedButton 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} /></ReasonedButton> <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>
<ReasonedButton 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} /></ReasonedButton> <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>
<ReasonedButton type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></ReasonedButton> <Button type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></Button>
<ReasonedButton type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></ReasonedButton> <Button type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></Button>
<ReasonedButton type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</ReasonedButton> <Button type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</Button>
<ReasonedButton type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</ReasonedButton> <Button type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</Button>
{selectedList ? {selectedList ?
selectedList.deleted_at ? selectedList.deleted_at ?
<ReasonedButton type="button" title="Restore address list" aria-label="Restore address list" disabledReason={restoreListReason(selectedList)} onClick={() => void restoreDeletedList(selectedList)}><RotateCcw size={15} /></ReasonedButton> : <Button type="button" title="Restore address list" aria-label="Restore address list" disabledReason={restoreListReason(selectedList)} onClick={() => void restoreDeletedList(selectedList)}><RotateCcw size={15} /></Button> :
<> <>
<ReasonedButton type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></ReasonedButton> <Button type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></Button>
<ReasonedButton 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} /></ReasonedButton> <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 ? selectedBook?.deleted_at ?
<ReasonedButton type="button" title="Restore address book" aria-label="Restore address book" disabledReason={restoreBookReason(selectedBook)} onClick={() => void restoreBook(selectedBook)}><RotateCcw size={15} /></ReasonedButton> : <Button type="button" title="Restore address book" aria-label="Restore address book" disabledReason={restoreBookReason(selectedBook)} onClick={() => void restoreBook(selectedBook)}><RotateCcw size={15} /></Button> :
<> <>
<ReasonedButton 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} /></ReasonedButton> <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>
<ReasonedButton 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} /></ReasonedButton> <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>
</> </>
} }
</> </>
@@ -1807,10 +1799,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function renderContactRow(contact: Contact) { function renderContactRow(contact: Contact) {
const selected = selectedContactId === contact.id; const selected = selectedContactId === contact.id;
return ( return (
<button <SelectionListItem
type="button"
key={contact.id} key={contact.id}
className={`address-contact-row ${selected ? "is-selected" : ""} ${contact.deleted_at ? "is-archived-row" : ""}`} selected={selected}
className={`address-contact-row ${contact.deleted_at ? "is-archived-row" : ""}`}
draggable={!contact.deleted_at && !saving} draggable={!contact.deleted_at && !saving}
onClick={() => setSelectedContactId(contact.id)} onClick={() => setSelectedContactId(contact.id)}
onDragStart={(event) => handleContactDragStart(event, contact)}> onDragStart={(event) => handleContactDragStart(event, contact)}>
@@ -1821,7 +1813,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<span className="address-contact-row-meta"> <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>)} {contact.deleted_at ? <StatusBadge status="archived" /> : contact.tags.slice(0, 2).map((tag) => <span key={tag} className="address-tag">{tag}</span>)}
</span> </span>
</button> </SelectionListItem>
); );
} }
@@ -1843,11 +1835,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
{selectedContact.deleted_at ? {selectedContact.deleted_at ?
<ReasonedButton type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</ReasonedButton> : <Button type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</Button> :
<> <>
<ReasonedButton type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</ReasonedButton> <Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
<ReasonedButton type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</ReasonedButton> <Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
<ReasonedButton type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</ReasonedButton> <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> </div>
@@ -1864,13 +1856,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedContactListEntries.map((entry) => ( {selectedContactListEntries.map((entry) => (
<div className="address-membership-row" key={entry.id}> <div className="address-membership-row" key={entry.id}>
<p className="muted">{entryTargetLabel(entry)}</p> <p className="muted">{entryTargetLabel(entry)}</p>
<ReasonedButton <Button
type="button" type="button"
variant="danger" variant="danger"
onClick={() => void removeAddressListEntry(entry)} onClick={() => void removeAddressListEntry(entry)}
disabledReason={removeContactFromListReason()}> disabledReason={removeContactFromListReason()}>
<X size={15} /> Remove <X size={15} /> Remove
</ReasonedButton> </Button>
</div> </div>
))} ))}
</div> </div>
@@ -1927,8 +1919,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
return ( return (
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen"> <div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
{error && <div className="alert alert-error address-error">{error}</div>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && !error && <div className="alert alert-success address-error">{notice}</div>} {notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading address books..." className="address-workspace-frame"> <LoadingFrame loading={loading} label="Loading address books..." className="address-workspace-frame">
<div className="address-book-workspace"> <div className="address-book-workspace">
@@ -1987,9 +1979,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
{selectedList && {selectedList &&
<ReasonedButton type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</ReasonedButton> <Button type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</Button>
} }
<ReasonedButton type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</ReasonedButton> <Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</Button>
</div> </div>
</header> </header>
<div className="address-contact-toolbar"> <div className="address-contact-toolbar">
@@ -1997,10 +1989,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedBook?.read_only && <StatusBadge status="read-only" />} {selectedBook?.read_only && <StatusBadge status="read-only" />}
{selectedBook?.deleted_at && <StatusBadge status="archived" />} {selectedBook?.deleted_at && <StatusBadge status="archived" />}
</div> </div>
<div className="address-contact-list" role="list"> <div className="address-contact-list">
{visibleContacts.length === 0 ? {visibleContacts.length === 0 ?
<div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> : <div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> :
visibleContacts.map(renderContactRow) <SelectionList label="Contacts" className="address-contact-selection-list">
{visibleContacts.map(renderContactRow)}
</SelectionList>
} }
</div> </div>
</section> </section>
@@ -2019,8 +2013,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<ReasonedButton type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton> <Button type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<ReasonedButton type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</ReasonedButton> <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)}> <form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
@@ -2055,8 +2049,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<ReasonedButton type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton> <Button type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<ReasonedButton type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</ReasonedButton> <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)}> <form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
@@ -2082,8 +2076,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<ReasonedButton type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton> <Button type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<ReasonedButton type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</ReasonedButton> <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)}> <form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
@@ -2099,7 +2093,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<section className="address-form-section"> <section className="address-form-section">
<div className="address-form-section-heading"> <div className="address-form-section-heading">
<strong>Email addresses</strong> <strong>Email addresses</strong>
<ReasonedButton type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</ReasonedButton> <Button type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</Button>
</div> </div>
<div className="address-form-list"> <div className="address-form-list">
{contactForm.emails.map((email) => ( {contactForm.emails.map((email) => (
@@ -2107,7 +2101,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<label className="address-primary-choice"><input type="radio" name="address-primary-email" checked={email.is_primary} onChange={() => setPrimaryEmailRow(email.rowId)} /> Primary</label> <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 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 })} /> <input type="email" value={email.email} placeholder="Email address" onChange={(event) => updateEmailRow(email.rowId, { email: event.target.value })} />
<ReasonedButton type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></ReasonedButton> <Button type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></Button>
</div> </div>
))} ))}
</div> </div>
@@ -2116,7 +2110,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<section className="address-form-section"> <section className="address-form-section">
<div className="address-form-section-heading"> <div className="address-form-section-heading">
<strong>Phone numbers</strong> <strong>Phone numbers</strong>
<ReasonedButton type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</ReasonedButton> <Button type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</Button>
</div> </div>
<div className="address-form-list"> <div className="address-form-list">
{contactForm.phones.map((phone) => ( {contactForm.phones.map((phone) => (
@@ -2124,7 +2118,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<label className="address-primary-choice"><input type="radio" name="address-primary-phone" checked={phone.is_primary} onChange={() => setPrimaryPhoneRow(phone.rowId)} /> Primary</label> <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.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 })} /> <input value={phone.phone} placeholder="Phone number" onChange={(event) => updatePhoneRow(phone.rowId, { phone: event.target.value })} />
<ReasonedButton type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></ReasonedButton> <Button type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></Button>
</div> </div>
))} ))}
</div> </div>
@@ -2133,7 +2127,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<section className="address-form-section"> <section className="address-form-section">
<div className="address-form-section-heading"> <div className="address-form-section-heading">
<strong>Postal addresses</strong> <strong>Postal addresses</strong>
<ReasonedButton type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</ReasonedButton> <Button type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</Button>
</div> </div>
<div className="address-form-list"> <div className="address-form-list">
{contactForm.postal_addresses.map((address) => ( {contactForm.postal_addresses.map((address) => (
@@ -2145,7 +2139,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<input value={address.locality} placeholder="Locality" onChange={(event) => updatePostalAddressRow(address.rowId, { locality: 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.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 })} /> <input value={address.country} placeholder="Country" onChange={(event) => updatePostalAddressRow(address.rowId, { country: event.target.value })} />
<ReasonedButton type="button" variant="danger" title="Remove postal address" aria-label="Remove postal address" disabledReason={removePostalAddressRowReason} onClick={() => removePostalAddressRow(address.rowId)}><Trash2 size={15} /></ReasonedButton> <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>
))} ))}
</div> </div>
@@ -2162,7 +2156,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
closeDisabled={saving} closeDisabled={saving}
className="address-member-dialog" className="address-member-dialog"
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={<ReasonedButton type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</ReasonedButton>}> footer={<Button type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</Button>}>
<div className="address-member-picker"> <div className="address-member-picker">
<div className="address-contact-toolbar address-member-search"> <div className="address-contact-toolbar address-member-search">
<input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus /> <input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus />
@@ -2186,12 +2180,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
disabled={saving || targetOptions.length === 0}> disabled={saving || targetOptions.length === 0}>
{targetOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)} {targetOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
</select> </select>
<ReasonedButton <Button
type="button" type="button"
onClick={() => selectedList && void addContactToAddressList(selectedList, contact, selectedTarget)} onClick={() => selectedList && void addContactToAddressList(selectedList, contact, selectedTarget)}
disabledReason={addContactToListReason(selectedList, contact, selectedTarget?.key ?? "")}> disabledReason={addContactToListReason(selectedList, contact, selectedTarget?.key ?? "")}>
<Plus size={15} /> Add <Plus size={15} /> Add
</ReasonedButton> </Button>
</div> </div>
); );
}) })
@@ -2209,8 +2203,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<ReasonedButton type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton> <Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<ReasonedButton type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</ReasonedButton> <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)}> <form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
@@ -2236,9 +2230,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<ReasonedButton type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton> <Button type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<ReasonedButton type="button" onClick={() => void discoverCardDavSources()} disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV URL before discovery."])}><Search size={16} /> Discover</ReasonedButton> <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>
<ReasonedButton 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</ReasonedButton> <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)}> <form id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}>
@@ -2273,27 +2267,28 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} /> <input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} />
</FormField> </FormField>
<FormField label="Password"> <FormField label="Password">
<input type="password" value={cardDavForm.password} onChange={(event) => setCardDavForm((current) => ({ ...current, password: event.target.value }))} /> <PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
</FormField> </FormField>
</div> </div>
} }
{cardDavForm.auth_type === "bearer" && {cardDavForm.auth_type === "bearer" &&
<FormField label="Bearer token"> <FormField label="Bearer token">
<input type="password" value={cardDavForm.bearer_token} onChange={(event) => setCardDavForm((current) => ({ ...current, bearer_token: event.target.value }))} /> <PasswordField value={cardDavForm.bearer_token} onValueChange={(value) => setCardDavForm((current) => ({ ...current, bearer_token: value }))} autoComplete="new-password" />
</FormField> </FormField>
} }
<FormField label="Credential reference">
<input value={cardDavForm.credential_ref} onChange={(event) => setCardDavForm((current) => ({ ...current, credential_ref: event.target.value }))} placeholder="env:GOVOPLAN_CARDDAV_PASSWORD" />
</FormField>
{cardDavDiscovery.length > 0 && {cardDavDiscovery.length > 0 &&
<div className="address-sync-result-list"> <SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">
{cardDavDiscovery.map((item) => ( {cardDavDiscovery.map((item) => (
<button type="button" className="address-sync-result-row" key={item.collection_url} onClick={() => useDiscoveredCardDavBook(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> <strong>{item.display_name || item.collection_url}</strong>
<small>{item.collection_url}</small> <small>{item.collection_url}</small>
</button> </SelectionListItem>
))} ))}
</div> </SelectionList>
} }
</form> </form>
</Dialog> </Dialog>
@@ -2307,9 +2302,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<ReasonedButton type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</ReasonedButton> <Button type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</Button>
<ReasonedButton type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</ReasonedButton> <Button type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</Button>
<ReasonedButton 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</ReasonedButton> <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 && {syncInspector &&
@@ -2324,22 +2319,22 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<StatusBadge status={syncInspector.source.status} /> <StatusBadge status={syncInspector.source.status} />
{syncInspector.source.read_only && <StatusBadge status="read-only" />} {syncInspector.source.read_only && <StatusBadge status="read-only" />}
</div> </div>
<p className="muted">Last attempt: {formatDateTime(syncInspector.source.last_attempted_at)} · Last success: {formatDateTime(syncInspector.source.last_success_at)}</p> <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 && <p className="alert alert-error">{syncInspector.source.last_error}</p>} {syncInspector.source.last_error && <DismissibleAlert tone="danger" resetKey={syncInspector.source.last_error}>{syncInspector.source.last_error}</DismissibleAlert>}
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<ReasonedButton <Button
type="button" type="button"
onClick={() => void toggleSyncSourceEnabled(syncInspector.source)} onClick={() => void toggleSyncSourceEnabled(syncInspector.source)}
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}> disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
{syncInspector.source.enabled ? "Disable" : "Enable"} {syncInspector.source.enabled ? "Disable" : "Enable"}
</ReasonedButton> </Button>
<ReasonedButton <Button
type="button" type="button"
variant="danger" variant="danger"
onClick={() => setConfirmState({ kind: "sync-source", source: syncInspector.source })} onClick={() => setConfirmState({ kind: "sync-source", source: syncInspector.source })}
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}> disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
Disconnect Disconnect
</ReasonedButton> </Button>
</div> </div>
</div> </div>
@@ -2366,7 +2361,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{syncConflicts.map((conflict) => ( {syncConflicts.map((conflict) => (
<div className="address-sync-record-row" key={conflict.id}> <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> <span><strong>{conflict.field_path}</strong><small>{conflict.resource_href || conflict.remote_uid || conflict.contact_id}</small></span>
<ReasonedButton type="button" onClick={() => openConflictReview(conflict)} disabledReason={disabledReason([saving, savingReason])}>Review</ReasonedButton> <Button type="button" onClick={() => openConflictReview(conflict)} disabledReason={disabledReason([saving, savingReason])}>Review</Button>
</div> </div>
))} ))}
</div> </div>
@@ -2393,7 +2388,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<div className="address-sync-record-list"> <div className="address-sync-record-list">
{syncTombstones.slice(0, 20).map((tombstone) => ( {syncTombstones.slice(0, 20).map((tombstone) => (
<div className="address-sync-record-row" key={tombstone.id}> <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)}</small></span> <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>
))} ))}
</div> </div>
@@ -2410,20 +2405,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
className="address-sync-dialog" className="address-sync-dialog"
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={conflictDialog && <> footer={conflictDialog && <>
<ReasonedButton type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</ReasonedButton> <Button type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</Button>
<ReasonedButton <Button
type="button" type="button"
onClick={() => void resolveConflictWith(conflictDialog, "ignored", "ignored")} onClick={() => void resolveConflictWith(conflictDialog, "ignored", "ignored")}
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}> disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
Ignore Ignore
</ReasonedButton> </Button>
<ReasonedButton <Button
type="button" type="button"
onClick={() => void resolveConflictWith(conflictDialog, "keep_local")} onClick={() => void resolveConflictWith(conflictDialog, "keep_local")}
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}> disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
Keep local Keep local
</ReasonedButton> </Button>
<ReasonedButton <Button
type="button" type="button"
onClick={() => void applyMergedConflict(conflictDialog)} onClick={() => void applyMergedConflict(conflictDialog)}
disabledReason={disabledReason( disabledReason={disabledReason(
@@ -2432,8 +2427,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[saving, savingReason] [saving, savingReason]
)}> )}>
Apply merged Apply merged
</ReasonedButton> </Button>
<ReasonedButton <Button
type="button" type="button"
variant="primary" variant="primary"
onClick={() => void resolveConflictWith(conflictDialog, "use_remote")} onClick={() => void resolveConflictWith(conflictDialog, "use_remote")}
@@ -2443,7 +2438,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[saving, savingReason] [saving, savingReason]
)}> )}>
Use remote Use remote
</ReasonedButton> </Button>
</>}> </>}>
{conflictDialog && {conflictDialog &&
<div className="address-conflict-review"> <div className="address-conflict-review">
@@ -2457,7 +2452,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />} {conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />}
</div> </div>
{conflictDialog.metadata?.message && <p className="muted">{String(conflictDialog.metadata.message)}</p>} {conflictDialog.metadata?.message && <p className="muted">{String(conflictDialog.metadata.message)}</p>}
{!canApplyRemoteConflict(conflictDialog) && <p className="alert alert-warning">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.</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>
<div className="address-conflict-grid"> <div className="address-conflict-grid">
<div className="address-conflict-grid-header">Field</div> <div className="address-conflict-grid-header">Field</div>
@@ -2469,15 +2464,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<strong>{row.field}</strong> <strong>{row.field}</strong>
<span>{row.local}</span> <span>{row.local}</span>
<span>{row.remote}</span> <span>{row.remote}</span>
<span className="address-conflict-choice"> <div>
{canMergeConflict(conflictDialog) ? {canMergeConflict(conflictDialog) ?
<> <SegmentedControl<ConflictMergeChoice>
<button type="button" className={conflictMergeChoices[row.field] !== "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "local")}>Local</button> className="address-conflict-choice"
<button type="button" className={conflictMergeChoices[row.field] === "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "remote")}>Remote</button> 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)}
/> :
"—" "—"
} }
</span> </div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -150,16 +150,19 @@
width: min(960px, calc(100vw - 36px)); width: min(960px, calc(100vw - 36px));
} }
.address-sync-record-list,
.address-sync-plan-grid {
display: grid;
}
.address-sync-result-list, .address-sync-result-list,
.address-sync-record-list, .address-sync-record-list,
.address-sync-plan-grid { .address-sync-plan-grid {
border: var(--border-line); border: var(--border-line);
display: grid;
max-height: 260px; max-height: 260px;
overflow: auto; overflow: auto;
} }
.address-sync-result-row,
.address-sync-record-row, .address-sync-record-row,
.address-sync-plan-row { .address-sync-plan-row {
align-items: center; align-items: center;
@@ -175,13 +178,11 @@
} }
.address-sync-result-row { .address-sync-result-row {
cursor: pointer; align-items: center;
} border-bottom: var(--border-line);
display: grid;
.address-sync-result-row:hover, gap: 10px;
.address-sync-result-row:focus-visible { grid-template-columns: minmax(0, 1fr) auto;
background: var(--line);
outline: none;
} }
.address-sync-result-row strong, .address-sync-result-row strong,
@@ -255,37 +256,6 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.address-conflict-choice {
align-items: center;
display: inline-flex;
gap: 0;
white-space: nowrap;
}
.address-conflict-choice button {
background: var(--panel);
border: var(--border-line);
color: var(--text);
cursor: pointer;
font: inherit;
min-height: 28px;
padding: 3px 8px;
}
.address-conflict-choice button:first-child {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.address-conflict-choice button:last-child {
border-left: 0;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.address-conflict-choice button.is-selected {
background: var(--line);
box-shadow: inset 0 1px 2px rgba(var(--shadow-color-rgb), .12);
}
.address-list-panel, .address-list-panel,
.address-detail-panel { .address-detail-panel {
display: flex; display: flex;
@@ -338,34 +308,15 @@
align-content: start; align-content: start;
} }
.address-contact-selection-list {
gap: 2px;
}
.address-contact-row { .address-contact-row {
align-items: center; align-items: center;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--text);
cursor: pointer;
display: grid; display: grid;
gap: 10px; gap: 10px;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
min-width: 0;
padding: 10px;
text-align: left;
}
.address-contact-row[draggable="true"] {
cursor: grab;
}
.address-contact-row[draggable="true"]:active {
cursor: grabbing;
}
.address-contact-row:hover,
.address-contact-row:focus-visible,
.address-contact-row.is-selected {
background: var(--line);
outline: none;
} }
.address-contact-row-main { .address-contact-row-main {
@@ -617,56 +568,10 @@
width: 30px; width: 30px;
} }
.form-grid {
display: grid;
gap: 12px;
}
.form-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.form-grid.four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.inline-actions {
justify-content: flex-end;
}
.inline-actions .btn {
align-items: center;
aspect-ratio: 1;
display: inline-flex;
justify-content: center;
min-height: 30px;
padding: 0;
width: 30px;
}
.strong-link { .strong-link {
font-weight: 700; font-weight: 700;
} }
.link-button {
background: transparent;
border: 0;
color: var(--text-strong);
cursor: pointer;
font: inherit;
padding: 0;
text-align: left;
}
.link-button:hover {
color: var(--accent);
text-decoration: underline;
}
.is-selected-row { .is-selected-row {
background: var(--panel-soft); background: var(--panel-soft);
} }
@@ -677,9 +582,6 @@
@media (max-width: 980px) { @media (max-width: 980px) {
.address-book-workspace, .address-book-workspace,
.form-grid.two,
.form-grid.three,
.form-grid.four,
.address-form-row-email, .address-form-row-email,
.address-form-row-phone, .address-form-row-phone,
.address-form-row-postal { .address-form-row-postal {