17 Commits

Author SHA1 Message Date
bf1d7c9678 Paginate address book contacts 2026-07-30 05:22:09 +02:00
1545ea711e chore(webui): require patched React Router 2026-07-29 14:32:54 +02:00
eab24750f9 Integrate address sources with credential envelopes 2026-07-28 19:33:08 +02:00
93dddbb8c5 chore(addresses): bump version to 0.1.9 2026-07-22 03:44:32 +02:00
04accaa206 feat(addresses): expose visible contact people search 2026-07-22 03:02:14 +02:00
75c9ece709 docs: define CardDAV credential deletion 2026-07-21 15:54:47 +02:00
d005040a8e fix(installer): audit credential retirement 2026-07-21 15:46:20 +02:00
613fb15a80 fix(secrets): audit connector credential deletion 2026-07-21 15:45:39 +02:00
5dc9392290 refactor(webui): use Core date-time formatting 2026-07-21 13:52:59 +02:00
7237679a85 refactor(webui): centralize address selections 2026-07-21 13:36:04 +02:00
5ff154bc64 Remove unused address link styling 2026-07-21 13:19:20 +02:00
3ec4b3c4ad Refactor CardDAV report planning branches 2026-07-21 12:53:51 +02:00
70ee3c0148 refactor(webui): use central reasoned buttons 2026-07-21 12:38:45 +02:00
5d560d4c58 Remove caller-managed CardDAV secret references 2026-07-21 12:12:49 +02:00
8b4cf362ca Harden CardDAV credential and network boundaries 2026-07-21 12:12:18 +02:00
b13e5760c8 Clean Addresses audit and test resources 2026-07-21 03:16:38 +02:00
22d12d674b Confine CardDAV requests to configured origins 2026-07-21 03:16:38 +02:00
18 changed files with 1898 additions and 425 deletions

View File

@@ -43,6 +43,13 @@ inspection UI are implemented. The conflict review UI compares stored local and
remote field payloads, can apply a stored remote vCard payload, and supports remote field payloads, can apply a stored remote vCard payload, and supports
manual per-field local/remote merge choices. manual per-field local/remote merge choices.
API-managed CardDAV credentials are encrypted inside the source record. Source
deletion physically removes that credential material and records a non-secret
audit event in the same database transaction; destructive module retirement
audits every remaining credential before the owning tables are dropped. Legacy
external references are detached but are never sent to a secret provider for
deletion because Addresses cannot prove that it owns them.
## Boundary ## Boundary
`govoplan-addresses` owns: `govoplan-addresses` owns:

View File

@@ -148,7 +148,12 @@ a conflict instead of silently overwriting remote data. The first conflict
review UI compares stored local and remote field payloads and can apply a review UI compares stored local and remote field payloads and can apply a
stored remote vCard payload or a manual per-field local/remote merge payload. stored remote vCard payload or a manual per-field local/remote merge payload.
Source disconnect/delete removes the source binding and related sync records Source disconnect/delete removes the source binding and related sync records
while keeping local contacts. while keeping local contacts. Because API-managed CardDAV credentials are
encrypted in the source row, the same transaction physically removes their
ciphertext and emits non-secret credential-deletion audit evidence. Destructive
module retirement audits all remaining owned credential material before table
removal. An unowned legacy reference is detached rather than passed to an
external secret provider.
## Connector Direction ## Connector Direction

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/addresses-webui", "name": "@govoplan/addresses-webui",
"version": "0.1.8", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"README.md" "README.md"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.11",
"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

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-addresses" name = "govoplan-addresses"
version = "0.1.8" version = "0.1.9"
description = "GovOPlaN reusable address and recipient-source module." description = "GovOPlaN reusable address and recipient-source module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" 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.11",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@@ -8,6 +8,11 @@ from sqlalchemy import func
from govoplan_core.auth import ApiPrincipal from govoplan_core.auth import ApiPrincipal
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
from govoplan_core.core.people import (
PeopleSearchGroup,
PersonSearchCandidate,
person_selection_key,
)
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone
from govoplan_addresses.backend.schemas import ContactCreateRequest from govoplan_addresses.backend.schemas import ContactCreateRequest
from govoplan_addresses.backend.service import ( from govoplan_addresses.backend.service import (
@@ -137,6 +142,60 @@ class AddressesLookupCapability:
return tuple(candidates) return tuple(candidates)
class AddressesPeopleSearchProvider:
"""Adapt visible address-book contacts to the shared people-search contract."""
def __init__(self, lookup: AddressesLookupCapability | None = None) -> None:
self._lookup = lookup or AddressesLookupCapability()
def search_people(
self,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
) -> tuple[PeopleSearchGroup, ...]:
if not hasattr(principal, "account_id") or not hasattr(principal, "tenant_id") or not hasattr(principal, "group_ids"):
raise AddressBookError("Address contact search requires an authenticated tenant principal.")
candidates = self._lookup.lookup(
session,
principal, # type: ignore[arg-type] - validated principal contract
query=query,
limit=limit,
)
return (
PeopleSearchGroup(
key="contacts",
label="Contacts",
candidates=tuple(
PersonSearchCandidate(
selection_key=person_selection_key("contact", item.contact_id, email=item.email),
kind="contact",
reference_id=item.contact_id,
display_name=item.display_name,
email=item.email,
source_module="addresses",
source_label="Contacts",
source_ref=item.source_ref or f"addresses:contact:{item.contact_id}",
source_revision=item.source_revision,
description=" · ".join(part for part in (item.organization, item.role_title) if part) or None,
provenance=dict(item.provenance),
metadata={
"address_book_id": item.address_book_id,
"email_label": item.email_label,
"organization": item.organization,
"role_title": item.role_title,
"tags": tuple(item.tags),
"source_kind": item.source_kind,
},
)
for item in candidates
),
),
)
class AddressesContactWriterCapability: class AddressesContactWriterCapability:
def list_write_targets( def list_write_targets(
self, self,
@@ -301,6 +360,10 @@ def lookup_capability(_context: Any) -> AddressesLookupCapability:
return AddressesLookupCapability() return AddressesLookupCapability()
def people_search_capability(_context: Any) -> AddressesPeopleSearchProvider:
return AddressesPeopleSearchProvider()
def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapability: def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapability:
return AddressesRecipientSourceCapability() return AddressesRecipientSourceCapability()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import posixpath
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@@ -8,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):
@@ -269,7 +276,19 @@ class AddressCardDAVClient:
return AddressCardDAVWriteResult(href=href, etag=response_etag(response_headers), status=status) return AddressCardDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
def object_url(self, href: str) -> str: def object_url(self, href: str) -> str:
return urllib.parse.urljoin(self.collection_url, href) candidate = same_origin_dav_url(
self.collection_url,
href,
label="CardDAV object href",
)
collection_parts = urllib.parse.urlparse(self.collection_url)
candidate_parts = urllib.parse.urlparse(candidate)
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
collection_prefix = collection_path.rstrip("/") + "/"
if not candidate_path.startswith(collection_prefix) or candidate_path == collection_path:
raise AddressCardDAVError("CardDAV object href must remain inside the configured collection path")
return candidate
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes: def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes:
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected) _status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
@@ -313,14 +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:
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method) url = validate_outbound_http_url(url, label="CardDAV URL")
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated CardDAV HTTP(S) URL. # nosec B310 request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
return response.status, dict(response.headers.items()), response.read() url,
data=body,
headers=dict(headers),
method=method,
)
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
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
@@ -431,16 +467,73 @@ def ensure_collection_url(value: str) -> str:
def validate_http_url(value: str) -> str: def validate_http_url(value: str) -> str:
parsed = urllib.parse.urlparse(value) parsed = urllib.parse.urlparse(value.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc: if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
raise AddressCardDAVError("CardDAV URL must be an absolute HTTP(S) URL") raise AddressCardDAVError("CardDAV URL must be an absolute HTTP(S) URL")
if parsed.username or parsed.password: if parsed.username or parsed.password:
raise AddressCardDAVError("CardDAV URL must not include embedded credentials") raise AddressCardDAVError("CardDAV URL must not include embedded credentials")
if parsed.query or parsed.fragment:
raise AddressCardDAVError("CardDAV URL must not include a query or fragment")
_url_origin(parsed)
return urllib.parse.urlunparse(parsed) return urllib.parse.urlunparse(parsed)
def absolute_dav_url(base_url: str, href: str) -> str: def absolute_dav_url(base_url: str, href: str) -> str:
return urllib.parse.urljoin(ensure_collection_url(base_url), href) return same_origin_dav_url(base_url, href, label="CardDAV discovery href")
def same_origin_dav_url(base_url: str, href: str, *, label: str) -> str:
base = ensure_collection_url(base_url)
candidate = validate_http_url(urllib.parse.urljoin(base, href))
if _url_origin(urllib.parse.urlparse(candidate)) != _url_origin(urllib.parse.urlparse(base)):
raise AddressCardDAVError(f"{label} must use the configured collection origin")
return candidate
def _url_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
try:
port = parsed.port
except ValueError as exc:
raise AddressCardDAVError("CardDAV URL has an invalid port") from exc
scheme = parsed.scheme.lower()
if port is None:
port = 443 if scheme == "https" else 80
return scheme, (parsed.hostname or "").lower(), port
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, source_url: str) -> None:
super().__init__()
self._source_origin = _url_origin(urllib.parse.urlparse(validate_http_url(source_url)))
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
del fp, msg, headers
try:
candidate = validate_http_url(newurl)
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
method = req.get_method()
data = req.data
if code == 303 and method != "HEAD":
method, data = "GET", None
elif code in {301, 302} and method == "POST":
method, data = "GET", None
forwarded_headers = {
key: value
for key, value in req.header_items()
if key.casefold() not in {"host", "content-length"}
}
return urllib.request.Request( # noqa: S310 - candidate is validated and same-origin.
candidate,
data=data,
headers=forwarded_headers,
origin_req_host=req.origin_req_host,
unverifiable=True,
method=method,
)
def strip_weak_etag(value: str | None) -> str | None: def strip_weak_etag(value: str | None) -> str | None:

View File

@@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
from pathlib import Path from pathlib import Path
from sqlalchemy import inspect
from govoplan_addresses.backend.capabilities import ( from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_CONTACT_WRITER, CAPABILITY_ADDRESSES_CONTACT_WRITER,
CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_LOOKUP,
@@ -10,6 +13,7 @@ from govoplan_addresses.backend.capabilities import (
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -25,6 +29,47 @@ from govoplan_core.core.modules import (
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
_addresses_table_retirement_provider = drop_table_retirement_provider(
addresses_models.AddressSyncDiagnostic,
addresses_models.AddressSyncConflict,
addresses_models.AddressSyncTombstone,
addresses_models.AddressSyncSource,
addresses_models.AddressListEntry,
addresses_models.AddressList,
addresses_models.ContactPostalAddress,
addresses_models.ContactPhone,
addresses_models.ContactEmail,
addresses_models.Contact,
addresses_models.AddressBook,
label="Addresses",
)
def _addresses_retirement_provider(session: object | None, module_id: str):
plan = _addresses_table_retirement_provider(session, module_id)
base_executor = plan.destroy_data_executor
if base_executor is None:
return plan
def executor(execute_session: object, execute_module_id: str) -> None:
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
raise RuntimeError("No database session is available for Addresses credential retirement.")
if inspect(execute_session.get_bind()).has_table(addresses_models.AddressSyncSource.__tablename__):
from govoplan_addresses.backend.service import audit_address_credentials_for_retirement
audit_address_credentials_for_retirement(execute_session)
base_executor(execute_session, execute_module_id)
return replace(
plan,
destroy_data_warnings=(
*plan.destroy_data_warnings,
"Addresses-owned encrypted connector credentials are audited and deleted with the sync-source table.",
),
destroy_data_executor=executor,
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2) module_id, resource, action = scope.split(":", 2)
return PermissionDefinition( return PermissionDefinition(
@@ -104,11 +149,12 @@ def _addresses_router(_context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="addresses", id="addresses",
name="Addresses", name="Addresses",
version="0.1.8", version="0.1.9",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox"), optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
), ),
@@ -128,24 +174,12 @@ manifest = ModuleManifest(
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True, retirement_supported=True,
retirement_provider=drop_table_retirement_provider( retirement_provider=_addresses_retirement_provider,
addresses_models.AddressSyncDiagnostic,
addresses_models.AddressSyncConflict,
addresses_models.AddressSyncTombstone,
addresses_models.AddressSyncSource,
addresses_models.AddressListEntry,
addresses_models.AddressList,
addresses_models.ContactPostalAddress,
addresses_models.ContactPhone,
addresses_models.ContactEmail,
addresses_models.Contact,
addresses_models.AddressBook,
label="Addresses",
),
retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.", retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.",
), ),
capability_factories={ capability_factories={
CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["lookup_capability"]).lookup_capability(context), CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["lookup_capability"]).lookup_capability(context),
CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["people_search_capability"]).people_search_capability(context),
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE: lambda context: __import__( CAPABILITY_ADDRESSES_RECIPIENT_SOURCE: lambda context: __import__(
"govoplan_addresses.backend.capabilities", "govoplan_addresses.backend.capabilities",
fromlist=["recipient_source_capability"], fromlist=["recipient_source_capability"],

View File

@@ -41,6 +41,8 @@ from govoplan_addresses.backend.schemas import (
AddressCardDavDiscoveryRequest, AddressCardDavDiscoveryRequest,
AddressCardDavDiscoveryResponse, AddressCardDavDiscoveryResponse,
AddressCardDavSourceCreateRequest, AddressCardDavSourceCreateRequest,
AddressCredentialEnvelopeListResponse,
AddressCredentialEnvelopeResponse,
AddressSyncAttemptFinishRequest, AddressSyncAttemptFinishRequest,
AddressSyncConflictCreateRequest, AddressSyncConflictCreateRequest,
AddressSyncConflictListResponse, AddressSyncConflictListResponse,
@@ -69,6 +71,7 @@ from govoplan_addresses.backend.schemas import (
) )
from govoplan_addresses.backend.service import ( from govoplan_addresses.backend.service import (
AddressBookError, AddressBookError,
available_address_credentials,
address_book_contact_counts, address_book_contact_counts,
address_list_entry_counts, address_list_entry_counts,
create_address_book, create_address_book,
@@ -77,6 +80,7 @@ from govoplan_addresses.backend.service import (
create_carddav_sync_source, create_carddav_sync_source,
create_contact, create_contact,
create_sync_source, create_sync_source,
count_contacts,
delete_address_book, delete_address_book,
delete_address_list, delete_address_list,
delete_address_list_entry, delete_address_list_entry,
@@ -103,6 +107,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 +222,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,
} }
@@ -426,16 +431,41 @@ def api_restore_address_book(
@router.get("/contacts", response_model=ContactListResponse) @router.get("/contacts", response_model=ContactListResponse)
def api_list_contacts( def api_list_contacts(
address_book_id: str | None = Query(default=None), address_book_id: str | None = Query(default=None),
address_list_id: str | None = Query(default=None),
query: str | None = Query(default=None), query: str | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500), limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
include_deleted: bool = Query(default=False), include_deleted: bool = Query(default=False),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "addresses:contact:read") _require_scope(principal, "addresses:contact:read")
try: try:
contacts = list_contacts(session, principal, address_book_id=address_book_id, query=query, limit=limit, include_deleted=include_deleted) contacts = list_contacts(
return ContactListResponse(contacts=[_contact_response(contact) for contact in contacts]) session,
principal,
address_book_id=address_book_id,
address_list_id=address_list_id,
query=query,
limit=limit,
offset=offset,
include_deleted=include_deleted,
)
total = count_contacts(
session,
principal,
address_book_id=address_book_id,
address_list_id=address_list_id,
query=query,
include_deleted=include_deleted,
)
return ContactListResponse(
contacts=[_contact_response(contact) for contact in contacts],
total=total,
offset=offset,
limit=limit,
has_more=offset + len(contacts) < total,
)
except AddressBookError as exc: except AddressBookError as exc:
raise _error(exc) from exc raise _error(exc) from exc
@@ -618,7 +648,7 @@ def api_discover_carddav_address_books(
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "addresses:sync:read") _require_scope(principal, "addresses:sync:write")
try: try:
address_books = discover_carddav_address_books(session, principal, payload) address_books = discover_carddav_address_books(session, principal, payload)
return AddressCardDavDiscoveryResponse( return AddressCardDavDiscoveryResponse(
@@ -638,6 +668,25 @@ def api_discover_carddav_address_books(
raise _error(AddressBookError(str(exc))) from exc raise _error(AddressBookError(str(exc))) from exc
@router.get("/credentials", response_model=AddressCredentialEnvelopeListResponse)
def api_list_address_credentials(
source_id: str | None = Query(default=None),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:sync:write")
return AddressCredentialEnvelopeListResponse(
credentials=[
AddressCredentialEnvelopeResponse.model_validate(item)
for item in available_address_credentials(
session,
principal,
source_id=source_id,
)
]
)
@router.post("/address-books/{book_id}/carddav/sources", response_model=AddressSyncSourceResponse, status_code=status.HTTP_201_CREATED) @router.post("/address-books/{book_id}/carddav/sources", response_model=AddressSyncSourceResponse, status_code=status.HTTP_201_CREATED)
def api_create_carddav_sync_source( def api_create_carddav_sync_source(
book_id: str, book_id: str,

View File

@@ -221,6 +221,10 @@ class ContactResponse(BaseModel):
class ContactListResponse(BaseModel): class ContactListResponse(BaseModel):
contacts: list[ContactResponse] contacts: list[ContactResponse]
total: int
offset: int
limit: int
has_more: bool
class AddressLookupResponse(BaseModel): class AddressLookupResponse(BaseModel):
@@ -439,6 +443,26 @@ class AddressCardDavDiscoveryResponse(BaseModel):
address_books: list[AddressCardDavAddressBookResponse] address_books: list[AddressCardDavAddressBookResponse]
class AddressCredentialEnvelopeResponse(BaseModel):
id: str
scope_type: str
scope_id: str | None = None
name: str
description: str | None = None
credential_kind: str
public_data: dict[str, Any] = Field(default_factory=dict)
secret_keys: list[str] = Field(default_factory=list)
secret_configured: bool = False
allowed_modules: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_active: bool = True
revision: str
class AddressCredentialEnvelopeListResponse(BaseModel):
credentials: list[AddressCredentialEnvelopeResponse] = Field(default_factory=list)
class AddressCardDavSourceCreateRequest(BaseModel): class AddressCardDavSourceCreateRequest(BaseModel):
collection_url: str = Field(min_length=1, max_length=2000) collection_url: str = Field(min_length=1, max_length=2000)
display_name: str | None = Field(default=None, max_length=255) display_name: str | None = Field(default=None, max_length=255)

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,19 @@
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, inspect
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
from govoplan_core.security.credential_envelopes import (
CredentialEnvelope,
create_credential_envelope,
)
from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult
from govoplan_addresses.backend.capabilities import ( from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_CONTACT_WRITER, CAPABILITY_ADDRESSES_CONTACT_WRITER,
@@ -15,6 +21,7 @@ from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
AddressesContactWriterCapability, AddressesContactWriterCapability,
AddressesLookupCapability, AddressesLookupCapability,
AddressesPeopleSearchProvider,
AddressesRecipientSourceCapability, AddressesRecipientSourceCapability,
AddressWriterError, AddressWriterError,
) )
@@ -33,6 +40,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 +56,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,
@@ -58,9 +67,11 @@ from govoplan_addresses.backend.service import (
create_carddav_sync_source, create_carddav_sync_source,
create_contact, create_contact,
create_sync_source, create_sync_source,
count_contacts,
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 +92,8 @@ 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,
_carddav_client_for_source,
) )
@@ -158,9 +171,9 @@ class AddressBookAdminPrincipal(NoGroupPrincipal):
class AddressServiceTest(unittest.TestCase): class AddressServiceTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
engine = create_engine("sqlite:///:memory:") self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all( Base.metadata.create_all(
engine, self.engine,
tables=[ tables=[
AddressBook.__table__, AddressBook.__table__,
AddressList.__table__, AddressList.__table__,
@@ -174,11 +187,17 @@ class AddressServiceTest(unittest.TestCase):
AddressSyncConflict.__table__, AddressSyncConflict.__table__,
AddressSyncDiagnostic.__table__, AddressSyncDiagnostic.__table__,
ChangeSequenceEntry.__table__, ChangeSequenceEntry.__table__,
CredentialEnvelope.__table__,
], ],
) )
self.session = sessionmaker(bind=engine)() self.session = sessionmaker(bind=self.engine)()
self.principal = Principal() self.principal = Principal()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def test_group_book_creation_requires_membership_or_admin_scope(self) -> None: def test_group_book_creation_requires_membership_or_admin_scope(self) -> None:
with self.assertRaisesRegex(AddressBookError, "selected group is not visible"): with self.assertRaisesRegex(AddressBookError, "selected group is not visible"):
create_address_book( create_address_book(
@@ -234,6 +253,50 @@ class AddressServiceTest(unittest.TestCase):
self.session.commit() self.session.commit()
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
def test_contact_windows_report_exact_totals(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Paged"),
)
self.session.flush()
contacts = [
create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name=name,
emails=[ContactEmailPayload(email=f"{name.lower()}@example.local")],
),
)
for name in ("Ada", "Barbara", "Claude", "Dorothy", "Edsger")
]
self.session.commit()
page = list_contacts(
self.session,
self.principal,
address_book_id=book.id,
limit=2,
offset=2,
)
self.assertEqual([contact.id for contact in page], [contacts[2].id, contacts[3].id])
self.assertEqual(
count_contacts(self.session, self.principal, address_book_id=book.id),
5,
)
self.assertEqual(
count_contacts(
self.session,
self.principal,
address_book_id=book.id,
query="example.local",
),
5,
)
def test_vcard_import_and_export_preserves_common_fields(self) -> None: def test_vcard_import_and_export_preserves_common_fields(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported")) book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported"))
self.session.commit() self.session.commit()
@@ -335,8 +398,17 @@ END:VCARD
self.assertEqual(len(lookup), 1) self.assertEqual(len(lookup), 1)
self.assertEqual(lookup[0].email, "ada@example.local") self.assertEqual(lookup[0].email, "ada@example.local")
self.assertEqual(lookup[0].contact_id, contact.id) self.assertEqual(lookup[0].contact_id, contact.id)
people_provider = AddressesPeopleSearchProvider()
self.assertIsInstance(people_provider, PeopleSearchProvider)
people = people_provider.search_people(self.session, self.principal, query="ada")
self.assertEqual(people[0].key, "contacts")
self.assertEqual(people[0].candidates[0].reference_id, contact.id)
self.assertEqual(people[0].candidates[0].source_ref, f"addresses:contact:{contact.id}")
self.assertEqual(people[0].candidates[0].metadata["address_book_id"], book.id)
warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10) warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10)
self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"]) self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"])
self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, manifest.capability_factories)
self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal) recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal)
self.assertEqual(len(recipient_sources), 1) self.assertEqual(len(recipient_sources), 1)
@@ -443,6 +515,27 @@ END:VCARD
self.assertEqual(entries[0].contact_email.email, "ada.private@example.local") self.assertEqual(entries[0].contact_email.email, "ada.private@example.local")
self.assertEqual(entries[1].target_kind, "postal_address") self.assertEqual(entries[1].target_kind, "postal_address")
self.assertEqual(entries[1].contact_postal_address.locality, "Berlin") self.assertEqual(entries[1].contact_postal_address.locality, "Berlin")
self.assertEqual(
[
item.id
for item in list_contacts(
self.session,
self.principal,
address_book_id=book.id,
address_list_id=address_list.id,
)
],
[contact.id],
)
self.assertEqual(
count_contacts(
self.session,
self.principal,
address_book_id=book.id,
address_list_id=address_list.id,
),
1,
)
with self.assertRaisesRegex(ValueError, "same address book"): with self.assertRaisesRegex(ValueError, "same address book"):
create_address_list_entry( create_address_list_entry(
@@ -782,6 +875,156 @@ 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"}},
),
)
def test_carddav_source_can_use_reusable_core_credential(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Shared credential"),
)
credential = create_credential_envelope(
self.session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Shared DAV login",
credential_kind="username_password",
public_data={"username": "ada"},
secret_data={"password": "secret"},
allowed_modules=["addresses"],
inherit_to_lower_scopes=True,
)
source = create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://dav.example.test/addressbooks/personal/",
auth_type="basic",
credential_ref=f"credential-envelope:{credential.id}",
),
)
self.session.commit()
client = _carddav_client_for_source(self.session, source)
response_auth = _sync_source_response(source).metadata["carddav"]
self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret")
self.assertEqual(response_auth["credential_envelope_id"], credential.id)
self.assertTrue(response_auth["has_credential"])
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()
@@ -929,6 +1172,107 @@ END:VCARD
self.assertFalse(book.read_only) self.assertFalse(book.read_only)
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
def test_delete_sync_source_deletes_and_audits_encrypted_credential_atomically(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Audited remote"),
)
self.session.commit()
self.session.refresh(book)
source = create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://carddav.example.local/addressbooks/audited/",
auth_type="basic",
username="ada",
password="do-not-audit-this",
),
)
self.session.commit()
source_id = source.id
with patch("govoplan_addresses.backend.service.audit_event") as audit:
delete_sync_source(self.session, self.principal, source_id)
self.session.commit()
self.assertIsNone(self.session.get(AddressSyncSource, source_id))
audit.assert_called_once()
audit_call = audit.call_args.kwargs
self.assertEqual(audit_call["action"], "addresses.sync_credential_deleted")
self.assertEqual(audit_call["object_id"], source_id)
self.assertEqual(audit_call["details"]["storage_backend"], "encrypted_database")
self.assertNotIn("credential_ref", audit_call["details"])
self.assertNotIn("secret_encrypted", audit_call["details"])
self.assertNotIn("do-not-audit-this", repr(audit_call))
def test_delete_sync_source_is_not_staged_when_credential_audit_fails(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Audit failure"),
)
self.session.commit()
self.session.refresh(book)
source = create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://carddav.example.local/addressbooks/audit-failure/",
auth_type="bearer",
bearer_token="do-not-audit-this",
),
)
self.session.commit()
with patch(
"govoplan_addresses.backend.service.audit_event",
side_effect=RuntimeError("audit unavailable"),
), self.assertRaisesRegex(RuntimeError, "audit unavailable"):
delete_sync_source(self.session, self.principal, source.id)
self.session.rollback()
persisted = self.session.get(AddressSyncSource, source.id)
self.assertIsNotNone(persisted)
self.assertIn("secret_encrypted", (persisted.metadata_ or {})["carddav"])
def test_destructive_module_retirement_audits_credentials_before_tables_are_dropped(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Retired module"),
)
self.session.commit()
self.session.refresh(book)
source = create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://carddav.example.local/addressbooks/module-retirement/",
auth_type="bearer",
bearer_token="do-not-audit-this",
),
)
self.session.commit()
source_id = source.id
retirement_provider = manifest.migration_spec.retirement_provider
assert retirement_provider is not None
plan = retirement_provider(self.session, "addresses")
assert plan.destroy_data_executor is not None
with patch("govoplan_addresses.backend.service.audit_event") as audit:
plan.destroy_data_executor(self.session, "addresses")
audit.assert_called_once()
audit_call = audit.call_args.kwargs
self.assertEqual(audit_call["object_id"], source_id)
self.assertEqual(audit_call["details"]["deletion_reason"], "module_data_retired")
self.assertNotIn("do-not-audit-this", repr(audit_call))
self.assertFalse(inspect(self.engine).has_table("addresses_sync_sources"))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,182 @@
from __future__ import annotations
import contextlib
import threading
import unittest
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import patch
from fastapi import HTTPException
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_addresses.backend.carddav import (
AddressCardDAVClient,
AddressCardDAVError,
absolute_dav_url,
urllib_transport,
)
from govoplan_addresses.backend.router import api_discover_carddav_address_books
from govoplan_addresses.backend.schemas import AddressCardDavDiscoveryRequest
@contextlib.contextmanager
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
host, port = server.server_address
yield f"http://{host}:{port}"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
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/"
self.assertEqual(
absolute_dav_url(base_url, "/principals/users/ada/"),
"https://dav.example.test/principals/users/ada/",
)
with self.assertRaisesRegex(AddressCardDAVError, "configured collection origin"):
absolute_dav_url(base_url, "https://evil.example.test/steal/")
with self.assertRaisesRegex(AddressCardDAVError, "query or fragment"):
absolute_dav_url(base_url, "/principals/users/ada/?token=secret")
def test_object_href_must_remain_inside_configured_collection(self) -> None:
client = AddressCardDAVClient(collection_url="https://dav.example.test/addressbooks/ada")
with self.assertRaisesRegex(AddressCardDAVError, "collection origin"):
client.object_url("https://evil.example.test/steal.vcf")
with self.assertRaisesRegex(AddressCardDAVError, "collection path"):
client.object_url("https://dav.example.test/addressbooks/other/steal.vcf")
with self.assertRaisesRegex(AddressCardDAVError, "collection path"):
client.object_url("/addressbooks/ada/%2e%2e/other/steal.vcf")
with self.assertRaisesRegex(AddressCardDAVError, "query or fragment"):
client.object_url("/addressbooks/ada/contact.vcf?download=1")
self.assertEqual(
client.object_url("/addressbooks/ada/contact.vcf"),
"https://dav.example.test/addressbooks/ada/contact.vcf",
)
def test_transport_refuses_redirect_before_forwarding_authorization(self) -> None:
forwarded_authorization: list[str | None] = []
class TargetHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(TargetHandler) as target_url:
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
self.send_response(302)
self.send_header("Location", f"{target_url}/stolen.vcf")
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as redirect_url:
status, _headers, _body = urllib_transport(
"GET",
f"{redirect_url}/contact.vcf",
{"Authorization": "Bearer top-secret"},
None,
2,
)
self.assertEqual(status, 302)
self.assertEqual(forwarded_authorization, [])
def test_transport_preserves_same_origin_redirects(self) -> None:
forwarded_authorization: list[str | None] = []
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path == "/contact.vcf":
self.send_response(302)
self.send_header("Location", "/redirected.vcf")
self.end_headers()
return
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
self.wfile.write(b"contact")
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as source_url:
status, _headers, body = urllib_transport(
"GET",
f"{source_url}/contact.vcf",
{"Authorization": "Bearer expected"},
None,
2,
)
self.assertEqual(status, 200)
self.assertEqual(body, b"contact")
self.assertEqual(forwarded_authorization, ["Bearer expected"])
class CardDAVDiscoveryAuthorizationTests(unittest.TestCase):
def test_sync_read_alone_cannot_start_authenticated_discovery(self) -> None:
principal = ApiPrincipal(
principal=PrincipalRef(
account_id="account-read-only",
membership_id="membership-read-only",
tenant_id="tenant-1",
scopes=frozenset({"addresses:sync:read"}),
),
account=object(),
user=object(),
)
with patch("govoplan_addresses.backend.router.discover_carddav_address_books") as discover:
with self.assertRaises(HTTPException) as raised:
api_discover_carddav_address_books(
AddressCardDavDiscoveryRequest(
url="https://dav.example.test/",
auth_type="basic",
username="reader",
password="secret",
),
principal,
object(), # type: ignore[arg-type] - scope rejection precedes session use
)
self.assertEqual(raised.exception.status_code, 403)
self.assertEqual(raised.exception.detail, "Missing scope: addresses:sync:write")
discover.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/addresses-webui", "name": "@govoplan/addresses-webui",
"version": "0.1.8", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,12 +13,15 @@
}, },
"./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.11",
"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",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {

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

@@ -168,8 +168,12 @@ type AddressListEntryListResponse = {
entries: AddressListEntry[]; entries: AddressListEntry[];
}; };
type ContactListResponse = { export type ContactListResponse = {
contacts: Contact[]; contacts: Contact[];
total: number;
offset: number;
limit: number;
has_more: boolean;
}; };
type AddressBookWriteTargetsResponse = { type AddressBookWriteTargetsResponse = {
@@ -216,6 +220,22 @@ export type AddressCardDavAddressBook = {
sync_token?: string | null; sync_token?: string | null;
}; };
export type AddressCredentialEnvelope = {
id: string;
scope_type: string;
scope_id?: string | null;
name: string;
description?: string | null;
credential_kind: string;
public_data: Record<string, unknown>;
secret_keys: string[];
secret_configured: boolean;
allowed_modules: string[];
inherit_to_lower_scopes: boolean;
is_active: boolean;
revision: string;
};
export type AddressSyncPlanStats = { export type AddressSyncPlanStats = {
created: number; created: number;
updated: number; updated: number;
@@ -305,6 +325,10 @@ type AddressCardDavDiscoveryResponse = {
address_books: AddressCardDavAddressBook[]; address_books: AddressCardDavAddressBook[];
}; };
type AddressCredentialEnvelopeListResponse = {
credentials: AddressCredentialEnvelope[];
};
type AddressSyncDiagnosticListResponse = { type AddressSyncDiagnosticListResponse = {
diagnostics: AddressSyncDiagnostic[]; diagnostics: AddressSyncDiagnostic[];
}; };
@@ -450,7 +474,14 @@ 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;
credential_ref?: 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",
@@ -482,6 +513,17 @@ export function createCardDavSyncSource(
}); });
} }
export async function listAddressCredentials(
settings: ApiSettings,
sourceId?: string | null
): Promise<AddressCredentialEnvelope[]> {
const response = await apiFetch<AddressCredentialEnvelopeListResponse>(
settings,
`/api/v1/addresses/credentials${queryString({ source_id: sourceId })}`
);
return response.credentials;
}
export function updateAddressSyncSource( export function updateAddressSyncSource(
settings: ApiSettings, settings: ApiSettings,
syncSourceId: string, syncSourceId: string,
@@ -550,11 +592,15 @@ export function resolveAddressSyncConflict(
}); });
} }
export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;query?: string | null;limit?: number;includeDeleted?: boolean;} = {}): Promise<Contact[]> { export function listContactsPage(settings: ApiSettings, options: {addressBookId?: string | null;addressListId?: string | null;query?: string | null;limit?: number;offset?: number;includeDeleted?: boolean;} = {}): Promise<ContactListResponse> {
const response = await apiFetch<ContactListResponse>( return apiFetch<ContactListResponse>(
settings, settings,
`/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, query: options.query, limit: options.limit, include_deleted: options.includeDeleted ? "true" : null })}` `/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, address_list_id: options.addressListId, query: options.query, limit: options.limit, offset: options.offset, include_deleted: options.includeDeleted ? "true" : null })}`
); );
}
export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;addressListId?: string | null;query?: string | null;limit?: number;offset?: number;includeDeleted?: boolean;} = {}): Promise<Contact[]> {
const response = await listContactsPage(settings, options);
return response.contacts; return response.contacts;
} }

View File

@@ -1,21 +1,28 @@
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,
DataGridPaginationBar,
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,
@@ -33,6 +40,7 @@ import {
exportContactVcard, exportContactVcard,
importAddressBookVcards, importAddressBookVcards,
listAddressBooks, listAddressBooks,
listAddressCredentials,
listAddressListEntries, listAddressListEntries,
listAddressLists, listAddressLists,
listAddressSyncConflicts, listAddressSyncConflicts,
@@ -40,6 +48,7 @@ import {
listAddressSyncSources, listAddressSyncSources,
listAddressSyncTombstones, listAddressSyncTombstones,
listContacts, listContacts,
listContactsPage,
previewAddressSyncSource, previewAddressSyncSource,
restoreAddressBook, restoreAddressBook,
restoreAddressList, restoreAddressList,
@@ -53,6 +62,7 @@ import {
type AddressCardDavAddressBook, type AddressCardDavAddressBook,
type AddressBook, type AddressBook,
type AddressBookScope, type AddressBookScope,
type AddressCredentialEnvelope,
type AddressList, type AddressList,
type AddressListEntry, type AddressListEntry,
type AddressSyncConflict, type AddressSyncConflict,
@@ -138,10 +148,10 @@ type CardDavFormState = {
collection_url: string; collection_url: string;
display_name: string; display_name: string;
auth_type: "none" | "basic" | "bearer"; auth_type: "none" | "basic" | "bearer";
credential_envelope_id: string;
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";
}; };
@@ -195,10 +205,10 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = {
collection_url: "", collection_url: "",
display_name: "", display_name: "",
auth_type: "basic", auth_type: "basic",
credential_envelope_id: "",
username: "", username: "",
password: "", password: "",
bearer_token: "", bearer_token: "",
credential_ref: "",
sync_direction: "read_only" sync_direction: "read_only"
}; };
@@ -289,14 +299,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,25 +617,15 @@ 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[]>([]);
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]); const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
const [syncSources, setSyncSources] = useState<AddressSyncSource[]>([]); const [syncSources, setSyncSources] = useState<AddressSyncSource[]>([]);
const [contacts, setContacts] = useState<Contact[]>([]); const [contacts, setContacts] = useState<Contact[]>([]);
const [contactTotal, setContactTotal] = useState(0);
const [contactPage, setContactPage] = useState(1);
const [contactPageSize, setContactPageSize] = useState(50);
const [selectedBookId, setSelectedBookId] = useState(""); const [selectedBookId, setSelectedBookId] = useState("");
const [selectedListId, setSelectedListId] = useState(""); const [selectedListId, setSelectedListId] = useState("");
const [selectedContactId, setSelectedContactId] = useState(""); const [selectedContactId, setSelectedContactId] = useState("");
@@ -649,6 +652,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [cardDavOpen, setCardDavOpen] = useState(false); const [cardDavOpen, setCardDavOpen] = useState(false);
const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM); const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM);
const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]); const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]);
const [cardDavCredentials, setCardDavCredentials] = useState<AddressCredentialEnvelope[]>([]);
const [cardDavCredentialsError, setCardDavCredentialsError] = useState("");
const [syncInspector, setSyncInspector] = useState<SyncInspectorState>(null); const [syncInspector, setSyncInspector] = useState<SyncInspectorState>(null);
const [syncPlan, setSyncPlan] = useState<AddressSyncPlan | null>(null); const [syncPlan, setSyncPlan] = useState<AddressSyncPlan | null>(null);
const [syncDiagnostics, setSyncDiagnostics] = useState<AddressSyncDiagnostic[]>([]); const [syncDiagnostics, setSyncDiagnostics] = useState<AddressSyncDiagnostic[]>([]);
@@ -668,6 +673,37 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const canReadSync = hasScope(auth, "addresses:sync:read"); const canReadSync = hasScope(auth, "addresses:sync:read");
const canWriteSync = hasScope(auth, "addresses:sync:write"); const canWriteSync = hasScope(auth, "addresses:sync:write");
useEffect(() => {
if (!cardDavOpen || !canWriteSync) {
setCardDavCredentials([]);
setCardDavCredentialsError("");
return;
}
let active = true;
listAddressCredentials(settings)
.then((credentials) => {
if (active) {
setCardDavCredentials(credentials);
setCardDavCredentialsError("");
}
})
.catch((err) => {
if (active) {
setCardDavCredentials([]);
setCardDavCredentialsError(errorMessage(err));
}
});
return () => {
active = false;
};
}, [
canWriteSync,
cardDavOpen,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
useEffect(() => { useEffect(() => {
if (auth.groups_loaded || !onAuthChange) return; if (auth.groups_loaded || !onAuthChange) return;
let active = true; let active = true;
@@ -687,10 +723,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]); const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]);
const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]); const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]);
const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]); const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]);
const visibleContacts = useMemo( const visibleContacts = contacts;
() => selectedList ? contacts.filter((contact) => selectedListContactIds.has(contact.id)) : contacts,
[contacts, selectedList, selectedListContactIds]
);
const memberCandidateContacts = useMemo(() => { const memberCandidateContacts = useMemo(() => {
const normalizedQuery = memberQuery.trim().toLowerCase(); const normalizedQuery = memberQuery.trim().toLowerCase();
return memberCandidates return memberCandidates
@@ -954,10 +987,25 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const refreshContacts = useCallback(async (bookId: string, search: string) => { const refreshContacts = useCallback(async (bookId: string, search: string) => {
if (!bookId) { if (!bookId) {
setContacts([]); setContacts([]);
setContactTotal(0);
return; return;
} }
setContacts(await listContacts(settings, { addressBookId: bookId, query: search, limit: 500, includeDeleted: showArchived })); const response = await listContactsPage(settings, {
}, [settings, showArchived]); addressBookId: bookId,
addressListId: selectedListId || undefined,
query: search,
limit: contactPageSize,
offset: (contactPage - 1) * contactPageSize,
includeDeleted: showArchived
});
const pageCount = Math.max(1, Math.ceil(response.total / contactPageSize));
if (contactPage > pageCount) {
setContactPage(pageCount);
return;
}
setContacts(response.contacts);
setContactTotal(response.total);
}, [contactPage, contactPageSize, selectedListId, settings, showArchived]);
const refreshListEntries = useCallback(async (listId: string) => { const refreshListEntries = useCallback(async (listId: string) => {
if (!listId) { if (!listId) {
@@ -1132,12 +1180,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function openTreeNode(node: AddressTreeNode) { function openTreeNode(node: AddressTreeNode) {
if (node.kind === "book" && node.book) { if (node.kind === "book" && node.book) {
setContactPage(1);
setSelectedBookId(node.book.id); setSelectedBookId(node.book.id);
setSelectedListId(""); setSelectedListId("");
setSelectedContactId(""); setSelectedContactId("");
return; return;
} }
if (node.kind === "list" && node.list) { if (node.kind === "list" && node.list) {
setContactPage(1);
setSelectedBookId(node.list.address_book_id); setSelectedBookId(node.list.address_book_id);
setSelectedListId(node.list.id); setSelectedListId(node.list.id);
setSelectedContactId(""); setSelectedContactId("");
@@ -1519,17 +1569,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function openCardDavDialog() { function openCardDavDialog() {
setCardDavForm(EMPTY_CARDDAV_FORM); setCardDavForm(EMPTY_CARDDAV_FORM);
setCardDavDiscovery([]); setCardDavDiscovery([]);
setCardDavCredentialsError("");
setCardDavOpen(true); setCardDavOpen(true);
} }
function cardDavPayload() { function cardDavPayload() {
const credentialRef = cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id
? `credential-envelope:${cardDavForm.credential_envelope_id}`
: null;
return { return {
url: cardDavForm.collection_url, url: cardDavForm.collection_url,
auth_type: cardDavForm.auth_type, auth_type: cardDavForm.auth_type,
username: cardDavForm.username || null, username: cardDavForm.username || null,
password: cardDavForm.password || null, password: credentialRef ? null : cardDavForm.password || null,
bearer_token: cardDavForm.bearer_token || null, bearer_token: credentialRef ? null : cardDavForm.bearer_token || null,
credential_ref: cardDavForm.credential_ref || null credential_ref: credentialRef
}; };
} }
@@ -1568,9 +1622,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
display_name: cardDavForm.display_name || selectedBook.name, display_name: cardDavForm.display_name || selectedBook.name,
auth_type: cardDavForm.auth_type, auth_type: cardDavForm.auth_type,
username: cardDavForm.username || null, username: cardDavForm.username || null,
password: cardDavForm.password || null, password: cardDavForm.credential_envelope_id ? null : cardDavForm.password || null,
bearer_token: cardDavForm.bearer_token || null, bearer_token: cardDavForm.credential_envelope_id ? null : cardDavForm.bearer_token || null,
credential_ref: cardDavForm.credential_ref || null, credential_ref: cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id
? `credential-envelope:${cardDavForm.credential_envelope_id}`
: 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 +1809,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 +1833,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 +1863,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 +1877,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 +1899,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 +1920,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 +1983,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">
@@ -1947,7 +2003,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
label="Show archived" label="Show archived"
checked={showArchived} checked={showArchived}
disabled={Boolean(toggleArchiveReason)} disabled={Boolean(toggleArchiveReason)}
onChange={() => setShowArchived((current) => !current)} onChange={() => {
setContactPage(1);
setShowArchived((current) => !current);
}}
help="Include archived address books, lists, and contacts." help="Include archived address books, lists, and contacts."
/> />
</div> </div>
@@ -1981,28 +2040,49 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<div> <div>
<h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2> <h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2>
<p> <p>
{visibleContacts.length} contact{visibleContacts.length === 1 ? "" : "s"} {contactTotal} contact{contactTotal === 1 ? "" : "s"}
{selectedList ? " in selected list" : selectedBook ? " in selected book" : ""} {selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}
</p> </p>
</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">
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search contacts" /> <input
value={query}
onChange={(event) => {
setContactPage(1);
setQuery(event.target.value);
}}
placeholder="Search contacts" />
{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>
<DataGridPaginationBar
page={contactPage}
pageSize={contactPageSize}
totalRows={contactTotal}
pageSizeOptions={[25, 50, 100, 200]}
disabled={loading || saving || !selectedBook}
className="address-contact-pagination"
ariaLabel="Contact pagination"
onPageChange={setContactPage}
onPageSizeChange={(pageSize) => {
setContactPageSize(pageSize);
setContactPage(1);
}} />
</section> </section>
<section className="address-detail-panel" aria-label="Contact detail"> <section className="address-detail-panel" aria-label="Contact detail">
@@ -2019,8 +2099,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 +2135,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 +2162,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 +2179,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 +2187,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 +2196,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 +2204,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 +2213,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 +2225,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 +2242,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 +2266,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 +2289,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 +2316,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)}>
@@ -2267,33 +2347,62 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<input value={cardDavForm.display_name} onChange={(event) => setCardDavForm((current) => ({ ...current, display_name: event.target.value }))} placeholder={selectedBook?.name ?? "CardDAV"} /> <input value={cardDavForm.display_name} onChange={(event) => setCardDavForm((current) => ({ ...current, display_name: event.target.value }))} placeholder={selectedBook?.name ?? "CardDAV"} />
</FormField> </FormField>
</div> </div>
{cardDavForm.auth_type !== "none" &&
<FormField label="Reusable credential">
<select
value={cardDavForm.credential_envelope_id}
onChange={(event) => {
const credentialId = event.target.value;
const credential = cardDavCredentials.find((item) => item.id === credentialId);
const username = credential?.public_data?.username;
setCardDavForm((current) => ({
...current,
credential_envelope_id: credentialId,
username: credentialId && username ? String(username) : "",
password: "",
bearer_token: ""
}));
}}>
<option value="">Enter source-specific credentials below</option>
{cardDavCredentials.map((credential) => (
<option key={credential.id} value={credential.id}>
{credential.name}{credential.public_data.username ? ` (${String(credential.public_data.username)})` : ""}
</option>
))}
</select>
</FormField>
}
{cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>}
{cardDavForm.auth_type === "basic" && {cardDavForm.auth_type === "basic" &&
<div className="form-grid two"> <div className="form-grid two">
<FormField label="Username"> <FormField label="Username">
<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 }))} disabled={Boolean(cardDavForm.credential_envelope_id)} />
</FormField>
<FormField label="Password">
<input type="password" value={cardDavForm.password} onChange={(event) => setCardDavForm((current) => ({ ...current, password: event.target.value }))} />
</FormField> </FormField>
{!cardDavForm.credential_envelope_id &&
<FormField label="Password">
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
</FormField>
}
</div> </div>
} }
{cardDavForm.auth_type === "bearer" && {cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id &&
<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 +2416,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 +2433,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 +2475,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 +2502,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 +2519,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 +2541,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 +2552,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 +2566,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 +2578,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

@@ -13,7 +13,7 @@ const translations = {
export const addressesModule: PlatformWebModule = { export const addressesModule: PlatformWebModule = {
id: "addresses", id: "addresses",
label: "i18n:govoplan-addresses.address_book.f6327f59", label: "i18n:govoplan-addresses.address_book.f6327f59",
version: "1.0.0", version: "0.1.9",
dependencies: [], dependencies: [],
optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"], optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"],
translations, translations,

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,20 @@
align-content: start; align-content: start;
} }
.address-contact-pagination {
border-top: var(--border-line);
flex: 0 0 auto;
}
.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 +573,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 +587,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 {