8 Commits

15 changed files with 920 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,8 +12,17 @@ from sqlalchemy import and_, false, func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal 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.core.change_sequence import record_change
from govoplan_core.db.base import utcnow 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_core.security.secrets import decrypt_secret, encrypt_secret
from govoplan_addresses.backend.carddav import ( from govoplan_addresses.backend.carddav import (
AddressCardDAVAddressBook, AddressCardDAVAddressBook,
@@ -67,6 +76,39 @@ class AddressBookError(ValueError):
pass 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) @dataclass(frozen=True, slots=True)
class VCardImportResult: class VCardImportResult:
contacts: list[Contact] contacts: list[Contact]
@@ -352,9 +394,89 @@ def delete_sync_source(session: Session, principal: ApiPrincipal, sync_source_id
book.sync_status = None book.sync_status = None
book.sync_error = None book.sync_error = None
book.updated_by_account_id = _account_id(principal) book.updated_by_account_id = _account_id(principal)
_audit_sync_credential_deletion(session, principal, sync_source)
session.delete(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: def start_sync_attempt(session: Session, principal: ApiPrincipal, sync_source_id: str) -> AddressSyncSource:
sync_source = get_visible_sync_source(session, principal, sync_source_id) sync_source = get_visible_sync_source(session, principal, sync_source_id)
if not sync_source.enabled: if not sync_source.enabled:
@@ -612,6 +734,8 @@ def create_carddav_sync_source(
payload: AddressCardDavSourceCreateRequest, payload: AddressCardDavSourceCreateRequest,
) -> AddressSyncSource: ) -> AddressSyncSource:
_assert_no_caller_carddav_credential_ref(payload.credential_ref) _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) collection_url = ensure_collection_url(payload.collection_url)
display_name = _trim(payload.display_name) or "CardDAV address book" display_name = _trim(payload.display_name) or "CardDAV address book"
metadata = _carddav_metadata( metadata = _carddav_metadata(
@@ -619,10 +743,10 @@ def create_carddav_sync_source(
username=payload.username, username=payload.username,
password=_secret_value(payload.password), password=_secret_value(payload.password),
bearer_token=_secret_value(payload.bearer_token), bearer_token=_secret_value(payload.bearer_token),
credential_ref=None, credential_ref=payload.credential_ref,
collection_url=collection_url, collection_url=collection_url,
) )
return create_sync_source( source = create_sync_source(
session, session,
principal, principal,
address_book_id, address_book_id,
@@ -639,6 +763,19 @@ def create_carddav_sync_source(
), ),
trusted_connector_metadata=True, 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( def preview_sync_source(
@@ -1050,8 +1187,6 @@ def _plan_matching_carddav_revision(
etag=item.etag, etag=item.etag,
), ),
) )
def _plan_carddav_remote_update( def _plan_carddav_remote_update(
*, *,
item: AddressCardDAVObject, item: AddressCardDAVObject,
@@ -1537,7 +1672,23 @@ def _carddav_client_from_payload(
password = _secret_value(payload.password) password = _secret_value(payload.password)
bearer_token = _secret_value(payload.bearer_token) bearer_token = _secret_value(payload.bearer_token)
credential_ref = payload.credential_ref if payload.credential_ref is not None else auth.get("credential_ref") 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) return _new_carddav_client(url, auth_type=auth_type, username=username, secret=secret)
@@ -1548,12 +1699,21 @@ def _carddav_client_for_source(
password: str | None = None, password: str | None = None,
bearer_token: str | None = None, bearer_token: str | None = None,
) -> AddressCardDAVClient: ) -> AddressCardDAVClient:
del session
metadata = dict(sync_source.metadata_ or {}) metadata = dict(sync_source.metadata_ or {})
auth = dict(metadata.get("carddav") or {}) auth = dict(metadata.get("carddav") or {})
auth_type = str(auth.get("auth_type") or "none") auth_type = str(auth.get("auth_type") or "none")
username = auth.get("username") 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( secret = _resolve_carddav_secret(
session=session,
tenant_id=sync_source.tenant_id or "",
source_id=sync_source.id,
auth_type=auth_type, auth_type=auth_type,
password=password, password=password,
bearer_token=bearer_token, bearer_token=bearer_token,
@@ -1602,6 +1762,9 @@ def _carddav_metadata(
def _resolve_carddav_secret( def _resolve_carddav_secret(
*, *,
session: Session,
tenant_id: str,
source_id: str | None,
auth_type: str, auth_type: str,
password: str | None, password: str | None,
bearer_token: str | None, bearer_token: str | None,
@@ -1613,14 +1776,74 @@ def _resolve_carddav_secret(
if auth_type == "bearer" and bearer_token: if auth_type == "bearer" and bearer_token:
return bearer_token return bearer_token
if credential_ref: if credential_ref:
raise AddressBookError( reusable = _resolve_core_address_credential(
"The CardDAV credential reference is not a server-owned credential; provide a replacement password or token" 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: if encrypted:
return decrypt_secret(encrypted) return decrypt_secret(encrypted)
return None 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: def resolve_trusted_deployment_carddav_credential_ref(credential_ref: str) -> str | None:
"""Resolve env-backed credentials only for trusted deployment code. """Resolve env-backed credentials only for trusted deployment code.
@@ -1643,6 +1866,7 @@ def public_address_sync_metadata(metadata: object) -> dict[str, Any]:
if not isinstance(auth, dict): if not isinstance(auth, dict):
return payload return payload
had_credential = bool(auth.get("secret_encrypted") or auth.get("credential_ref")) 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("secret_encrypted", None)
auth.pop("credential_ref", None) auth.pop("credential_ref", None)
auth["has_credential"] = had_credential auth["has_credential"] = had_credential
@@ -1650,9 +1874,10 @@ def public_address_sync_metadata(metadata: object) -> dict[str, Any]:
def _assert_no_caller_carddav_credential_ref(credential_ref: str | None) -> None: def _assert_no_caller_carddav_credential_ref(credential_ref: str | None) -> None:
if _trim(credential_ref): value = _trim(credential_ref)
if value and not value.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX):
raise AddressBookError( raise AddressBookError(
"Caller-supplied credential references are not accepted; provide a password or bearer token" "Caller-supplied credential references are accepted only for reusable credential envelopes."
) )
@@ -2196,14 +2421,79 @@ def list_contacts(
principal: ApiPrincipal, principal: ApiPrincipal,
*, *,
address_book_id: str | None = None, address_book_id: str | None = None,
address_list_id: str | None = None,
query: str | None = None, query: str | None = None,
limit: int = 200, limit: int = 200,
offset: int = 0,
include_deleted: bool = False, include_deleted: bool = False,
) -> list[Contact]: ) -> 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) contact_query = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted)
if address_book_id: if address_book_id:
get_visible_address_book(session, principal, address_book_id, include_deleted=include_deleted) 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) 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) normalized_query = _trim(query)
if normalized_query: if normalized_query:
pattern = f"%{normalized_query.lower()}%" pattern = f"%{normalized_query.lower()}%"
@@ -2214,7 +2504,7 @@ def list_contacts(
func.lower(ContactEmail.email).like(pattern), 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: def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False) -> Contact:

View File

@@ -3,12 +3,17 @@ from __future__ import annotations
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from sqlalchemy import create_engine from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
from govoplan_core.security.credential_envelopes import (
CredentialEnvelope,
create_credential_envelope,
)
from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult
from govoplan_addresses.backend.capabilities import ( from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_CONTACT_WRITER, CAPABILITY_ADDRESSES_CONTACT_WRITER,
@@ -16,6 +21,7 @@ from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
AddressesContactWriterCapability, AddressesContactWriterCapability,
AddressesLookupCapability, AddressesLookupCapability,
AddressesPeopleSearchProvider,
AddressesRecipientSourceCapability, AddressesRecipientSourceCapability,
AddressWriterError, AddressWriterError,
) )
@@ -61,6 +67,7 @@ from govoplan_addresses.backend.service import (
create_carddav_sync_source, create_carddav_sync_source,
create_contact, create_contact,
create_sync_source, create_sync_source,
count_contacts,
delete_address_list_entry, delete_address_list_entry,
delete_contact, delete_contact,
delete_sync_source, delete_sync_source,
@@ -86,6 +93,7 @@ from govoplan_addresses.backend.service import (
finish_sync_attempt, finish_sync_attempt,
update_sync_source, update_sync_source,
resolve_trusted_deployment_carddav_credential_ref, resolve_trusted_deployment_carddav_credential_ref,
_carddav_client_for_source,
) )
@@ -179,6 +187,7 @@ class AddressServiceTest(unittest.TestCase):
AddressSyncConflict.__table__, AddressSyncConflict.__table__,
AddressSyncDiagnostic.__table__, AddressSyncDiagnostic.__table__,
ChangeSequenceEntry.__table__, ChangeSequenceEntry.__table__,
CredentialEnvelope.__table__,
], ],
) )
self.session = sessionmaker(bind=self.engine)() self.session = sessionmaker(bind=self.engine)()
@@ -244,6 +253,50 @@ class AddressServiceTest(unittest.TestCase):
self.session.commit() self.session.commit()
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
def test_contact_windows_report_exact_totals(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Paged"),
)
self.session.flush()
contacts = [
create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name=name,
emails=[ContactEmailPayload(email=f"{name.lower()}@example.local")],
),
)
for name in ("Ada", "Barbara", "Claude", "Dorothy", "Edsger")
]
self.session.commit()
page = list_contacts(
self.session,
self.principal,
address_book_id=book.id,
limit=2,
offset=2,
)
self.assertEqual([contact.id for contact in page], [contacts[2].id, contacts[3].id])
self.assertEqual(
count_contacts(self.session, self.principal, address_book_id=book.id),
5,
)
self.assertEqual(
count_contacts(
self.session,
self.principal,
address_book_id=book.id,
query="example.local",
),
5,
)
def test_vcard_import_and_export_preserves_common_fields(self) -> None: def test_vcard_import_and_export_preserves_common_fields(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported")) book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported"))
self.session.commit() self.session.commit()
@@ -345,8 +398,17 @@ END:VCARD
self.assertEqual(len(lookup), 1) self.assertEqual(len(lookup), 1)
self.assertEqual(lookup[0].email, "ada@example.local") self.assertEqual(lookup[0].email, "ada@example.local")
self.assertEqual(lookup[0].contact_id, contact.id) self.assertEqual(lookup[0].contact_id, contact.id)
people_provider = AddressesPeopleSearchProvider()
self.assertIsInstance(people_provider, PeopleSearchProvider)
people = people_provider.search_people(self.session, self.principal, query="ada")
self.assertEqual(people[0].key, "contacts")
self.assertEqual(people[0].candidates[0].reference_id, contact.id)
self.assertEqual(people[0].candidates[0].source_ref, f"addresses:contact:{contact.id}")
self.assertEqual(people[0].candidates[0].metadata["address_book_id"], book.id)
warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10) warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10)
self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"]) self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"])
self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, manifest.capability_factories)
self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal) recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal)
self.assertEqual(len(recipient_sources), 1) self.assertEqual(len(recipient_sources), 1)
@@ -453,6 +515,27 @@ END:VCARD
self.assertEqual(entries[0].contact_email.email, "ada.private@example.local") self.assertEqual(entries[0].contact_email.email, "ada.private@example.local")
self.assertEqual(entries[1].target_kind, "postal_address") self.assertEqual(entries[1].target_kind, "postal_address")
self.assertEqual(entries[1].contact_postal_address.locality, "Berlin") self.assertEqual(entries[1].contact_postal_address.locality, "Berlin")
self.assertEqual(
[
item.id
for item in list_contacts(
self.session,
self.principal,
address_book_id=book.id,
address_list_id=address_list.id,
)
],
[contact.id],
)
self.assertEqual(
count_contacts(
self.session,
self.principal,
address_book_id=book.id,
address_list_id=address_list.id,
),
1,
)
with self.assertRaisesRegex(ValueError, "same address book"): with self.assertRaisesRegex(ValueError, "same address book"):
create_address_list_entry( create_address_list_entry(
@@ -834,6 +917,44 @@ END:VCARD
), ),
) )
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( source = create_carddav_sync_source(
self.session, self.session,
self.principal, self.principal,
@@ -1051,6 +1172,107 @@ END:VCARD
self.assertFalse(book.read_only) self.assertFalse(book.read_only)
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
def test_delete_sync_source_deletes_and_audits_encrypted_credential_atomically(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Audited remote"),
)
self.session.commit()
self.session.refresh(book)
source = create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://carddav.example.local/addressbooks/audited/",
auth_type="basic",
username="ada",
password="do-not-audit-this",
),
)
self.session.commit()
source_id = source.id
with patch("govoplan_addresses.backend.service.audit_event") as audit:
delete_sync_source(self.session, self.principal, source_id)
self.session.commit()
self.assertIsNone(self.session.get(AddressSyncSource, source_id))
audit.assert_called_once()
audit_call = audit.call_args.kwargs
self.assertEqual(audit_call["action"], "addresses.sync_credential_deleted")
self.assertEqual(audit_call["object_id"], source_id)
self.assertEqual(audit_call["details"]["storage_backend"], "encrypted_database")
self.assertNotIn("credential_ref", audit_call["details"])
self.assertNotIn("secret_encrypted", audit_call["details"])
self.assertNotIn("do-not-audit-this", repr(audit_call))
def test_delete_sync_source_is_not_staged_when_credential_audit_fails(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Audit failure"),
)
self.session.commit()
self.session.refresh(book)
source = create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://carddav.example.local/addressbooks/audit-failure/",
auth_type="bearer",
bearer_token="do-not-audit-this",
),
)
self.session.commit()
with patch(
"govoplan_addresses.backend.service.audit_event",
side_effect=RuntimeError("audit unavailable"),
), self.assertRaisesRegex(RuntimeError, "audit unavailable"):
delete_sync_source(self.session, self.principal, source.id)
self.session.rollback()
persisted = self.session.get(AddressSyncSource, source.id)
self.assertIsNotNone(persisted)
self.assertIn("secret_encrypted", (persisted.metadata_ or {})["carddav"])
def test_destructive_module_retirement_audits_credentials_before_tables_are_dropped(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Retired module"),
)
self.session.commit()
self.session.refresh(book)
source = create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://carddav.example.local/addressbooks/module-retirement/",
auth_type="bearer",
bearer_token="do-not-audit-this",
),
)
self.session.commit()
source_id = source.id
retirement_provider = manifest.migration_spec.retirement_provider
assert retirement_provider is not None
plan = retirement_provider(self.session, "addresses")
assert plan.destroy_data_executor is not None
with patch("govoplan_addresses.backend.service.audit_event") as audit:
plan.destroy_data_executor(self.session, "addresses")
audit.assert_called_once()
audit_call = audit.call_args.kwargs
self.assertEqual(audit_call["object_id"], source_id)
self.assertEqual(audit_call["details"]["deletion_reason"], "module_data_retired")
self.assertNotIn("do-not-audit-this", repr(audit_call))
self.assertFalse(inspect(self.engine).has_table("addresses_sync_sources"))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/addresses-webui", "name": "@govoplan/addresses-webui",
"version": "0.1.8", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -17,11 +17,11 @@
"test:ui-structure": "node scripts/test-selection-list-structure.mjs" "test:ui-structure": "node scripts/test-selection-list-structure.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.9", "@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {

View File

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

View File

@@ -4,6 +4,7 @@ import {
ApiError, ApiError,
Button, Button,
ConfirmDialog, ConfirmDialog,
DataGridPaginationBar,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
ExplorerTree, ExplorerTree,
@@ -39,6 +40,7 @@ import {
exportContactVcard, exportContactVcard,
importAddressBookVcards, importAddressBookVcards,
listAddressBooks, listAddressBooks,
listAddressCredentials,
listAddressListEntries, listAddressListEntries,
listAddressLists, listAddressLists,
listAddressSyncConflicts, listAddressSyncConflicts,
@@ -46,6 +48,7 @@ import {
listAddressSyncSources, listAddressSyncSources,
listAddressSyncTombstones, listAddressSyncTombstones,
listContacts, listContacts,
listContactsPage,
previewAddressSyncSource, previewAddressSyncSource,
restoreAddressBook, restoreAddressBook,
restoreAddressList, restoreAddressList,
@@ -59,6 +62,7 @@ import {
type AddressCardDavAddressBook, type AddressCardDavAddressBook,
type AddressBook, type AddressBook,
type AddressBookScope, type AddressBookScope,
type AddressCredentialEnvelope,
type AddressList, type AddressList,
type AddressListEntry, type AddressListEntry,
type AddressSyncConflict, type AddressSyncConflict,
@@ -144,6 +148,7 @@ type CardDavFormState = {
collection_url: string; collection_url: string;
display_name: string; display_name: string;
auth_type: "none" | "basic" | "bearer"; auth_type: "none" | "basic" | "bearer";
credential_envelope_id: string;
username: string; username: string;
password: string; password: string;
bearer_token: string; bearer_token: string;
@@ -200,6 +205,7 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = {
collection_url: "", collection_url: "",
display_name: "", display_name: "",
auth_type: "basic", auth_type: "basic",
credential_envelope_id: "",
username: "", username: "",
password: "", password: "",
bearer_token: "", bearer_token: "",
@@ -617,6 +623,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]); const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
const [syncSources, setSyncSources] = useState<AddressSyncSource[]>([]); const [syncSources, setSyncSources] = useState<AddressSyncSource[]>([]);
const [contacts, setContacts] = useState<Contact[]>([]); const [contacts, setContacts] = useState<Contact[]>([]);
const [contactTotal, setContactTotal] = useState(0);
const [contactPage, setContactPage] = useState(1);
const [contactPageSize, setContactPageSize] = useState(50);
const [selectedBookId, setSelectedBookId] = useState(""); const [selectedBookId, setSelectedBookId] = useState("");
const [selectedListId, setSelectedListId] = useState(""); const [selectedListId, setSelectedListId] = useState("");
const [selectedContactId, setSelectedContactId] = useState(""); const [selectedContactId, setSelectedContactId] = useState("");
@@ -643,6 +652,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [cardDavOpen, setCardDavOpen] = useState(false); const [cardDavOpen, setCardDavOpen] = useState(false);
const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM); const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM);
const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]); const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]);
const [cardDavCredentials, setCardDavCredentials] = useState<AddressCredentialEnvelope[]>([]);
const [cardDavCredentialsError, setCardDavCredentialsError] = useState("");
const [syncInspector, setSyncInspector] = useState<SyncInspectorState>(null); const [syncInspector, setSyncInspector] = useState<SyncInspectorState>(null);
const [syncPlan, setSyncPlan] = useState<AddressSyncPlan | null>(null); const [syncPlan, setSyncPlan] = useState<AddressSyncPlan | null>(null);
const [syncDiagnostics, setSyncDiagnostics] = useState<AddressSyncDiagnostic[]>([]); const [syncDiagnostics, setSyncDiagnostics] = useState<AddressSyncDiagnostic[]>([]);
@@ -662,6 +673,37 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const canReadSync = hasScope(auth, "addresses:sync:read"); const canReadSync = hasScope(auth, "addresses:sync:read");
const canWriteSync = hasScope(auth, "addresses:sync:write"); const canWriteSync = hasScope(auth, "addresses:sync:write");
useEffect(() => {
if (!cardDavOpen || !canWriteSync) {
setCardDavCredentials([]);
setCardDavCredentialsError("");
return;
}
let active = true;
listAddressCredentials(settings)
.then((credentials) => {
if (active) {
setCardDavCredentials(credentials);
setCardDavCredentialsError("");
}
})
.catch((err) => {
if (active) {
setCardDavCredentials([]);
setCardDavCredentialsError(errorMessage(err));
}
});
return () => {
active = false;
};
}, [
canWriteSync,
cardDavOpen,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
useEffect(() => { useEffect(() => {
if (auth.groups_loaded || !onAuthChange) return; if (auth.groups_loaded || !onAuthChange) return;
let active = true; let active = true;
@@ -681,10 +723,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]); const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]);
const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]); const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]);
const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]); const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]);
const visibleContacts = useMemo( const visibleContacts = contacts;
() => selectedList ? contacts.filter((contact) => selectedListContactIds.has(contact.id)) : contacts,
[contacts, selectedList, selectedListContactIds]
);
const memberCandidateContacts = useMemo(() => { const memberCandidateContacts = useMemo(() => {
const normalizedQuery = memberQuery.trim().toLowerCase(); const normalizedQuery = memberQuery.trim().toLowerCase();
return memberCandidates return memberCandidates
@@ -948,10 +987,25 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const refreshContacts = useCallback(async (bookId: string, search: string) => { const refreshContacts = useCallback(async (bookId: string, search: string) => {
if (!bookId) { if (!bookId) {
setContacts([]); setContacts([]);
setContactTotal(0);
return; return;
} }
setContacts(await listContacts(settings, { addressBookId: bookId, query: search, limit: 500, includeDeleted: showArchived })); const response = await listContactsPage(settings, {
}, [settings, showArchived]); addressBookId: bookId,
addressListId: selectedListId || undefined,
query: search,
limit: contactPageSize,
offset: (contactPage - 1) * contactPageSize,
includeDeleted: showArchived
});
const pageCount = Math.max(1, Math.ceil(response.total / contactPageSize));
if (contactPage > pageCount) {
setContactPage(pageCount);
return;
}
setContacts(response.contacts);
setContactTotal(response.total);
}, [contactPage, contactPageSize, selectedListId, settings, showArchived]);
const refreshListEntries = useCallback(async (listId: string) => { const refreshListEntries = useCallback(async (listId: string) => {
if (!listId) { if (!listId) {
@@ -1126,12 +1180,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function openTreeNode(node: AddressTreeNode) { function openTreeNode(node: AddressTreeNode) {
if (node.kind === "book" && node.book) { if (node.kind === "book" && node.book) {
setContactPage(1);
setSelectedBookId(node.book.id); setSelectedBookId(node.book.id);
setSelectedListId(""); setSelectedListId("");
setSelectedContactId(""); setSelectedContactId("");
return; return;
} }
if (node.kind === "list" && node.list) { if (node.kind === "list" && node.list) {
setContactPage(1);
setSelectedBookId(node.list.address_book_id); setSelectedBookId(node.list.address_book_id);
setSelectedListId(node.list.id); setSelectedListId(node.list.id);
setSelectedContactId(""); setSelectedContactId("");
@@ -1513,16 +1569,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function openCardDavDialog() { function openCardDavDialog() {
setCardDavForm(EMPTY_CARDDAV_FORM); setCardDavForm(EMPTY_CARDDAV_FORM);
setCardDavDiscovery([]); setCardDavDiscovery([]);
setCardDavCredentialsError("");
setCardDavOpen(true); setCardDavOpen(true);
} }
function cardDavPayload() { function cardDavPayload() {
const credentialRef = cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id
? `credential-envelope:${cardDavForm.credential_envelope_id}`
: null;
return { return {
url: cardDavForm.collection_url, url: cardDavForm.collection_url,
auth_type: cardDavForm.auth_type, auth_type: cardDavForm.auth_type,
username: cardDavForm.username || null, username: cardDavForm.username || null,
password: cardDavForm.password || null, password: credentialRef ? null : cardDavForm.password || null,
bearer_token: cardDavForm.bearer_token || null bearer_token: credentialRef ? null : cardDavForm.bearer_token || null,
credential_ref: credentialRef
}; };
} }
@@ -1561,8 +1622,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
display_name: cardDavForm.display_name || selectedBook.name, display_name: cardDavForm.display_name || selectedBook.name,
auth_type: cardDavForm.auth_type, auth_type: cardDavForm.auth_type,
username: cardDavForm.username || null, username: cardDavForm.username || null,
password: cardDavForm.password || null, password: cardDavForm.credential_envelope_id ? null : cardDavForm.password || null,
bearer_token: cardDavForm.bearer_token || null, bearer_token: cardDavForm.credential_envelope_id ? null : cardDavForm.bearer_token || null,
credential_ref: cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id
? `credential-envelope:${cardDavForm.credential_envelope_id}`
: null,
sync_direction: cardDavForm.sync_direction, sync_direction: cardDavForm.sync_direction,
read_only: cardDavForm.sync_direction === "read_only" || cardDavForm.sync_direction === "import" read_only: cardDavForm.sync_direction === "read_only" || cardDavForm.sync_direction === "import"
}); });
@@ -1939,7 +2003,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
label="Show archived" label="Show archived"
checked={showArchived} checked={showArchived}
disabled={Boolean(toggleArchiveReason)} disabled={Boolean(toggleArchiveReason)}
onChange={() => setShowArchived((current) => !current)} onChange={() => {
setContactPage(1);
setShowArchived((current) => !current);
}}
help="Include archived address books, lists, and contacts." help="Include archived address books, lists, and contacts."
/> />
</div> </div>
@@ -1973,7 +2040,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<div> <div>
<h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2> <h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2>
<p> <p>
{visibleContacts.length} contact{visibleContacts.length === 1 ? "" : "s"} {contactTotal} contact{contactTotal === 1 ? "" : "s"}
{selectedList ? " in selected list" : selectedBook ? " in selected book" : ""} {selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}
</p> </p>
</div> </div>
@@ -1985,7 +2052,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div> </div>
</header> </header>
<div className="address-contact-toolbar"> <div className="address-contact-toolbar">
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search contacts" /> <input
value={query}
onChange={(event) => {
setContactPage(1);
setQuery(event.target.value);
}}
placeholder="Search contacts" />
{selectedBook?.read_only && <StatusBadge status="read-only" />} {selectedBook?.read_only && <StatusBadge status="read-only" />}
{selectedBook?.deleted_at && <StatusBadge status="archived" />} {selectedBook?.deleted_at && <StatusBadge status="archived" />}
</div> </div>
@@ -1997,6 +2070,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</SelectionList> </SelectionList>
} }
</div> </div>
<DataGridPaginationBar
page={contactPage}
pageSize={contactPageSize}
totalRows={contactTotal}
pageSizeOptions={[25, 50, 100, 200]}
disabled={loading || saving || !selectedBook}
className="address-contact-pagination"
ariaLabel="Contact pagination"
onPageChange={setContactPage}
onPageSizeChange={(pageSize) => {
setContactPageSize(pageSize);
setContactPage(1);
}} />
</section> </section>
<section className="address-detail-panel" aria-label="Contact detail"> <section className="address-detail-panel" aria-label="Contact detail">
@@ -2261,17 +2347,45 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<input value={cardDavForm.display_name} onChange={(event) => setCardDavForm((current) => ({ ...current, display_name: event.target.value }))} placeholder={selectedBook?.name ?? "CardDAV"} /> <input value={cardDavForm.display_name} onChange={(event) => setCardDavForm((current) => ({ ...current, display_name: event.target.value }))} placeholder={selectedBook?.name ?? "CardDAV"} />
</FormField> </FormField>
</div> </div>
{cardDavForm.auth_type !== "none" &&
<FormField label="Reusable credential">
<select
value={cardDavForm.credential_envelope_id}
onChange={(event) => {
const credentialId = event.target.value;
const credential = cardDavCredentials.find((item) => item.id === credentialId);
const username = credential?.public_data?.username;
setCardDavForm((current) => ({
...current,
credential_envelope_id: credentialId,
username: credentialId && username ? String(username) : "",
password: "",
bearer_token: ""
}));
}}>
<option value="">Enter source-specific credentials below</option>
{cardDavCredentials.map((credential) => (
<option key={credential.id} value={credential.id}>
{credential.name}{credential.public_data.username ? ` (${String(credential.public_data.username)})` : ""}
</option>
))}
</select>
</FormField>
}
{cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>}
{cardDavForm.auth_type === "basic" && {cardDavForm.auth_type === "basic" &&
<div className="form-grid two"> <div className="form-grid two">
<FormField label="Username"> <FormField label="Username">
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} /> <input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} disabled={Boolean(cardDavForm.credential_envelope_id)} />
</FormField>
<FormField label="Password">
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
</FormField> </FormField>
{!cardDavForm.credential_envelope_id &&
<FormField label="Password">
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
</FormField>
}
</div> </div>
} }
{cardDavForm.auth_type === "bearer" && {cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id &&
<FormField label="Bearer token"> <FormField label="Bearer token">
<PasswordField value={cardDavForm.bearer_token} onValueChange={(value) => setCardDavForm((current) => ({ ...current, bearer_token: value }))} autoComplete="new-password" /> <PasswordField value={cardDavForm.bearer_token} onValueChange={(value) => setCardDavForm((current) => ({ ...current, bearer_token: value }))} autoComplete="new-password" />
</FormField> </FormField>

View File

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

View File

@@ -308,6 +308,11 @@
align-content: start; align-content: start;
} }
.address-contact-pagination {
border-top: var(--border-line);
flex: 0 0 auto;
}
.address-contact-selection-list { .address-contact-selection-list {
gap: 2px; gap: 2px;
} }