Compare commits

..

7 Commits

12 changed files with 738 additions and 370 deletions

View File

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

View File

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

View File

@@ -9,6 +9,12 @@ from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol
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):
@@ -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]:
url = validate_http_url(url)
try:
url = validate_outbound_http_url(url, label="CardDAV URL")
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
url,
data=body,
headers=dict(headers),
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
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:
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:
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
@@ -493,7 +510,8 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
del fp, msg, headers
try:
candidate = validate_http_url(newurl)
except AddressCardDAVError:
candidate = validate_outbound_http_url(candidate, label="CardDAV redirect URL")
except (AddressCardDAVError, OutboundHttpError):
return None
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
return None

View File

@@ -103,6 +103,7 @@ from govoplan_addresses.backend.service import (
restore_contact,
resolve_sync_conflict,
preview_sync_source,
public_address_sync_metadata,
run_sync_source,
start_sync_attempt,
update_address_book,
@@ -217,7 +218,7 @@ def _sync_source_response(sync_source: AddressSyncSource) -> AddressSyncSourceRe
"last_success_at": sync_source.last_success_at,
"last_error": sync_source.last_error,
"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,
"updated_at": sync_source.updated_at,
}

View File

@@ -1,8 +1,10 @@
from __future__ import annotations
import copy
from collections.abc import Iterable
from dataclasses import dataclass, field
import os
import re
import urllib.parse
from typing import Any
@@ -17,6 +19,7 @@ from govoplan_addresses.backend.carddav import (
AddressCardDAVAddressBook,
AddressCardDAVClient,
AddressCardDAVError,
AddressCardDAVObject,
AddressCardDAVPreconditionFailed,
AddressCardDAVReportResult,
AddressCardDAVSyncUnsupported,
@@ -57,7 +60,7 @@ from govoplan_addresses.backend.schemas import (
ContactPostalAddressPayload,
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):
@@ -256,6 +259,8 @@ def create_sync_source(
principal: ApiPrincipal,
address_book_id: str,
payload: AddressSyncSourceCreateRequest,
*,
trusted_connector_metadata: bool = False,
) -> AddressSyncSource:
book = get_visible_address_book(session, principal, address_book_id)
if book.deleted_at is not None:
@@ -266,6 +271,8 @@ def create_sync_source(
raise AddressBookError("Sync connector type is required.")
if not display_name:
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)
sync_source = AddressSyncSource(
tenant_id=book.tenant_id,
@@ -325,7 +332,11 @@ def update_sync_source(
if "remote_revision" in payload.model_fields_set:
sync_source.remote_revision = _trim(payload.remote_revision)
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)
_apply_sync_source_to_book(sync_source.address_book, sync_source)
return sync_source
@@ -588,6 +599,7 @@ def discover_carddav_address_books(
principal: ApiPrincipal,
payload: AddressCardDavDiscoveryRequest,
) -> 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
client = _carddav_client_from_payload(session, principal, payload, source=source)
return client.discover_addressbooks()
@@ -599,6 +611,7 @@ def create_carddav_sync_source(
address_book_id: str,
payload: AddressCardDavSourceCreateRequest,
) -> AddressSyncSource:
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
collection_url = ensure_collection_url(payload.collection_url)
display_name = _trim(payload.display_name) or "CardDAV address book"
metadata = _carddav_metadata(
@@ -606,7 +619,7 @@ def create_carddav_sync_source(
username=payload.username,
password=_secret_value(payload.password),
bearer_token=_secret_value(payload.bearer_token),
credential_ref=payload.credential_ref,
credential_ref=None,
collection_url=collection_url,
)
return create_sync_source(
@@ -624,6 +637,7 @@ def create_carddav_sync_source(
remote_revision=payload.remote_revision,
metadata=metadata,
),
trusted_connector_metadata=True,
)
@@ -759,152 +773,358 @@ def _plan_carddav_report(
for item in report.objects:
href = item.href
seen_hrefs.add(href)
local = local_by_href.get(href)
if item.deleted:
if local is not None and local.deleted_at is 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="conflict",
href=href,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag,
message="Remote object was deleted while the local contact changed since the last successful sync.",
),
)
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
_plan_carddav_report_object(
sync_source=sync_source,
client=client,
item=item,
local=local_by_href.get(href),
reads_remote=reads_remote,
plan=plan,
)
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 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,
)
if local is None:
if reads_remote:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="create",
href=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=href, etag=item.etag, message="Remote object ignored by export-only sync source."))
continue
_plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan)
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):
def _plan_carddav_report_object(
*,
sync_source: AddressSyncSource,
client: AddressCardDAVClient,
item: AddressCardDAVObject,
local: Contact | None,
reads_remote: bool,
plan: AddressSyncPlan,
) -> None:
if item.deleted:
_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):
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="conflict",
href=item.href,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag,
message="Remote object was deleted while the local contact changed since the last successful sync.",
),
)
elif reads_remote:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="delete",
href=item.href,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag,
),
)
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="remote_delete",
href=href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag or local.source_revision,
message="Local delete will be pushed to CardDAV.",
),
AddressSyncPlanItem(action="error", href=item.href, etag=item.etag, message=str(exc)),
)
continue
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
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):
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="remote_update",
href=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=href),
source_revision=item.etag or local.source_revision,
message="Local update will be pushed to CardDAV.",
),
)
continue
_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):
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
if _carddav_local_delete_needs_push(local, sync_source):
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="remote_delete",
href=item.href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag or local.source_revision,
message="Local delete will be pushed to CardDAV.",
),
)
return
if local.deleted_at is None and local.source_revision == remote_revision:
_plan_matching_carddav_revision(
sync_source=sync_source,
item=item,
local=local,
parsed=parsed,
plan=plan,
)
return
if _local_contact_changed_after_last_sync(local, sync_source):
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="conflict",
href=item.href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag,
raw_vcard=raw_vcard,
parsed_payload=parsed.payload,
source_revision=item.etag or parsed.source_revision,
message="Local contact changed since the last successful sync.",
),
)
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:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="update",
href=item.href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=parsed.payload.display_name or local.display_name,
etag=item.etag,
raw_vcard=raw_vcard,
parsed_payload=parsed.payload,
source_revision=remote_revision,
),
)
else:
_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.",
),
)
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():
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):
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="conflict",
href=href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag,
raw_vcard=raw_vcard,
parsed_payload=parsed.payload,
source_revision=item.etag or parsed.source_revision,
message="Local contact changed since the last successful sync.",
contact_id=contact.id,
display_name=contact.display_name,
etag=contact.source_revision,
message="Remote object is absent from full sync, but the local contact changed since the last successful sync.",
),
)
continue
if reads_remote:
elif reads_remote:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="update",
action="delete",
href=href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=parsed.payload.display_name or local.display_name,
etag=item.etag,
raw_vcard=raw_vcard,
parsed_payload=parsed.payload,
source_revision=remote_revision,
contact_id=contact.id,
display_name=contact.display_name,
message="Remote object is absent from full sync.",
),
)
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."))
if plan.stats.full_sync:
for href, contact in local_by_href.items():
if href not in seen_hrefs and contact.deleted_at is None:
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(contact, sync_source):
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="conflict",
href=href,
contact_id=contact.id,
display_name=contact.display_name,
etag=contact.source_revision,
message="Remote object is absent from full sync, but the local contact changed since the last successful sync.",
),
)
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."))
_plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan)
def _plan_carddav_outbound_local_changes(
@@ -1308,6 +1528,7 @@ def _carddav_client_from_payload(
*,
source: AddressSyncSource | None = None,
) -> AddressCardDAVClient:
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
url = payload.url or (source.external_address_book_ref if source else "")
metadata = dict(source.metadata_ or {}) if source else {}
auth = dict(metadata.get("carddav") or {})
@@ -1391,13 +1612,83 @@ def _resolve_carddav_secret(
return password
if auth_type == "bearer" and bearer_token:
return bearer_token
if credential_ref and credential_ref.startswith(CARDDAV_SECRET_ENV_PREFIX):
return os.environ.get(credential_ref.removeprefix(CARDDAV_SECRET_ENV_PREFIX))
if credential_ref:
raise AddressBookError(
"The CardDAV credential reference is not a server-owned credential; provide a replacement password or token"
)
if encrypted:
return decrypt_secret(encrypted)
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:
if value is None:
return None

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@@ -33,6 +34,7 @@ from govoplan_addresses.backend.db.models import (
)
from govoplan_addresses.backend.schemas import (
AddressBookCreateRequest,
AddressCardDavDiscoveryRequest,
AddressListCreateRequest,
AddressListEntryCreateRequest,
AddressCardDavSourceCreateRequest,
@@ -48,6 +50,7 @@ from govoplan_addresses.backend.schemas import (
ContactPostalAddressPayload,
)
from govoplan_addresses.backend.manifest import manifest
from govoplan_addresses.backend.router import _sync_source_response
from govoplan_addresses.backend.service import (
AddressBookError,
address_book_contact_counts,
@@ -61,6 +64,7 @@ from govoplan_addresses.backend.service import (
delete_address_list_entry,
delete_contact,
delete_sync_source,
discover_carddav_address_books,
export_address_book_vcard,
import_vcards,
list_address_list_entries,
@@ -81,6 +85,7 @@ from govoplan_addresses.backend.service import (
start_sync_attempt,
finish_sync_attempt,
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].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:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Writable Remote"))
self.session.commit()

View File

@@ -37,6 +37,22 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
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:
base_url = "https://dav.example.test/addressbooks/ada/"

View File

@@ -13,8 +13,11 @@
},
"./styles/addresses.css": "./src/styles/addresses.css"
},
"scripts": {
"test:ui-structure": "node scripts/test-selection-list-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^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(
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[]> {
return apiFetch<AddressCardDavDiscoveryResponse>(settings, "/api/v1/addresses/carddav/discover", {
method: "POST",
@@ -468,7 +468,6 @@ export function createCardDavSyncSource(
username?: string | null;
password?: string | null;
bearer_token?: string | null;
credential_ref?: string | null;
sync_direction: "read_only" | "import" | "export" | "two_way";
read_only?: boolean | 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 { 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 {
ApiError,
Button,
ConfirmDialog,
Dialog,
DisabledActionTooltip,
DismissibleAlert,
ExplorerTree,
fetchAuthGroups,
formatDateTime,
FormField,
LoadingFrame,
PasswordField,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
ToggleSwitch,
hasScope,
type ApiSettings,
type AuthInfo,
type AuthUpdate
type AuthUpdate,
type FormatDateTimeOptions
} from "@govoplan/core-webui";
import {
createAddressBook,
@@ -141,7 +147,6 @@ type CardDavFormState = {
username: string;
password: string;
bearer_token: string;
credential_ref: string;
sync_direction: "read_only" | "import" | "export" | "two_way";
};
@@ -198,7 +203,6 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = {
username: "",
password: "",
bearer_token: "",
credential_ref: "",
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;
}
function formatDateTime(value?: string | null): string {
if (!value) return "Never";
try {
return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
} catch {
return value;
}
}
const ADDRESS_DATE_TIME_OPTIONS: FormatDateTimeOptions = {
fallback: "Never",
year: undefined,
month: undefined,
day: undefined,
hour: undefined,
minute: undefined,
timeZoneName: undefined,
dateStyle: "short",
timeStyle: "short"
};
function planSummary(plan: AddressSyncPlan | null): string {
if (!plan) return "No preview loaded.";
@@ -604,19 +611,6 @@ function disabledReason(...conditions: Array<[boolean, string]>): string {
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) {
const [books, setBooks] = useState<AddressBook[]>([]);
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
@@ -1528,8 +1522,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
auth_type: cardDavForm.auth_type,
username: cardDavForm.username || null,
password: cardDavForm.password || null,
bearer_token: cardDavForm.bearer_token || null,
credential_ref: cardDavForm.credential_ref || null
bearer_token: cardDavForm.bearer_token || null
};
}
@@ -1570,7 +1563,6 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
username: cardDavForm.username || null,
password: cardDavForm.password || null,
bearer_token: cardDavForm.bearer_token || null,
credential_ref: cardDavForm.credential_ref || null,
sync_direction: cardDavForm.sync_direction,
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) => (
<p key={source.id}>
{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}` : ""}
</p>
))}
@@ -1777,27 +1769,27 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function renderSelectedBookActions() {
return (
<>
<ReasonedButton type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></ReasonedButton>
<ReasonedButton type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></ReasonedButton>
<ReasonedButton type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></ReasonedButton>
<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>
<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>
<ReasonedButton type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></ReasonedButton>
<ReasonedButton type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></ReasonedButton>
<ReasonedButton type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</ReasonedButton>
<ReasonedButton type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</ReasonedButton>
<Button type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></Button>
<Button type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></Button>
<Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
<Button type="button" title="Import vCard into selected address book" aria-label="Import vCard into selected address book" onClick={() => setImportOpen(true)} disabledReason={importBookReason}><Upload size={15} /></Button>
<Button type="button" title="Export selected address book as vCard" aria-label="Export selected address book as vCard" onClick={() => void exportSelectedBook()} disabledReason={exportBookReason}><Download size={15} /></Button>
<Button type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></Button>
<Button type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></Button>
<Button type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</Button>
<Button type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</Button>
{selectedList ?
selectedList.deleted_at ?
<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>
<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" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></Button>
<Button type="button" variant="danger" title="Delete address list" aria-label="Delete address list" disabledReason={deleteListReason(selectedList)} onClick={() => setConfirmState({ kind: "list", list: selectedList })}><Trash2 size={15} /></Button>
</> :
selectedBook?.deleted_at ?
<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>
<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" title="Edit address book" aria-label="Edit address book" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && openEditBookDialog(selectedBook)}><Edit3 size={15} /></Button>
<Button type="button" variant="danger" title="Delete address book" aria-label="Delete address book" disabledReason={selectedBook ? deleteBookReason(selectedBook) : "Select an address book before deleting."} onClick={() => selectedBook && setConfirmState({ kind: "book", book: selectedBook })}><Trash2 size={15} /></Button>
</>
}
</>
@@ -1807,10 +1799,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function renderContactRow(contact: Contact) {
const selected = selectedContactId === contact.id;
return (
<button
type="button"
<SelectionListItem
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}
onClick={() => setSelectedContactId(contact.id)}
onDragStart={(event) => handleContactDragStart(event, contact)}>
@@ -1821,7 +1813,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<span className="address-contact-row-meta">
{contact.deleted_at ? <StatusBadge status="archived" /> : contact.tags.slice(0, 2).map((tag) => <span key={tag} className="address-tag">{tag}</span>)}
</span>
</button>
</SelectionListItem>
);
}
@@ -1843,11 +1835,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div>
<div className="button-row compact-actions">
{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>
<ReasonedButton type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</ReasonedButton>
<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" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
</>
}
</div>
@@ -1864,13 +1856,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedContactListEntries.map((entry) => (
<div className="address-membership-row" key={entry.id}>
<p className="muted">{entryTargetLabel(entry)}</p>
<ReasonedButton
<Button
type="button"
variant="danger"
onClick={() => void removeAddressListEntry(entry)}
disabledReason={removeContactFromListReason()}>
<X size={15} /> Remove
</ReasonedButton>
</Button>
</div>
))}
</div>
@@ -1927,8 +1919,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
return (
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
{error && <div className="alert alert-error address-error">{error}</div>}
{notice && !error && <div className="alert alert-success address-error">{notice}</div>}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading address books..." className="address-workspace-frame">
<div className="address-book-workspace">
@@ -1987,9 +1979,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div>
<div className="button-row compact-actions">
{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>
</header>
<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?.deleted_at && <StatusBadge status="archived" />}
</div>
<div className="address-contact-list" role="list">
<div className="address-contact-list">
{visibleContacts.length === 0 ?
<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>
</section>
@@ -2019,8 +2013,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={
<>
<ReasonedButton type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
<ReasonedButton type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</ReasonedButton>
<Button type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</Button>
</>
}>
<form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
@@ -2055,8 +2049,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={
<>
<ReasonedButton type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
<ReasonedButton type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</ReasonedButton>
<Button type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</Button>
</>
}>
<form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
@@ -2082,8 +2076,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={
<>
<ReasonedButton type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
<ReasonedButton type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</ReasonedButton>
<Button type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</Button>
</>
}>
<form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
@@ -2099,7 +2093,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<section className="address-form-section">
<div className="address-form-section-heading">
<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 className="address-form-list">
{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>
<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 })} />
<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>
@@ -2116,7 +2110,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<section className="address-form-section">
<div className="address-form-section-heading">
<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 className="address-form-list">
{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>
<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 })} />
<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>
@@ -2133,7 +2127,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<section className="address-form-section">
<div className="address-form-section-heading">
<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 className="address-form-list">
{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.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 })} />
<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>
@@ -2162,7 +2156,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
closeDisabled={saving}
className="address-member-dialog"
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-contact-toolbar address-member-search">
<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}>
{targetOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
</select>
<ReasonedButton
<Button
type="button"
onClick={() => selectedList && void addContactToAddressList(selectedList, contact, selectedTarget)}
disabledReason={addContactToListReason(selectedList, contact, selectedTarget?.key ?? "")}>
<Plus size={15} /> Add
</ReasonedButton>
</Button>
</div>
);
})
@@ -2209,8 +2203,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={
<>
<ReasonedButton type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
<ReasonedButton type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</ReasonedButton>
<Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>
</>
}>
<form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
@@ -2236,9 +2230,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={
<>
<ReasonedButton type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
<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>
<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="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="button" onClick={() => void discoverCardDavSources()} disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV URL before discovery."])}><Search size={16} /> Discover</Button>
<Button type="submit" form="address-carddav-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV address-book URL before saving."])}><Save size={16} /> Connect</Button>
</>
}>
<form id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}>
@@ -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 }))} />
</FormField>
<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>
</div>
}
{cardDavForm.auth_type === "bearer" &&
<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 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 &&
<div className="address-sync-result-list">
<SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">
{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>
<small>{item.collection_url}</small>
</button>
</SelectionListItem>
))}
</div>
</SelectionList>
}
</form>
</Dialog>
@@ -2307,9 +2302,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={
<>
<ReasonedButton type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</ReasonedButton>
<ReasonedButton type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</ReasonedButton>
<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" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</Button>
<Button type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</Button>
<Button type="button" variant="primary" onClick={() => syncInspector && void runSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [!canWriteSync, "You need permission to run address sync."], [saving, savingReason])}>Run sync</Button>
</>
}>
{syncInspector &&
@@ -2324,22 +2319,22 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<StatusBadge status={syncInspector.source.status} />
{syncInspector.source.read_only && <StatusBadge status="read-only" />}
</div>
<p className="muted">Last attempt: {formatDateTime(syncInspector.source.last_attempted_at)} · Last success: {formatDateTime(syncInspector.source.last_success_at)}</p>
{syncInspector.source.last_error && <p className="alert alert-error">{syncInspector.source.last_error}</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 && <DismissibleAlert tone="danger" resetKey={syncInspector.source.last_error}>{syncInspector.source.last_error}</DismissibleAlert>}
<div className="button-row compact-actions">
<ReasonedButton
<Button
type="button"
onClick={() => void toggleSyncSourceEnabled(syncInspector.source)}
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
{syncInspector.source.enabled ? "Disable" : "Enable"}
</ReasonedButton>
<ReasonedButton
</Button>
<Button
type="button"
variant="danger"
onClick={() => setConfirmState({ kind: "sync-source", source: syncInspector.source })}
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
Disconnect
</ReasonedButton>
</Button>
</div>
</div>
@@ -2366,7 +2361,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{syncConflicts.map((conflict) => (
<div className="address-sync-record-row" key={conflict.id}>
<span><strong>{conflict.field_path}</strong><small>{conflict.resource_href || conflict.remote_uid || conflict.contact_id}</small></span>
<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>
@@ -2393,7 +2388,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<div className="address-sync-record-list">
{syncTombstones.slice(0, 20).map((tombstone) => (
<div className="address-sync-record-row" key={tombstone.id}>
<span><strong>{tombstone.remote_uid || tombstone.resource_href || tombstone.contact_id}</strong><small>{formatDateTime(tombstone.synced_at || tombstone.remote_deleted_at || tombstone.local_deleted_at)}</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>
@@ -2410,20 +2405,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
className="address-sync-dialog"
footerClassName="button-row compact-actions"
footer={conflictDialog && <>
<ReasonedButton type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</ReasonedButton>
<ReasonedButton
<Button type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</Button>
<Button
type="button"
onClick={() => void resolveConflictWith(conflictDialog, "ignored", "ignored")}
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
Ignore
</ReasonedButton>
<ReasonedButton
</Button>
<Button
type="button"
onClick={() => void resolveConflictWith(conflictDialog, "keep_local")}
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
Keep local
</ReasonedButton>
<ReasonedButton
</Button>
<Button
type="button"
onClick={() => void applyMergedConflict(conflictDialog)}
disabledReason={disabledReason(
@@ -2432,8 +2427,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[saving, savingReason]
)}>
Apply merged
</ReasonedButton>
<ReasonedButton
</Button>
<Button
type="button"
variant="primary"
onClick={() => void resolveConflictWith(conflictDialog, "use_remote")}
@@ -2443,7 +2438,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[saving, savingReason]
)}>
Use remote
</ReasonedButton>
</Button>
</>}>
{conflictDialog &&
<div className="address-conflict-review">
@@ -2457,7 +2452,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />}
</div>
{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 className="address-conflict-grid">
<div className="address-conflict-grid-header">Field</div>
@@ -2469,15 +2464,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<strong>{row.field}</strong>
<span>{row.local}</span>
<span>{row.remote}</span>
<span className="address-conflict-choice">
<div>
{canMergeConflict(conflictDialog) ?
<>
<button type="button" className={conflictMergeChoices[row.field] !== "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "local")}>Local</button>
<button type="button" className={conflictMergeChoices[row.field] === "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "remote")}>Remote</button>
</> :
<SegmentedControl<ConflictMergeChoice>
className="address-conflict-choice"
role="group"
size="equal"
ariaLabel={`Source for ${row.field}`}
options={[{ id: "local", label: "Local" }, { id: "remote", label: "Remote" }]}
value={conflictMergeChoices[row.field] ?? "local"}
onChange={(choice) => setConflictMergeChoice(row.field, choice)}
/> :
"—"
}
</span>
</div>
</div>
))}
</div>

View File

@@ -150,16 +150,19 @@
width: min(960px, calc(100vw - 36px));
}
.address-sync-record-list,
.address-sync-plan-grid {
display: grid;
}
.address-sync-result-list,
.address-sync-record-list,
.address-sync-plan-grid {
border: var(--border-line);
display: grid;
max-height: 260px;
overflow: auto;
}
.address-sync-result-row,
.address-sync-record-row,
.address-sync-plan-row {
align-items: center;
@@ -175,13 +178,11 @@
}
.address-sync-result-row {
cursor: pointer;
}
.address-sync-result-row:hover,
.address-sync-result-row:focus-visible {
background: var(--line);
outline: none;
align-items: center;
border-bottom: var(--border-line);
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
}
.address-sync-result-row strong,
@@ -255,37 +256,6 @@
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-detail-panel {
display: flex;
@@ -338,34 +308,15 @@
align-content: start;
}
.address-contact-selection-list {
gap: 2px;
}
.address-contact-row {
align-items: center;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--text);
cursor: pointer;
display: grid;
gap: 10px;
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 {
@@ -617,56 +568,10 @@
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 {
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 {
background: var(--panel-soft);
}
@@ -677,9 +582,6 @@
@media (max-width: 980px) {
.address-book-workspace,
.form-grid.two,
.form-grid.three,
.form-grid.four,
.address-form-row-email,
.address-form-row-phone,
.address-form-row-postal {