3 Commits

8 changed files with 594 additions and 37 deletions

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

@@ -15,6 +15,14 @@ from govoplan_core.auth import ApiPrincipal
from govoplan_core.audit.logging import audit_event 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,
@@ -68,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]
@@ -693,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(
@@ -700,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,
@@ -720,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(
@@ -1131,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,
@@ -1618,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)
@@ -1629,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,
@@ -1683,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,
@@ -1694,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.
@@ -1724,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
@@ -1731,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."
) )
@@ -2277,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()}%"
@@ -2295,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

@@ -10,6 +10,10 @@ from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider 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,
@@ -63,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,
@@ -88,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,
) )
@@ -181,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)()
@@ -246,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()
@@ -464,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(
@@ -845,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,

View File

@@ -21,7 +21,7 @@
"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

@@ -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;
} }