Compare commits
5 Commits
75c9ece709
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bf1d7c9678 | |||
| 1545ea711e | |||
| eab24750f9 | |||
| 93dddbb8c5 | |||
| 04accaa206 |
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -13,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,
|
||||||
@@ -148,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"),
|
||||||
),
|
),
|
||||||
@@ -177,6 +179,7 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
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"],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ 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,
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user