15 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
18 changed files with 1643 additions and 416 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
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
`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
stored remote vCard payload or a manual per-field local/remote merge payload.
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

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.8",
"version": "0.1.9",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"README.md"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-addresses"
version = "0.1.8"
version = "0.1.9"
description = "GovOPlaN reusable address and recipient-source module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"defusedxml>=0.7.1",
"govoplan-core>=0.1.8",
"govoplan-core>=0.1.11",
]
[tool.setuptools.packages.find]

View File

@@ -8,6 +8,11 @@ from sqlalchemy import func
from govoplan_core.auth import ApiPrincipal
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.schemas import ContactCreateRequest
from govoplan_addresses.backend.service import (
@@ -137,6 +142,60 @@ class AddressesLookupCapability:
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:
def list_write_targets(
self,
@@ -301,6 +360,10 @@ def lookup_capability(_context: Any) -> AddressesLookupCapability:
return AddressesLookupCapability()
def people_search_capability(_context: Any) -> AddressesPeopleSearchProvider:
return AddressesPeopleSearchProvider()
def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapability:
return AddressesRecipientSourceCapability()

View File

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

View File

@@ -1,7 +1,10 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from sqlalchemy import inspect
from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_CONTACT_WRITER,
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_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.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
@@ -25,6 +29,47 @@ from govoplan_core.core.modules import (
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:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
@@ -104,11 +149,12 @@ def _addresses_router(_context: ModuleContext):
manifest = ModuleManifest(
id="addresses",
name="Addresses",
version="0.1.8",
version="0.1.9",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
provides_interfaces=(
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_CONTACT_WRITER, version="0.1.8"),
),
@@ -128,24 +174,12 @@ manifest = ModuleManifest(
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
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",
),
retirement_provider=_addresses_retirement_provider,
retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.",
),
capability_factories={
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__(
"govoplan_addresses.backend.capabilities",
fromlist=["recipient_source_capability"],

View File

@@ -41,6 +41,8 @@ from govoplan_addresses.backend.schemas import (
AddressCardDavDiscoveryRequest,
AddressCardDavDiscoveryResponse,
AddressCardDavSourceCreateRequest,
AddressCredentialEnvelopeListResponse,
AddressCredentialEnvelopeResponse,
AddressSyncAttemptFinishRequest,
AddressSyncConflictCreateRequest,
AddressSyncConflictListResponse,
@@ -69,6 +71,7 @@ from govoplan_addresses.backend.schemas import (
)
from govoplan_addresses.backend.service import (
AddressBookError,
available_address_credentials,
address_book_contact_counts,
address_list_entry_counts,
create_address_book,
@@ -77,6 +80,7 @@ from govoplan_addresses.backend.service import (
create_carddav_sync_source,
create_contact,
create_sync_source,
count_contacts,
delete_address_book,
delete_address_list,
delete_address_list_entry,
@@ -103,6 +107,7 @@ from govoplan_addresses.backend.service import (
restore_contact,
resolve_sync_conflict,
preview_sync_source,
public_address_sync_metadata,
run_sync_source,
start_sync_attempt,
update_address_book,
@@ -217,7 +222,7 @@ def _sync_source_response(sync_source: AddressSyncSource) -> AddressSyncSourceRe
"last_success_at": sync_source.last_success_at,
"last_error": sync_source.last_error,
"last_diagnostic": sync_source.last_diagnostic,
"metadata": sync_source.metadata_ or {},
"metadata": public_address_sync_metadata(sync_source.metadata_),
"created_at": sync_source.created_at,
"updated_at": sync_source.updated_at,
}
@@ -426,16 +431,41 @@ def api_restore_address_book(
@router.get("/contacts", response_model=ContactListResponse)
def api_list_contacts(
address_book_id: str | None = Query(default=None),
address_list_id: str | None = Query(default=None),
query: str | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
include_deleted: bool = Query(default=False),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
try:
contacts = list_contacts(session, principal, address_book_id=address_book_id, query=query, limit=limit, include_deleted=include_deleted)
return ContactListResponse(contacts=[_contact_response(contact) for contact in contacts])
contacts = list_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:
raise _error(exc) from exc
@@ -638,6 +668,25 @@ def api_discover_carddav_address_books(
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)
def api_create_carddav_sync_source(
book_id: str,

View File

@@ -221,6 +221,10 @@ class ContactResponse(BaseModel):
class ContactListResponse(BaseModel):
contacts: list[ContactResponse]
total: int
offset: int
limit: int
has_more: bool
class AddressLookupResponse(BaseModel):
@@ -439,6 +443,26 @@ class AddressCardDavDiscoveryResponse(BaseModel):
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):
collection_url: str = Field(min_length=1, max_length=2000)
display_name: str | None = Field(default=None, max_length=255)

View File

@@ -1,8 +1,10 @@
from __future__ import annotations
import copy
from collections.abc import Iterable
from dataclasses import dataclass, field
import os
import re
import urllib.parse
from typing import Any
@@ -10,13 +12,23 @@ from sqlalchemy import and_, false, func, or_
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.audit.logging import audit_event
from govoplan_core.core.change_sequence import record_change
from govoplan_core.db.base import utcnow
from govoplan_core.security.credential_envelopes import (
CredentialAccessContext,
CredentialEnvelopeError,
ResolvedCredentialEnvelope,
credential_envelope_summary,
list_credential_envelopes,
resolve_credential_envelope,
)
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
from govoplan_addresses.backend.carddav import (
AddressCardDAVAddressBook,
AddressCardDAVClient,
AddressCardDAVError,
AddressCardDAVObject,
AddressCardDAVPreconditionFailed,
AddressCardDAVReportResult,
AddressCardDAVSyncUnsupported,
@@ -57,13 +69,46 @@ from govoplan_addresses.backend.schemas import (
ContactPostalAddressPayload,
ContactUpdateRequest,
)
from govoplan_addresses.backend.vcard import ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues
from govoplan_addresses.backend.vcard import ParsedVCard, ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues
class AddressBookError(ValueError):
pass
CORE_CREDENTIAL_ENVELOPE_PREFIX = "credential-envelope:"
def address_credential_context(
*,
tenant_id: str,
source_id: str | None = None,
) -> CredentialAccessContext:
return CredentialAccessContext(
tenant_id=tenant_id,
target_scope_type="tenant",
target_scope_id=tenant_id,
module_id="addresses",
server_ref=f"addresses:{source_id}" if source_id else None,
)
def available_address_credentials(
session: Session,
principal: ApiPrincipal,
*,
source_id: str | None = None,
) -> list[dict[str, Any]]:
context = address_credential_context(
tenant_id=principal.tenant_id,
source_id=source_id,
)
return [
credential_envelope_summary(row)
for row in list_credential_envelopes(session, context=context)
]
@dataclass(frozen=True, slots=True)
class VCardImportResult:
contacts: list[Contact]
@@ -256,6 +301,8 @@ def create_sync_source(
principal: ApiPrincipal,
address_book_id: str,
payload: AddressSyncSourceCreateRequest,
*,
trusted_connector_metadata: bool = False,
) -> AddressSyncSource:
book = get_visible_address_book(session, principal, address_book_id)
if book.deleted_at is not None:
@@ -266,6 +313,8 @@ def create_sync_source(
raise AddressBookError("Sync connector type is required.")
if not display_name:
raise AddressBookError("Sync source display name is required.")
if connector_type.casefold() == "carddav" and not trusted_connector_metadata:
_assert_api_carddav_metadata_safe(payload.metadata)
read_only = _read_only_from_sync_direction(payload.sync_direction, payload.read_only)
sync_source = AddressSyncSource(
tenant_id=book.tenant_id,
@@ -325,7 +374,11 @@ def update_sync_source(
if "remote_revision" in payload.model_fields_set:
sync_source.remote_revision = _trim(payload.remote_revision)
if "metadata" in payload.model_fields_set:
sync_source.metadata_ = payload.metadata or {}
metadata = payload.metadata or {}
if sync_source.connector_type.casefold() == "carddav":
_assert_api_carddav_metadata_safe(metadata)
metadata = _merge_server_owned_carddav_metadata(sync_source.metadata_, metadata)
sync_source.metadata_ = metadata
sync_source.updated_by_account_id = _account_id(principal)
_apply_sync_source_to_book(sync_source.address_book, sync_source)
return sync_source
@@ -341,9 +394,89 @@ def delete_sync_source(session: Session, principal: ApiPrincipal, sync_source_id
book.sync_status = None
book.sync_error = None
book.updated_by_account_id = _account_id(principal)
_audit_sync_credential_deletion(session, principal, sync_source)
session.delete(sync_source)
def _audit_sync_credential_deletion(
session: Session,
principal: ApiPrincipal,
sync_source: AddressSyncSource,
) -> None:
"""Audit removal of source-owned credential material in the DB transaction.
CardDAV credentials are encrypted inside the sync-source row. Deleting that
row therefore deletes the credential atomically with the connector. Legacy
credential references are detached from the source, but are never resolved
or sent to an external provider because ownership cannot be proven.
"""
tenant_id = _tenant_id_or_none(principal)
user = getattr(principal, "user", None)
_record_sync_credential_deletion_audit(
session,
sync_source=sync_source,
tenant_id=tenant_id,
user_id=getattr(user, "id", None),
api_key_id=getattr(principal, "api_key_id", None),
deletion_reason="sync_source_deleted",
)
def _record_sync_credential_deletion_audit(
session: Session,
*,
sync_source: AddressSyncSource,
tenant_id: str | None,
user_id: str | None,
api_key_id: str | None,
deletion_reason: str,
) -> bool:
auth = _carddav_auth_metadata(sync_source.metadata_)
if auth.get("secret_encrypted"):
storage_backend = "encrypted_database"
elif auth.get("credential_ref"):
storage_backend = "legacy_reference"
else:
return False
audit_event(
session,
tenant_id=tenant_id,
user_id=user_id,
api_key_id=api_key_id,
action="addresses.sync_credential_deleted",
scope="tenant" if tenant_id is not None else "system",
object_type="address_sync_credential",
object_id=sync_source.id,
details={
"sync_source_id": sync_source.id,
"connector_type": sync_source.connector_type,
"storage_backend": storage_backend,
"deletion_reason": deletion_reason,
},
)
return True
def audit_address_credentials_for_retirement(session: Session) -> int:
"""Audit credentials that the ensuing destructive table drop deletes."""
sources = session.query(AddressSyncSource).order_by(AddressSyncSource.id.asc()).all()
audited = 0
for source in sources:
if _record_sync_credential_deletion_audit(
session,
sync_source=source,
tenant_id=source.tenant_id,
user_id=None,
api_key_id=None,
deletion_reason="module_data_retired",
):
audited += 1
session.flush()
return audited
def start_sync_attempt(session: Session, principal: ApiPrincipal, sync_source_id: str) -> AddressSyncSource:
sync_source = get_visible_sync_source(session, principal, sync_source_id)
if not sync_source.enabled:
@@ -588,6 +721,7 @@ def discover_carddav_address_books(
principal: ApiPrincipal,
payload: AddressCardDavDiscoveryRequest,
) -> list[AddressCardDAVAddressBook]:
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
source = get_visible_sync_source(session, principal, payload.source_id) if payload.source_id else None
client = _carddav_client_from_payload(session, principal, payload, source=source)
return client.discover_addressbooks()
@@ -599,6 +733,9 @@ def create_carddav_sync_source(
address_book_id: str,
payload: AddressCardDavSourceCreateRequest,
) -> AddressSyncSource:
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
if payload.credential_ref and (payload.password is not None or payload.bearer_token is not None):
raise AddressBookError("Select a reusable credential or enter a new secret, not both.")
collection_url = ensure_collection_url(payload.collection_url)
display_name = _trim(payload.display_name) or "CardDAV address book"
metadata = _carddav_metadata(
@@ -609,7 +746,7 @@ def create_carddav_sync_source(
credential_ref=payload.credential_ref,
collection_url=collection_url,
)
return create_sync_source(
source = create_sync_source(
session,
principal,
address_book_id,
@@ -624,7 +761,21 @@ def create_carddav_sync_source(
remote_revision=payload.remote_revision,
metadata=metadata,
),
trusted_connector_metadata=True,
)
reusable = _resolve_core_address_credential(
session,
tenant_id=principal.tenant_id,
source_id=source.id,
credential_ref=payload.credential_ref,
)
if reusable is not None:
source_metadata = dict(source.metadata_ or {})
auth = dict(source_metadata.get("carddav") or {})
auth["username"] = _credential_username(reusable) or auth.get("username")
source_metadata["carddav"] = auth
source.metadata_ = source_metadata
return source
def preview_sync_source(
@@ -759,15 +910,85 @@ def _plan_carddav_report(
for item in report.objects:
href = item.href
seen_hrefs.add(href)
local = local_by_href.get(href)
_plan_carddav_report_object(
sync_source=sync_source,
client=client,
item=item,
local=local_by_href.get(href),
reads_remote=reads_remote,
plan=plan,
)
if plan.stats.full_sync:
_plan_carddav_full_sync_absences(
sync_source=sync_source,
local_by_href=local_by_href,
seen_hrefs=seen_hrefs,
reads_remote=reads_remote,
plan=plan,
)
_plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan)
def _plan_carddav_report_object(
*,
sync_source: AddressSyncSource,
client: AddressCardDAVClient,
item: AddressCardDAVObject,
local: Contact | None,
reads_remote: bool,
plan: AddressSyncPlan,
) -> None:
if item.deleted:
if local is not None and local.deleted_at is None:
_plan_carddav_remote_deletion(
sync_source=sync_source,
item=item,
local=local,
reads_remote=reads_remote,
plan=plan,
)
return
loaded = _load_carddav_report_vcard(client=client, item=item, plan=plan)
if loaded is None:
return
raw_vcard, parsed = loaded
_plan_carddav_live_object(
sync_source=sync_source,
item=item,
local=local,
reads_remote=reads_remote,
raw_vcard=raw_vcard,
parsed=parsed,
plan=plan,
)
def _plan_carddav_remote_deletion(
*,
sync_source: AddressSyncSource,
item: AddressCardDAVObject,
local: Contact | None,
reads_remote: bool,
plan: AddressSyncPlan,
) -> None:
if local is None or local.deleted_at is not None:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="unchanged",
href=item.href,
etag=item.etag,
message="Remote delete already reflected locally.",
),
)
return
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="conflict",
href=href,
href=item.href,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag,
@@ -775,49 +996,75 @@ def _plan_carddav_report(
),
)
elif reads_remote:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="delete", href=href, contact_id=local.id, display_name=local.display_name, etag=item.etag))
else:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, etag=item.etag, message="Remote delete already reflected locally."))
continue
raw_vcard = item.address_data
if not raw_vcard:
try:
raw_vcard = client.fetch_object(href)
except AddressCardDAVError as exc:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="error", href=href, etag=item.etag, message=str(exc)))
continue
parsed, error_message = _parse_single_remote_vcard(raw_vcard)
if parsed is None:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="error", href=href, etag=item.etag, message=error_message or "Remote vCard could not be parsed."))
continue
if local is None:
if reads_remote:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="create",
href=href,
remote_uid=parsed.source_ref,
display_name=parsed.payload.display_name,
action="delete",
href=item.href,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag,
raw_vcard=raw_vcard,
parsed_payload=parsed.payload,
source_revision=item.etag or parsed.source_revision,
),
)
else:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, etag=item.etag, message="Remote object ignored by export-only sync source."))
continue
def _load_carddav_report_vcard(
*,
client: AddressCardDAVClient,
item: AddressCardDAVObject,
plan: AddressSyncPlan,
) -> tuple[str, ParsedVCard] | None:
raw_vcard = item.address_data
if not raw_vcard:
try:
raw_vcard = client.fetch_object(item.href)
except AddressCardDAVError as exc:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(action="error", href=item.href, etag=item.etag, message=str(exc)),
)
return None
parsed, error_message = _parse_single_remote_vcard(raw_vcard)
if parsed is None:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="error",
href=item.href,
etag=item.etag,
message=error_message or "Remote vCard could not be parsed.",
),
)
return None
return raw_vcard, parsed
def _plan_carddav_live_object(
*,
sync_source: AddressSyncSource,
item: AddressCardDAVObject,
local: Contact | None,
reads_remote: bool,
raw_vcard: str,
parsed: ParsedVCard,
plan: AddressSyncPlan,
) -> None:
if local is None:
_plan_new_carddav_remote_object(
item=item,
reads_remote=reads_remote,
raw_vcard=raw_vcard,
parsed=parsed,
plan=plan,
)
return
remote_revision = item.etag or parsed.source_revision
if local.deleted_at is not None and _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
if _carddav_local_delete_needs_push(local, sync_source):
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="remote_delete",
href=href,
href=item.href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=local.display_name,
@@ -825,37 +1072,22 @@ def _plan_carddav_report(
message="Local delete will be pushed to CardDAV.",
),
)
continue
return
if local.deleted_at is None and local.source_revision == remote_revision:
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="remote_update",
href=href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag or local.source_revision,
raw_vcard=_carddav_contact_vcard(local, href=href),
source_revision=item.etag or local.source_revision,
message="Local update will be pushed to CardDAV.",
),
_plan_matching_carddav_revision(
sync_source=sync_source,
item=item,
local=local,
parsed=parsed,
plan=plan,
)
continue
_add_sync_plan_item(
plan,
AddressSyncPlanItem(action="unchanged", href=href, remote_uid=parsed.source_ref, contact_id=local.id, display_name=local.display_name, etag=item.etag),
)
continue
return
if _local_contact_changed_after_last_sync(local, sync_source):
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="conflict",
href=href,
href=item.href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=local.display_name,
@@ -866,14 +1098,111 @@ def _plan_carddav_report(
message="Local contact changed since the last successful sync.",
),
)
continue
return
_plan_carddav_remote_update(
item=item,
local=local,
reads_remote=reads_remote,
raw_vcard=raw_vcard,
parsed=parsed,
remote_revision=remote_revision,
plan=plan,
)
def _plan_new_carddav_remote_object(
*,
item: AddressCardDAVObject,
reads_remote: bool,
raw_vcard: str,
parsed: ParsedVCard,
plan: AddressSyncPlan,
) -> None:
if reads_remote:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="create",
href=item.href,
remote_uid=parsed.source_ref,
display_name=parsed.payload.display_name,
etag=item.etag,
raw_vcard=raw_vcard,
parsed_payload=parsed.payload,
source_revision=item.etag or parsed.source_revision,
),
)
else:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="unchanged",
href=item.href,
etag=item.etag,
message="Remote object ignored by export-only sync source.",
),
)
def _carddav_local_delete_needs_push(local: Contact, sync_source: AddressSyncSource) -> bool:
return bool(
local.deleted_at is not None
and _sync_source_writes_remote(sync_source)
and _local_contact_changed_after_last_sync(local, sync_source)
)
def _plan_matching_carddav_revision(
*,
sync_source: AddressSyncSource,
item: AddressCardDAVObject,
local: Contact,
parsed: ParsedVCard,
plan: AddressSyncPlan,
) -> None:
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="remote_update",
href=item.href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag or local.source_revision,
raw_vcard=_carddav_contact_vcard(local, href=item.href),
source_revision=item.etag or local.source_revision,
message="Local update will be pushed to CardDAV.",
),
)
return
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="unchanged",
href=item.href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag,
),
)
def _plan_carddav_remote_update(
*,
item: AddressCardDAVObject,
local: Contact,
reads_remote: bool,
raw_vcard: str,
parsed: ParsedVCard,
remote_revision: str | None,
plan: AddressSyncPlan,
) -> None:
if reads_remote:
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="update",
href=href,
href=item.href,
remote_uid=parsed.source_ref,
contact_id=local.id,
display_name=parsed.payload.display_name or local.display_name,
@@ -884,11 +1213,30 @@ def _plan_carddav_report(
),
)
else:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, contact_id=local.id, display_name=local.display_name, etag=item.etag, message="Remote update ignored by export-only sync source."))
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="unchanged",
href=item.href,
contact_id=local.id,
display_name=local.display_name,
etag=item.etag,
message="Remote update ignored by export-only sync source.",
),
)
if plan.stats.full_sync:
def _plan_carddav_full_sync_absences(
*,
sync_source: AddressSyncSource,
local_by_href: dict[str, Contact],
seen_hrefs: set[str],
reads_remote: bool,
plan: AddressSyncPlan,
) -> None:
for href, contact in local_by_href.items():
if href not in seen_hrefs and contact.deleted_at is None:
if href in seen_hrefs or contact.deleted_at is not None:
continue
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(contact, sync_source):
_add_sync_plan_item(
plan,
@@ -902,9 +1250,16 @@ def _plan_carddav_report(
),
)
elif reads_remote:
_add_sync_plan_item(plan, AddressSyncPlanItem(action="delete", href=href, contact_id=contact.id, display_name=contact.display_name, message="Remote object is absent from full sync."))
_plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan)
_add_sync_plan_item(
plan,
AddressSyncPlanItem(
action="delete",
href=href,
contact_id=contact.id,
display_name=contact.display_name,
message="Remote object is absent from full sync.",
),
)
def _plan_carddav_outbound_local_changes(
@@ -1308,6 +1663,7 @@ def _carddav_client_from_payload(
*,
source: AddressSyncSource | None = None,
) -> AddressCardDAVClient:
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
url = payload.url or (source.external_address_book_ref if source else "")
metadata = dict(source.metadata_ or {}) if source else {}
auth = dict(metadata.get("carddav") or {})
@@ -1316,7 +1672,23 @@ def _carddav_client_from_payload(
password = _secret_value(payload.password)
bearer_token = _secret_value(payload.bearer_token)
credential_ref = payload.credential_ref if payload.credential_ref is not None else auth.get("credential_ref")
secret = _resolve_carddav_secret(auth_type=auth_type, password=password, bearer_token=bearer_token, credential_ref=credential_ref, encrypted=auth.get("secret_encrypted"))
reusable = _resolve_core_address_credential(
session,
tenant_id=principal.tenant_id,
source_id=source.id if source else None,
credential_ref=credential_ref,
)
username = username or _credential_username(reusable)
secret = _resolve_carddav_secret(
session=session,
tenant_id=principal.tenant_id,
source_id=source.id if source else None,
auth_type=auth_type,
password=password,
bearer_token=bearer_token,
credential_ref=credential_ref,
encrypted=auth.get("secret_encrypted"),
)
return _new_carddav_client(url, auth_type=auth_type, username=username, secret=secret)
@@ -1327,12 +1699,21 @@ def _carddav_client_for_source(
password: str | None = None,
bearer_token: str | None = None,
) -> AddressCardDAVClient:
del session
metadata = dict(sync_source.metadata_ or {})
auth = dict(metadata.get("carddav") or {})
auth_type = str(auth.get("auth_type") or "none")
username = auth.get("username")
reusable = _resolve_core_address_credential(
session,
tenant_id=sync_source.tenant_id or "",
source_id=sync_source.id,
credential_ref=auth.get("credential_ref"),
)
username = username or _credential_username(reusable)
secret = _resolve_carddav_secret(
session=session,
tenant_id=sync_source.tenant_id or "",
source_id=sync_source.id,
auth_type=auth_type,
password=password,
bearer_token=bearer_token,
@@ -1381,6 +1762,9 @@ def _carddav_metadata(
def _resolve_carddav_secret(
*,
session: Session,
tenant_id: str,
source_id: str | None,
auth_type: str,
password: str | None,
bearer_token: str | None,
@@ -1391,13 +1775,145 @@ def _resolve_carddav_secret(
return password
if auth_type == "bearer" and bearer_token:
return bearer_token
if credential_ref and credential_ref.startswith(CARDDAV_SECRET_ENV_PREFIX):
return os.environ.get(credential_ref.removeprefix(CARDDAV_SECRET_ENV_PREFIX))
if credential_ref:
reusable = _resolve_core_address_credential(
session,
tenant_id=tenant_id,
source_id=source_id,
credential_ref=credential_ref,
)
if reusable is None:
raise AddressBookError(
"The CardDAV credential reference is not a server-owned credential or visible reusable credential envelope."
)
return _credential_secret(reusable, auth_type=auth_type)
if encrypted:
return decrypt_secret(encrypted)
return None
def _core_credential_id(credential_ref: str | None) -> str | None:
if not credential_ref or not credential_ref.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX):
return None
value = credential_ref.removeprefix(CORE_CREDENTIAL_ENVELOPE_PREFIX).strip()
return value or None
def _resolve_core_address_credential(
session: Session,
*,
tenant_id: str,
source_id: str | None,
credential_ref: str | None,
) -> ResolvedCredentialEnvelope | None:
credential_id = _core_credential_id(credential_ref)
if credential_id is None:
return None
try:
return resolve_credential_envelope(
session,
credential_id=credential_id,
context=address_credential_context(
tenant_id=tenant_id,
source_id=source_id,
),
)
except CredentialEnvelopeError as exc:
raise AddressBookError(
"The selected reusable credential is unavailable to this CardDAV source."
) from exc
def _credential_username(credential: ResolvedCredentialEnvelope | None) -> str | None:
if credential is None:
return None
value = credential.public_data.get("username")
return _trim(value)
def _credential_secret(credential: ResolvedCredentialEnvelope, *, auth_type: str) -> str | None:
keys = (
("password", "secret", "token")
if auth_type == "basic"
else ("access_token", "bearer_token", "token", "password", "secret")
)
for key in keys:
value = credential.secret_data.get(key)
if value is not None and str(value):
return str(value)
return None
def resolve_trusted_deployment_carddav_credential_ref(credential_ref: str) -> str | None:
"""Resolve env-backed credentials only for trusted deployment code.
API-managed discovery and sync-source paths deliberately never call this
function, so a tenant user cannot select arbitrary process environment
variables as connector credentials.
"""
if not credential_ref.startswith(CARDDAV_SECRET_ENV_PREFIX):
raise AddressBookError("Trusted deployment credential references must use the env: prefix")
env_name = credential_ref.removeprefix(CARDDAV_SECRET_ENV_PREFIX)
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_name):
raise AddressBookError("Trusted deployment credential reference contains an invalid environment variable name")
return os.environ.get(env_name)
def public_address_sync_metadata(metadata: object) -> dict[str, Any]:
payload = copy.deepcopy(metadata) if isinstance(metadata, dict) else {}
auth = payload.get("carddav")
if not isinstance(auth, dict):
return payload
had_credential = bool(auth.get("secret_encrypted") or auth.get("credential_ref"))
auth["credential_envelope_id"] = _core_credential_id(auth.get("credential_ref"))
auth.pop("secret_encrypted", None)
auth.pop("credential_ref", None)
auth["has_credential"] = had_credential
return payload
def _assert_no_caller_carddav_credential_ref(credential_ref: str | None) -> None:
value = _trim(credential_ref)
if value and not value.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX):
raise AddressBookError(
"Caller-supplied credential references are accepted only for reusable credential envelopes."
)
def _carddav_auth_metadata(metadata: object) -> dict[str, Any]:
if not isinstance(metadata, dict):
return {}
carddav = metadata.get("carddav")
return carddav if isinstance(carddav, dict) else {}
def _assert_api_carddav_metadata_safe(metadata: object) -> None:
auth = _carddav_auth_metadata(metadata)
if auth.get("credential_ref") or auth.get("secret_encrypted"):
raise AddressBookError(
"CardDAV credential references and encrypted secrets are server-managed; provide credentials through the CardDAV source endpoint"
)
def _merge_server_owned_carddav_metadata(existing: object, incoming: dict[str, Any]) -> dict[str, Any]:
merged = copy.deepcopy(incoming)
existing_auth = _carddav_auth_metadata(existing)
server_owned = {
key: existing_auth[key]
for key in ("credential_ref", "secret_encrypted")
if existing_auth.get(key)
}
if not server_owned:
return merged
auth = merged.get("carddav")
if not isinstance(auth, dict):
auth = {}
merged["carddav"] = auth
auth.update(server_owned)
return merged
def _secret_value(value: Any | None) -> str | None:
if value is None:
return None
@@ -1905,14 +2421,79 @@ def list_contacts(
principal: ApiPrincipal,
*,
address_book_id: str | None = None,
address_list_id: str | None = None,
query: str | None = None,
limit: int = 200,
offset: int = 0,
include_deleted: bool = False,
) -> list[Contact]:
contact_query = _filtered_contact_query(
session,
principal,
address_book_id=address_book_id,
address_list_id=address_list_id,
query=query,
include_deleted=include_deleted,
)
return (
contact_query.order_by(Contact.display_name.asc(), Contact.id.asc())
.offset(max(0, offset))
.limit(max(1, min(limit, 500)))
.all()
)
def count_contacts(
session: Session,
principal: ApiPrincipal,
*,
address_book_id: str | None = None,
address_list_id: str | None = None,
query: str | None = None,
include_deleted: bool = False,
) -> int:
contact_query = _filtered_contact_query(
session,
principal,
address_book_id=address_book_id,
address_list_id=address_list_id,
query=query,
include_deleted=include_deleted,
)
return int(
contact_query.order_by(None)
.with_entities(func.count(func.distinct(Contact.id)))
.scalar()
or 0
)
def _filtered_contact_query(
session: Session,
principal: ApiPrincipal,
*,
address_book_id: str | None,
address_list_id: str | None,
query: str | None,
include_deleted: bool,
):
contact_query = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted)
if address_book_id:
get_visible_address_book(session, principal, address_book_id, include_deleted=include_deleted)
contact_query = contact_query.filter(Contact.address_book_id == address_book_id)
if address_list_id:
address_list = get_visible_address_list(
session,
principal,
address_list_id,
include_deleted=include_deleted,
)
if address_book_id and address_list.address_book_id != address_book_id:
raise AddressBookError("Address list does not belong to the selected address book.")
contact_query = contact_query.join(
AddressListEntry,
AddressListEntry.contact_id == Contact.id,
).filter(AddressListEntry.address_list_id == address_list_id)
normalized_query = _trim(query)
if normalized_query:
pattern = f"%{normalized_query.lower()}%"
@@ -1923,7 +2504,7 @@ def list_contacts(
func.lower(ContactEmail.email).like(pattern),
)
)
return contact_query.order_by(Contact.display_name.asc(), Contact.id.asc()).limit(max(1, min(limit, 500))).all()
return contact_query.distinct()
def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False) -> Contact:

View File

@@ -1,13 +1,19 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker
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 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.capabilities import (
CAPABILITY_ADDRESSES_CONTACT_WRITER,
@@ -15,6 +21,7 @@ from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
AddressesContactWriterCapability,
AddressesLookupCapability,
AddressesPeopleSearchProvider,
AddressesRecipientSourceCapability,
AddressWriterError,
)
@@ -33,6 +40,7 @@ from govoplan_addresses.backend.db.models import (
)
from govoplan_addresses.backend.schemas import (
AddressBookCreateRequest,
AddressCardDavDiscoveryRequest,
AddressListCreateRequest,
AddressListEntryCreateRequest,
AddressCardDavSourceCreateRequest,
@@ -48,6 +56,7 @@ from govoplan_addresses.backend.schemas import (
ContactPostalAddressPayload,
)
from govoplan_addresses.backend.manifest import manifest
from govoplan_addresses.backend.router import _sync_source_response
from govoplan_addresses.backend.service import (
AddressBookError,
address_book_contact_counts,
@@ -58,9 +67,11 @@ from govoplan_addresses.backend.service import (
create_carddav_sync_source,
create_contact,
create_sync_source,
count_contacts,
delete_address_list_entry,
delete_contact,
delete_sync_source,
discover_carddav_address_books,
export_address_book_vcard,
import_vcards,
list_address_list_entries,
@@ -81,6 +92,8 @@ from govoplan_addresses.backend.service import (
start_sync_attempt,
finish_sync_attempt,
update_sync_source,
resolve_trusted_deployment_carddav_credential_ref,
_carddav_client_for_source,
)
@@ -174,6 +187,7 @@ class AddressServiceTest(unittest.TestCase):
AddressSyncConflict.__table__,
AddressSyncDiagnostic.__table__,
ChangeSequenceEntry.__table__,
CredentialEnvelope.__table__,
],
)
self.session = sessionmaker(bind=self.engine)()
@@ -239,6 +253,50 @@ class AddressServiceTest(unittest.TestCase):
self.session.commit()
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:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported"))
self.session.commit()
@@ -340,8 +398,17 @@ END:VCARD
self.assertEqual(len(lookup), 1)
self.assertEqual(lookup[0].email, "ada@example.local")
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)
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)
self.assertEqual(len(recipient_sources), 1)
@@ -448,6 +515,27 @@ END:VCARD
self.assertEqual(entries[0].contact_email.email, "ada.private@example.local")
self.assertEqual(entries[1].target_kind, "postal_address")
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"):
create_address_list_entry(
@@ -787,6 +875,156 @@ END:VCARD
self.assertEqual(contacts[0].display_name, "Ada Remote")
self.assertEqual(contacts[0].emails[0].email, "ada.remote@example.local")
def test_carddav_credentials_are_server_managed_and_public_metadata_is_redacted(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Secured remote"),
)
self.session.commit()
self.session.refresh(book)
with self.assertRaisesRegex(AddressBookError, "Caller-supplied credential references"):
create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://attacker.example.test/addressbooks/personal/",
auth_type="bearer",
credential_ref="env:MASTER_KEY_B64",
),
)
with self.assertRaisesRegex(AddressBookError, "Caller-supplied credential references"):
discover_carddav_address_books(
self.session,
self.principal,
AddressCardDavDiscoveryRequest(
url="https://attacker.example.test/addressbooks/",
auth_type="bearer",
credential_ref="env:MASTER_KEY_B64",
),
)
with self.assertRaisesRegex(AddressBookError, "server-managed"):
create_sync_source(
self.session,
self.principal,
book.id,
AddressSyncSourceCreateRequest(
connector_type="carddav",
display_name="Injected",
metadata={"carddav": {"auth_type": "bearer", "credential_ref": "env:DATABASE_URL"}},
),
)
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:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Writable Remote"))
self.session.commit()
@@ -934,6 +1172,107 @@ END:VCARD
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])
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__":
unittest.main()

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.8",
"version": "0.1.9",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -13,12 +13,15 @@
},
"./styles/addresses.css": "./src/styles/addresses.css"
},
"scripts": {
"test:ui-structure": "node scripts/test-selection-list-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@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[];
};
type ContactListResponse = {
export type ContactListResponse = {
contacts: Contact[];
total: number;
offset: number;
limit: number;
has_more: boolean;
};
type AddressBookWriteTargetsResponse = {
@@ -216,6 +220,22 @@ export type AddressCardDavAddressBook = {
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 = {
created: number;
updated: number;
@@ -305,6 +325,10 @@ type AddressCardDavDiscoveryResponse = {
address_books: AddressCardDavAddressBook[];
};
type AddressCredentialEnvelopeListResponse = {
credentials: AddressCredentialEnvelope[];
};
type AddressSyncDiagnosticListResponse = {
diagnostics: AddressSyncDiagnostic[];
};
@@ -450,7 +474,14 @@ export async function listAddressSyncSources(
export function discoverCardDavAddressBooks(
settings: ApiSettings,
payload: { url: string; auth_type: "none" | "basic" | "bearer"; username?: string | null; password?: string | null; bearer_token?: string | null; credential_ref?: string | null }
payload: {
url: string;
auth_type: "none" | "basic" | "bearer";
username?: string | null;
password?: string | null;
bearer_token?: string | null;
credential_ref?: string | null;
}
): Promise<AddressCardDavAddressBook[]> {
return apiFetch<AddressCardDavDiscoveryResponse>(settings, "/api/v1/addresses/carddav/discover", {
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(
settings: ApiSettings,
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[]> {
const response = await apiFetch<ContactListResponse>(
export function listContactsPage(settings: ApiSettings, options: {addressBookId?: string | null;addressListId?: string | null;query?: string | null;limit?: number;offset?: number;includeDeleted?: boolean;} = {}): Promise<ContactListResponse> {
return apiFetch<ContactListResponse>(
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;
}

View File

@@ -1,21 +1,28 @@
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type ButtonHTMLAttributes, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import {
ApiError,
Button,
ConfirmDialog,
DataGridPaginationBar,
Dialog,
DisabledActionTooltip,
DismissibleAlert,
ExplorerTree,
fetchAuthGroups,
formatDateTime,
FormField,
LoadingFrame,
PasswordField,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
ToggleSwitch,
hasScope,
type ApiSettings,
type AuthInfo,
type AuthUpdate
type AuthUpdate,
type FormatDateTimeOptions
} from "@govoplan/core-webui";
import {
createAddressBook,
@@ -33,6 +40,7 @@ import {
exportContactVcard,
importAddressBookVcards,
listAddressBooks,
listAddressCredentials,
listAddressListEntries,
listAddressLists,
listAddressSyncConflicts,
@@ -40,6 +48,7 @@ import {
listAddressSyncSources,
listAddressSyncTombstones,
listContacts,
listContactsPage,
previewAddressSyncSource,
restoreAddressBook,
restoreAddressList,
@@ -53,6 +62,7 @@ import {
type AddressCardDavAddressBook,
type AddressBook,
type AddressBookScope,
type AddressCredentialEnvelope,
type AddressList,
type AddressListEntry,
type AddressSyncConflict,
@@ -138,10 +148,10 @@ type CardDavFormState = {
collection_url: string;
display_name: string;
auth_type: "none" | "basic" | "bearer";
credential_envelope_id: string;
username: string;
password: string;
bearer_token: string;
credential_ref: string;
sync_direction: "read_only" | "import" | "export" | "two_way";
};
@@ -195,10 +205,10 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = {
collection_url: "",
display_name: "",
auth_type: "basic",
credential_envelope_id: "",
username: "",
password: "",
bearer_token: "",
credential_ref: "",
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;
}
function formatDateTime(value?: string | null): string {
if (!value) return "Never";
try {
return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
} catch {
return value;
}
}
const ADDRESS_DATE_TIME_OPTIONS: FormatDateTimeOptions = {
fallback: "Never",
year: undefined,
month: undefined,
day: undefined,
hour: undefined,
minute: undefined,
timeZoneName: undefined,
dateStyle: "short",
timeStyle: "short"
};
function planSummary(plan: AddressSyncPlan | null): string {
if (!plan) return "No preview loaded.";
@@ -604,25 +617,15 @@ function disabledReason(...conditions: Array<[boolean, string]>): string {
return conditions.find(([applies]) => applies)?.[1] ?? "";
}
type ReasonedButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost" | "danger";
disabledReason: string;
};
function ReasonedButton({ disabledReason, disabled, children, ...props }: ReasonedButtonProps) {
return (
<DisabledActionTooltip reason={disabledReason}>
<Button {...props} disabled={disabled || Boolean(disabledReason)}>{children}</Button>
</DisabledActionTooltip>
);
}
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
const [books, setBooks] = useState<AddressBook[]>([]);
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
const [syncSources, setSyncSources] = useState<AddressSyncSource[]>([]);
const [contacts, setContacts] = useState<Contact[]>([]);
const [contactTotal, setContactTotal] = useState(0);
const [contactPage, setContactPage] = useState(1);
const [contactPageSize, setContactPageSize] = useState(50);
const [selectedBookId, setSelectedBookId] = useState("");
const [selectedListId, setSelectedListId] = useState("");
const [selectedContactId, setSelectedContactId] = useState("");
@@ -649,6 +652,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [cardDavOpen, setCardDavOpen] = useState(false);
const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM);
const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]);
const [cardDavCredentials, setCardDavCredentials] = useState<AddressCredentialEnvelope[]>([]);
const [cardDavCredentialsError, setCardDavCredentialsError] = useState("");
const [syncInspector, setSyncInspector] = useState<SyncInspectorState>(null);
const [syncPlan, setSyncPlan] = useState<AddressSyncPlan | null>(null);
const [syncDiagnostics, setSyncDiagnostics] = useState<AddressSyncDiagnostic[]>([]);
@@ -668,6 +673,37 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const canReadSync = hasScope(auth, "addresses:sync:read");
const canWriteSync = hasScope(auth, "addresses:sync:write");
useEffect(() => {
if (!cardDavOpen || !canWriteSync) {
setCardDavCredentials([]);
setCardDavCredentialsError("");
return;
}
let active = true;
listAddressCredentials(settings)
.then((credentials) => {
if (active) {
setCardDavCredentials(credentials);
setCardDavCredentialsError("");
}
})
.catch((err) => {
if (active) {
setCardDavCredentials([]);
setCardDavCredentialsError(errorMessage(err));
}
});
return () => {
active = false;
};
}, [
canWriteSync,
cardDavOpen,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
useEffect(() => {
if (auth.groups_loaded || !onAuthChange) return;
let active = true;
@@ -687,10 +723,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]);
const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]);
const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]);
const visibleContacts = useMemo(
() => selectedList ? contacts.filter((contact) => selectedListContactIds.has(contact.id)) : contacts,
[contacts, selectedList, selectedListContactIds]
);
const visibleContacts = contacts;
const memberCandidateContacts = useMemo(() => {
const normalizedQuery = memberQuery.trim().toLowerCase();
return memberCandidates
@@ -954,10 +987,25 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const refreshContacts = useCallback(async (bookId: string, search: string) => {
if (!bookId) {
setContacts([]);
setContactTotal(0);
return;
}
setContacts(await listContacts(settings, { addressBookId: bookId, query: search, limit: 500, includeDeleted: showArchived }));
}, [settings, showArchived]);
const response = await listContactsPage(settings, {
addressBookId: bookId,
addressListId: selectedListId || undefined,
query: search,
limit: contactPageSize,
offset: (contactPage - 1) * contactPageSize,
includeDeleted: showArchived
});
const pageCount = Math.max(1, Math.ceil(response.total / contactPageSize));
if (contactPage > pageCount) {
setContactPage(pageCount);
return;
}
setContacts(response.contacts);
setContactTotal(response.total);
}, [contactPage, contactPageSize, selectedListId, settings, showArchived]);
const refreshListEntries = useCallback(async (listId: string) => {
if (!listId) {
@@ -1132,12 +1180,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function openTreeNode(node: AddressTreeNode) {
if (node.kind === "book" && node.book) {
setContactPage(1);
setSelectedBookId(node.book.id);
setSelectedListId("");
setSelectedContactId("");
return;
}
if (node.kind === "list" && node.list) {
setContactPage(1);
setSelectedBookId(node.list.address_book_id);
setSelectedListId(node.list.id);
setSelectedContactId("");
@@ -1519,17 +1569,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function openCardDavDialog() {
setCardDavForm(EMPTY_CARDDAV_FORM);
setCardDavDiscovery([]);
setCardDavCredentialsError("");
setCardDavOpen(true);
}
function cardDavPayload() {
const credentialRef = cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id
? `credential-envelope:${cardDavForm.credential_envelope_id}`
: null;
return {
url: cardDavForm.collection_url,
auth_type: cardDavForm.auth_type,
username: cardDavForm.username || null,
password: cardDavForm.password || null,
bearer_token: cardDavForm.bearer_token || null,
credential_ref: cardDavForm.credential_ref || null
password: credentialRef ? null : cardDavForm.password || null,
bearer_token: credentialRef ? null : cardDavForm.bearer_token || null,
credential_ref: credentialRef
};
}
@@ -1568,9 +1622,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
display_name: cardDavForm.display_name || selectedBook.name,
auth_type: cardDavForm.auth_type,
username: cardDavForm.username || null,
password: cardDavForm.password || null,
bearer_token: cardDavForm.bearer_token || null,
credential_ref: cardDavForm.credential_ref || null,
password: cardDavForm.credential_envelope_id ? null : cardDavForm.password || null,
bearer_token: cardDavForm.credential_envelope_id ? null : cardDavForm.bearer_token || null,
credential_ref: cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id
? `credential-envelope:${cardDavForm.credential_envelope_id}`
: null,
sync_direction: cardDavForm.sync_direction,
read_only: cardDavForm.sync_direction === "read_only" || cardDavForm.sync_direction === "import"
});
@@ -1753,7 +1809,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedBookSyncSources.map((source) => (
<p key={source.id}>
{syncSourceLabel(source)} · {source.connector_type} · {source.status}
{source.last_success_at ? ` · last success ${formatDateTime(source.last_success_at)}` : ""}
{source.last_success_at ? ` · last success ${formatDateTime(source.last_success_at, ADDRESS_DATE_TIME_OPTIONS)}` : ""}
{source.last_error ? ` · ${source.last_error}` : ""}
</p>
))}
@@ -1777,27 +1833,27 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function renderSelectedBookActions() {
return (
<>
<ReasonedButton type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></ReasonedButton>
<ReasonedButton type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></ReasonedButton>
<ReasonedButton type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></ReasonedButton>
<ReasonedButton type="button" title="Import vCard into selected address book" aria-label="Import vCard into selected address book" onClick={() => setImportOpen(true)} disabledReason={importBookReason}><Upload size={15} /></ReasonedButton>
<ReasonedButton type="button" title="Export selected address book as vCard" aria-label="Export selected address book as vCard" onClick={() => void exportSelectedBook()} disabledReason={exportBookReason}><Download size={15} /></ReasonedButton>
<ReasonedButton type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></ReasonedButton>
<ReasonedButton type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></ReasonedButton>
<ReasonedButton type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</ReasonedButton>
<ReasonedButton type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</ReasonedButton>
<Button type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></Button>
<Button type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></Button>
<Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
<Button type="button" title="Import vCard into selected address book" aria-label="Import vCard into selected address book" onClick={() => setImportOpen(true)} disabledReason={importBookReason}><Upload size={15} /></Button>
<Button type="button" title="Export selected address book as vCard" aria-label="Export selected address book as vCard" onClick={() => void exportSelectedBook()} disabledReason={exportBookReason}><Download size={15} /></Button>
<Button type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></Button>
<Button type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></Button>
<Button type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</Button>
<Button type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</Button>
{selectedList ?
selectedList.deleted_at ?
<ReasonedButton type="button" title="Restore address list" aria-label="Restore address list" disabledReason={restoreListReason(selectedList)} onClick={() => void restoreDeletedList(selectedList)}><RotateCcw size={15} /></ReasonedButton> :
<Button type="button" title="Restore address list" aria-label="Restore address list" disabledReason={restoreListReason(selectedList)} onClick={() => void restoreDeletedList(selectedList)}><RotateCcw size={15} /></Button> :
<>
<ReasonedButton type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></ReasonedButton>
<ReasonedButton type="button" variant="danger" title="Delete address list" aria-label="Delete address list" disabledReason={deleteListReason(selectedList)} onClick={() => setConfirmState({ kind: "list", list: selectedList })}><Trash2 size={15} /></ReasonedButton>
<Button type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></Button>
<Button type="button" variant="danger" title="Delete address list" aria-label="Delete address list" disabledReason={deleteListReason(selectedList)} onClick={() => setConfirmState({ kind: "list", list: selectedList })}><Trash2 size={15} /></Button>
</> :
selectedBook?.deleted_at ?
<ReasonedButton type="button" title="Restore address book" aria-label="Restore address book" disabledReason={restoreBookReason(selectedBook)} onClick={() => void restoreBook(selectedBook)}><RotateCcw size={15} /></ReasonedButton> :
<Button type="button" title="Restore address book" aria-label="Restore address book" disabledReason={restoreBookReason(selectedBook)} onClick={() => void restoreBook(selectedBook)}><RotateCcw size={15} /></Button> :
<>
<ReasonedButton type="button" title="Edit address book" aria-label="Edit address book" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && openEditBookDialog(selectedBook)}><Edit3 size={15} /></ReasonedButton>
<ReasonedButton type="button" variant="danger" title="Delete address book" aria-label="Delete address book" disabledReason={selectedBook ? deleteBookReason(selectedBook) : "Select an address book before deleting."} onClick={() => selectedBook && setConfirmState({ kind: "book", book: selectedBook })}><Trash2 size={15} /></ReasonedButton>
<Button type="button" title="Edit address book" aria-label="Edit address book" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && openEditBookDialog(selectedBook)}><Edit3 size={15} /></Button>
<Button type="button" variant="danger" title="Delete address book" aria-label="Delete address book" disabledReason={selectedBook ? deleteBookReason(selectedBook) : "Select an address book before deleting."} onClick={() => selectedBook && setConfirmState({ kind: "book", book: selectedBook })}><Trash2 size={15} /></Button>
</>
}
</>
@@ -1807,10 +1863,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function renderContactRow(contact: Contact) {
const selected = selectedContactId === contact.id;
return (
<button
type="button"
<SelectionListItem
key={contact.id}
className={`address-contact-row ${selected ? "is-selected" : ""} ${contact.deleted_at ? "is-archived-row" : ""}`}
selected={selected}
className={`address-contact-row ${contact.deleted_at ? "is-archived-row" : ""}`}
draggable={!contact.deleted_at && !saving}
onClick={() => setSelectedContactId(contact.id)}
onDragStart={(event) => handleContactDragStart(event, contact)}>
@@ -1821,7 +1877,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<span className="address-contact-row-meta">
{contact.deleted_at ? <StatusBadge status="archived" /> : contact.tags.slice(0, 2).map((tag) => <span key={tag} className="address-tag">{tag}</span>)}
</span>
</button>
</SelectionListItem>
);
}
@@ -1843,11 +1899,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div>
<div className="button-row compact-actions">
{selectedContact.deleted_at ?
<ReasonedButton type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</ReasonedButton> :
<Button type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</Button> :
<>
<ReasonedButton type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</ReasonedButton>
<ReasonedButton type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</ReasonedButton>
<ReasonedButton type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</ReasonedButton>
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
</>
}
</div>
@@ -1864,13 +1920,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedContactListEntries.map((entry) => (
<div className="address-membership-row" key={entry.id}>
<p className="muted">{entryTargetLabel(entry)}</p>
<ReasonedButton
<Button
type="button"
variant="danger"
onClick={() => void removeAddressListEntry(entry)}
disabledReason={removeContactFromListReason()}>
<X size={15} /> Remove
</ReasonedButton>
</Button>
</div>
))}
</div>
@@ -1927,8 +1983,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
return (
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
{error && <div className="alert alert-error address-error">{error}</div>}
{notice && !error && <div className="alert alert-success address-error">{notice}</div>}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading address books..." className="address-workspace-frame">
<div className="address-book-workspace">
@@ -1947,7 +2003,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
label="Show archived"
checked={showArchived}
disabled={Boolean(toggleArchiveReason)}
onChange={() => setShowArchived((current) => !current)}
onChange={() => {
setContactPage(1);
setShowArchived((current) => !current);
}}
help="Include archived address books, lists, and contacts."
/>
</div>
@@ -1981,28 +2040,49 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<div>
<h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2>
<p>
{visibleContacts.length} contact{visibleContacts.length === 1 ? "" : "s"}
{contactTotal} contact{contactTotal === 1 ? "" : "s"}
{selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}
</p>
</div>
<div className="button-row compact-actions">
{selectedList &&
<ReasonedButton type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</ReasonedButton>
<Button type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</Button>
}
<ReasonedButton type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</ReasonedButton>
<Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</Button>
</div>
</header>
<div className="address-contact-toolbar">
<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?.deleted_at && <StatusBadge status="archived" />}
</div>
<div className="address-contact-list" role="list">
<div className="address-contact-list">
{visibleContacts.length === 0 ?
<div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> :
visibleContacts.map(renderContactRow)
<SelectionList label="Contacts" className="address-contact-selection-list">
{visibleContacts.map(renderContactRow)}
</SelectionList>
}
</div>
<DataGridPaginationBar
page={contactPage}
pageSize={contactPageSize}
totalRows={contactTotal}
pageSizeOptions={[25, 50, 100, 200]}
disabled={loading || saving || !selectedBook}
className="address-contact-pagination"
ariaLabel="Contact pagination"
onPageChange={setContactPage}
onPageSizeChange={(pageSize) => {
setContactPageSize(pageSize);
setContactPage(1);
}} />
</section>
<section className="address-detail-panel" aria-label="Contact detail">
@@ -2019,8 +2099,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={
<>
<ReasonedButton type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
<ReasonedButton type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</ReasonedButton>
<Button type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</Button>
</>
}>
<form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
@@ -2055,8 +2135,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={
<>
<ReasonedButton type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
<ReasonedButton type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</ReasonedButton>
<Button type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</Button>
</>
}>
<form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
@@ -2082,8 +2162,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={
<>
<ReasonedButton type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
<ReasonedButton type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</ReasonedButton>
<Button type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</Button>
</>
}>
<form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
@@ -2099,7 +2179,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<section className="address-form-section">
<div className="address-form-section-heading">
<strong>Email addresses</strong>
<ReasonedButton type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</ReasonedButton>
<Button type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</Button>
</div>
<div className="address-form-list">
{contactForm.emails.map((email) => (
@@ -2107,7 +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>
<input value={email.label} placeholder="Label" onChange={(event) => updateEmailRow(email.rowId, { label: event.target.value })} />
<input type="email" value={email.email} placeholder="Email address" onChange={(event) => updateEmailRow(email.rowId, { email: event.target.value })} />
<ReasonedButton type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></ReasonedButton>
<Button type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></Button>
</div>
))}
</div>
@@ -2116,7 +2196,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<section className="address-form-section">
<div className="address-form-section-heading">
<strong>Phone numbers</strong>
<ReasonedButton type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</ReasonedButton>
<Button type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</Button>
</div>
<div className="address-form-list">
{contactForm.phones.map((phone) => (
@@ -2124,7 +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>
<input value={phone.label} placeholder="Label" onChange={(event) => updatePhoneRow(phone.rowId, { label: event.target.value })} />
<input value={phone.phone} placeholder="Phone number" onChange={(event) => updatePhoneRow(phone.rowId, { phone: event.target.value })} />
<ReasonedButton type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></ReasonedButton>
<Button type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></Button>
</div>
))}
</div>
@@ -2133,7 +2213,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<section className="address-form-section">
<div className="address-form-section-heading">
<strong>Postal addresses</strong>
<ReasonedButton type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</ReasonedButton>
<Button type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</Button>
</div>
<div className="address-form-list">
{contactForm.postal_addresses.map((address) => (
@@ -2145,7 +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.region} placeholder="Region" onChange={(event) => updatePostalAddressRow(address.rowId, { region: event.target.value })} />
<input value={address.country} placeholder="Country" onChange={(event) => updatePostalAddressRow(address.rowId, { country: event.target.value })} />
<ReasonedButton type="button" variant="danger" title="Remove postal address" aria-label="Remove postal address" disabledReason={removePostalAddressRowReason} onClick={() => removePostalAddressRow(address.rowId)}><Trash2 size={15} /></ReasonedButton>
<Button type="button" variant="danger" title="Remove postal address" aria-label="Remove postal address" disabledReason={removePostalAddressRowReason} onClick={() => removePostalAddressRow(address.rowId)}><Trash2 size={15} /></Button>
</div>
))}
</div>
@@ -2162,7 +2242,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
closeDisabled={saving}
className="address-member-dialog"
footerClassName="button-row compact-actions"
footer={<ReasonedButton type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</ReasonedButton>}>
footer={<Button type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</Button>}>
<div className="address-member-picker">
<div className="address-contact-toolbar address-member-search">
<input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus />
@@ -2186,12 +2266,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
disabled={saving || targetOptions.length === 0}>
{targetOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
</select>
<ReasonedButton
<Button
type="button"
onClick={() => selectedList && void addContactToAddressList(selectedList, contact, selectedTarget)}
disabledReason={addContactToListReason(selectedList, contact, selectedTarget?.key ?? "")}>
<Plus size={15} /> Add
</ReasonedButton>
</Button>
</div>
);
})
@@ -2209,8 +2289,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={
<>
<ReasonedButton type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
<ReasonedButton type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</ReasonedButton>
<Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>
</>
}>
<form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
@@ -2236,9 +2316,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={
<>
<ReasonedButton type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
<ReasonedButton type="button" onClick={() => void discoverCardDavSources()} disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV URL before discovery."])}><Search size={16} /> Discover</ReasonedButton>
<ReasonedButton type="submit" form="address-carddav-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV address-book URL before saving."])}><Save size={16} /> Connect</ReasonedButton>
<Button type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="button" onClick={() => void discoverCardDavSources()} disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV URL before discovery."])}><Search size={16} /> Discover</Button>
<Button type="submit" form="address-carddav-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV address-book URL before saving."])}><Save size={16} /> Connect</Button>
</>
}>
<form id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}>
@@ -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"} />
</FormField>
</div>
{cardDavForm.auth_type !== "none" &&
<FormField label="Reusable credential">
<select
value={cardDavForm.credential_envelope_id}
onChange={(event) => {
const credentialId = event.target.value;
const credential = cardDavCredentials.find((item) => item.id === credentialId);
const username = credential?.public_data?.username;
setCardDavForm((current) => ({
...current,
credential_envelope_id: credentialId,
username: credentialId && username ? String(username) : "",
password: "",
bearer_token: ""
}));
}}>
<option value="">Enter source-specific credentials below</option>
{cardDavCredentials.map((credential) => (
<option key={credential.id} value={credential.id}>
{credential.name}{credential.public_data.username ? ` (${String(credential.public_data.username)})` : ""}
</option>
))}
</select>
</FormField>
}
{cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>}
{cardDavForm.auth_type === "basic" &&
<div className="form-grid two">
<FormField label="Username">
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} />
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} disabled={Boolean(cardDavForm.credential_envelope_id)} />
</FormField>
{!cardDavForm.credential_envelope_id &&
<FormField label="Password">
<input type="password" value={cardDavForm.password} onChange={(event) => setCardDavForm((current) => ({ ...current, password: event.target.value }))} />
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
</FormField>
}
</div>
}
{cardDavForm.auth_type === "bearer" &&
{cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id &&
<FormField label="Bearer token">
<input type="password" value={cardDavForm.bearer_token} onChange={(event) => setCardDavForm((current) => ({ ...current, bearer_token: event.target.value }))} />
<PasswordField value={cardDavForm.bearer_token} onValueChange={(value) => setCardDavForm((current) => ({ ...current, bearer_token: value }))} autoComplete="new-password" />
</FormField>
}
<FormField label="Credential reference">
<input value={cardDavForm.credential_ref} onChange={(event) => setCardDavForm((current) => ({ ...current, credential_ref: event.target.value }))} placeholder="env:GOVOPLAN_CARDDAV_PASSWORD" />
</FormField>
{cardDavDiscovery.length > 0 &&
<div className="address-sync-result-list">
<SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">
{cardDavDiscovery.map((item) => (
<button type="button" className="address-sync-result-row" key={item.collection_url} onClick={() => useDiscoveredCardDavBook(item)}>
<SelectionListItem
selected={cardDavForm.collection_url === item.collection_url}
className="address-sync-result-row"
key={item.collection_url}
onClick={() => useDiscoveredCardDavBook(item)}>
<strong>{item.display_name || item.collection_url}</strong>
<small>{item.collection_url}</small>
</button>
</SelectionListItem>
))}
</div>
</SelectionList>
}
</form>
</Dialog>
@@ -2307,9 +2416,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions"
footer={
<>
<ReasonedButton type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</ReasonedButton>
<ReasonedButton type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</ReasonedButton>
<ReasonedButton type="button" variant="primary" onClick={() => syncInspector && void runSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [!canWriteSync, "You need permission to run address sync."], [saving, savingReason])}>Run sync</ReasonedButton>
<Button type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</Button>
<Button type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</Button>
<Button type="button" variant="primary" onClick={() => syncInspector && void runSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [!canWriteSync, "You need permission to run address sync."], [saving, savingReason])}>Run sync</Button>
</>
}>
{syncInspector &&
@@ -2324,22 +2433,22 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<StatusBadge status={syncInspector.source.status} />
{syncInspector.source.read_only && <StatusBadge status="read-only" />}
</div>
<p className="muted">Last attempt: {formatDateTime(syncInspector.source.last_attempted_at)} · Last success: {formatDateTime(syncInspector.source.last_success_at)}</p>
{syncInspector.source.last_error && <p className="alert alert-error">{syncInspector.source.last_error}</p>}
<p className="muted">Last attempt: {formatDateTime(syncInspector.source.last_attempted_at, ADDRESS_DATE_TIME_OPTIONS)} · Last success: {formatDateTime(syncInspector.source.last_success_at, ADDRESS_DATE_TIME_OPTIONS)}</p>
{syncInspector.source.last_error && <DismissibleAlert tone="danger" resetKey={syncInspector.source.last_error}>{syncInspector.source.last_error}</DismissibleAlert>}
<div className="button-row compact-actions">
<ReasonedButton
<Button
type="button"
onClick={() => void toggleSyncSourceEnabled(syncInspector.source)}
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
{syncInspector.source.enabled ? "Disable" : "Enable"}
</ReasonedButton>
<ReasonedButton
</Button>
<Button
type="button"
variant="danger"
onClick={() => setConfirmState({ kind: "sync-source", source: syncInspector.source })}
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
Disconnect
</ReasonedButton>
</Button>
</div>
</div>
@@ -2366,7 +2475,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{syncConflicts.map((conflict) => (
<div className="address-sync-record-row" key={conflict.id}>
<span><strong>{conflict.field_path}</strong><small>{conflict.resource_href || conflict.remote_uid || conflict.contact_id}</small></span>
<ReasonedButton type="button" onClick={() => openConflictReview(conflict)} disabledReason={disabledReason([saving, savingReason])}>Review</ReasonedButton>
<Button type="button" onClick={() => openConflictReview(conflict)} disabledReason={disabledReason([saving, savingReason])}>Review</Button>
</div>
))}
</div>
@@ -2393,7 +2502,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<div className="address-sync-record-list">
{syncTombstones.slice(0, 20).map((tombstone) => (
<div className="address-sync-record-row" key={tombstone.id}>
<span><strong>{tombstone.remote_uid || tombstone.resource_href || tombstone.contact_id}</strong><small>{formatDateTime(tombstone.synced_at || tombstone.remote_deleted_at || tombstone.local_deleted_at)}</small></span>
<span><strong>{tombstone.remote_uid || tombstone.resource_href || tombstone.contact_id}</strong><small>{formatDateTime(tombstone.synced_at || tombstone.remote_deleted_at || tombstone.local_deleted_at, ADDRESS_DATE_TIME_OPTIONS)}</small></span>
</div>
))}
</div>
@@ -2410,20 +2519,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
className="address-sync-dialog"
footerClassName="button-row compact-actions"
footer={conflictDialog && <>
<ReasonedButton type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</ReasonedButton>
<ReasonedButton
<Button type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</Button>
<Button
type="button"
onClick={() => void resolveConflictWith(conflictDialog, "ignored", "ignored")}
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
Ignore
</ReasonedButton>
<ReasonedButton
</Button>
<Button
type="button"
onClick={() => void resolveConflictWith(conflictDialog, "keep_local")}
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
Keep local
</ReasonedButton>
<ReasonedButton
</Button>
<Button
type="button"
onClick={() => void applyMergedConflict(conflictDialog)}
disabledReason={disabledReason(
@@ -2432,8 +2541,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[saving, savingReason]
)}>
Apply merged
</ReasonedButton>
<ReasonedButton
</Button>
<Button
type="button"
variant="primary"
onClick={() => void resolveConflictWith(conflictDialog, "use_remote")}
@@ -2443,7 +2552,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[saving, savingReason]
)}>
Use remote
</ReasonedButton>
</Button>
</>}>
{conflictDialog &&
<div className="address-conflict-review">
@@ -2457,7 +2566,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />}
</div>
{conflictDialog.metadata?.message && <p className="muted">{String(conflictDialog.metadata.message)}</p>}
{!canApplyRemoteConflict(conflictDialog) && <p className="alert alert-warning">This conflict predates stored field payloads or came from a stale write. It can be marked resolved or ignored, but the remote value cannot be applied automatically.</p>}
{!canApplyRemoteConflict(conflictDialog) && <DismissibleAlert tone="warning" dismissible={false}>This conflict predates stored field payloads or came from a stale write. It can be marked resolved or ignored, but the remote value cannot be applied automatically.</DismissibleAlert>}
</div>
<div className="address-conflict-grid">
<div className="address-conflict-grid-header">Field</div>
@@ -2469,15 +2578,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<strong>{row.field}</strong>
<span>{row.local}</span>
<span>{row.remote}</span>
<span className="address-conflict-choice">
<div>
{canMergeConflict(conflictDialog) ?
<>
<button type="button" className={conflictMergeChoices[row.field] !== "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "local")}>Local</button>
<button type="button" className={conflictMergeChoices[row.field] === "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "remote")}>Remote</button>
</> :
<SegmentedControl<ConflictMergeChoice>
className="address-conflict-choice"
role="group"
size="equal"
ariaLabel={`Source for ${row.field}`}
options={[{ id: "local", label: "Local" }, { id: "remote", label: "Remote" }]}
value={conflictMergeChoices[row.field] ?? "local"}
onChange={(choice) => setConflictMergeChoice(row.field, choice)}
/> :
"—"
}
</span>
</div>
</div>
))}
</div>

View File

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

View File

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