Compare commits
15 Commits
b13e5760c8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bf1d7c9678 | |||
| 1545ea711e | |||
| eab24750f9 | |||
| 93dddbb8c5 | |||
| 04accaa206 | |||
| 75c9ece709 | |||
| d005040a8e | |||
| 613fb15a80 | |||
| 5dc9392290 | |||
| 7237679a85 | |||
| 5ff154bc64 | |||
| 3ec4b3c4ad | |||
| 70ee3c0148 | |||
| 5d560d4c58 | |||
| 8b4cf362ca |
@@ -43,6 +43,13 @@ inspection UI are implemented. The conflict review UI compares stored local and
|
||||
remote field payloads, can apply a stored remote vCard payload, and supports
|
||||
manual per-field local/remote merge choices.
|
||||
|
||||
API-managed CardDAV credentials are encrypted inside the source record. Source
|
||||
deletion physically removes that credential material and records a non-secret
|
||||
audit event in the same database transaction; destructive module retirement
|
||||
audits every remaining credential before the owning tables are dropped. Legacy
|
||||
external references are detached but are never sent to a secret provider for
|
||||
deletion because Addresses cannot prove that it owns them.
|
||||
|
||||
## Boundary
|
||||
|
||||
`govoplan-addresses` owns:
|
||||
|
||||
@@ -148,7 +148,12 @@ a conflict instead of silently overwriting remote data. The first conflict
|
||||
review UI compares stored local and remote field payloads and can apply a
|
||||
stored remote vCard payload or a manual per-field local/remote merge payload.
|
||||
Source disconnect/delete removes the source binding and related sync records
|
||||
while keeping local contacts.
|
||||
while keeping local contacts. Because API-managed CardDAV credentials are
|
||||
encrypted in the source row, the same transaction physically removes their
|
||||
ciphertext and emits non-secret credential-deletion audit evidence. Destructive
|
||||
module retirement audits all remaining owned credential material before table
|
||||
removal. An unowned legacy reference is detached rather than passed to an
|
||||
external secret provider.
|
||||
|
||||
## Connector Direction
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/addresses-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -18,7 +18,7 @@
|
||||
"README.md"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-addresses"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
description = "GovOPlaN reusable address and recipient-source module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"defusedxml>=0.7.1",
|
||||
"govoplan-core>=0.1.8",
|
||||
"govoplan-core>=0.1.11",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -8,6 +8,11 @@ from sqlalchemy import func
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.db.base import utcnow
|
||||
from govoplan_core.core.people import (
|
||||
PeopleSearchGroup,
|
||||
PersonSearchCandidate,
|
||||
person_selection_key,
|
||||
)
|
||||
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone
|
||||
from govoplan_addresses.backend.schemas import ContactCreateRequest
|
||||
from govoplan_addresses.backend.service import (
|
||||
@@ -137,6 +142,60 @@ class AddressesLookupCapability:
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
class AddressesPeopleSearchProvider:
|
||||
"""Adapt visible address-book contacts to the shared people-search contract."""
|
||||
|
||||
def __init__(self, lookup: AddressesLookupCapability | None = None) -> None:
|
||||
self._lookup = lookup or AddressesLookupCapability()
|
||||
|
||||
def search_people(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
) -> tuple[PeopleSearchGroup, ...]:
|
||||
if not hasattr(principal, "account_id") or not hasattr(principal, "tenant_id") or not hasattr(principal, "group_ids"):
|
||||
raise AddressBookError("Address contact search requires an authenticated tenant principal.")
|
||||
candidates = self._lookup.lookup(
|
||||
session,
|
||||
principal, # type: ignore[arg-type] - validated principal contract
|
||||
query=query,
|
||||
limit=limit,
|
||||
)
|
||||
return (
|
||||
PeopleSearchGroup(
|
||||
key="contacts",
|
||||
label="Contacts",
|
||||
candidates=tuple(
|
||||
PersonSearchCandidate(
|
||||
selection_key=person_selection_key("contact", item.contact_id, email=item.email),
|
||||
kind="contact",
|
||||
reference_id=item.contact_id,
|
||||
display_name=item.display_name,
|
||||
email=item.email,
|
||||
source_module="addresses",
|
||||
source_label="Contacts",
|
||||
source_ref=item.source_ref or f"addresses:contact:{item.contact_id}",
|
||||
source_revision=item.source_revision,
|
||||
description=" · ".join(part for part in (item.organization, item.role_title) if part) or None,
|
||||
provenance=dict(item.provenance),
|
||||
metadata={
|
||||
"address_book_id": item.address_book_id,
|
||||
"email_label": item.email_label,
|
||||
"organization": item.organization,
|
||||
"role_title": item.role_title,
|
||||
"tags": tuple(item.tags),
|
||||
"source_kind": item.source_kind,
|
||||
},
|
||||
)
|
||||
for item in candidates
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AddressesContactWriterCapability:
|
||||
def list_write_targets(
|
||||
self,
|
||||
@@ -301,6 +360,10 @@ def lookup_capability(_context: Any) -> AddressesLookupCapability:
|
||||
return AddressesLookupCapability()
|
||||
|
||||
|
||||
def people_search_capability(_context: Any) -> AddressesPeopleSearchProvider:
|
||||
return AddressesPeopleSearchProvider()
|
||||
|
||||
|
||||
def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapability:
|
||||
return AddressesRecipientSourceCapability()
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
from defusedxml import ElementTree as SafeElementTree
|
||||
from govoplan_core.security.outbound_http import (
|
||||
OutboundHttpError,
|
||||
bounded_response_bytes,
|
||||
build_outbound_http_opener,
|
||||
validate_outbound_http_url,
|
||||
)
|
||||
|
||||
|
||||
class AddressCardDAVError(RuntimeError):
|
||||
@@ -326,20 +332,31 @@ class AddressCardDAVClient:
|
||||
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
||||
url = validate_http_url(url)
|
||||
try:
|
||||
url = validate_outbound_http_url(url, label="CardDAV URL")
|
||||
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
|
||||
url,
|
||||
data=body,
|
||||
headers=dict(headers),
|
||||
method=method,
|
||||
)
|
||||
opener = urllib.request.build_opener(_SameOriginRedirectHandler(url))
|
||||
opener = build_outbound_http_opener(_SameOriginRedirectHandler(url))
|
||||
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CardDAV URL; redirects remain on origin. # nosec B310
|
||||
return response.status, dict(response.headers.items()), response.read()
|
||||
response_headers = dict(response.headers.items())
|
||||
return response.status, response_headers, bounded_response_bytes(
|
||||
response,
|
||||
headers=response_headers,
|
||||
label="CardDAV response",
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, dict(exc.headers.items()), exc.read()
|
||||
response_headers = dict(exc.headers.items())
|
||||
try:
|
||||
payload = bounded_response_bytes(exc, headers=response_headers, label="CardDAV error response")
|
||||
except OutboundHttpError as policy_exc:
|
||||
raise AddressCardDAVError(f"{method} {url} failed: {policy_exc}") from policy_exc
|
||||
return exc.code, response_headers, payload
|
||||
except urllib.error.URLError as exc:
|
||||
raise AddressCardDAVError(f"{method} {url} failed: {exc.reason}") from exc
|
||||
except ValueError as exc:
|
||||
except (OutboundHttpError, ValueError) as exc:
|
||||
raise AddressCardDAVError(f"{method} {url} failed: {exc}") from exc
|
||||
|
||||
|
||||
@@ -493,7 +510,8 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
del fp, msg, headers
|
||||
try:
|
||||
candidate = validate_http_url(newurl)
|
||||
except AddressCardDAVError:
|
||||
candidate = validate_outbound_http_url(candidate, label="CardDAV redirect URL")
|
||||
except (AddressCardDAVError, OutboundHttpError):
|
||||
return None
|
||||
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
|
||||
return None
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from govoplan_addresses.backend.capabilities import (
|
||||
CAPABILITY_ADDRESSES_CONTACT_WRITER,
|
||||
CAPABILITY_ADDRESSES_LOOKUP,
|
||||
@@ -10,6 +13,7 @@ from govoplan_addresses.backend.capabilities import (
|
||||
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -25,6 +29,47 @@ from govoplan_core.core.modules import (
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
_addresses_table_retirement_provider = drop_table_retirement_provider(
|
||||
addresses_models.AddressSyncDiagnostic,
|
||||
addresses_models.AddressSyncConflict,
|
||||
addresses_models.AddressSyncTombstone,
|
||||
addresses_models.AddressSyncSource,
|
||||
addresses_models.AddressListEntry,
|
||||
addresses_models.AddressList,
|
||||
addresses_models.ContactPostalAddress,
|
||||
addresses_models.ContactPhone,
|
||||
addresses_models.ContactEmail,
|
||||
addresses_models.Contact,
|
||||
addresses_models.AddressBook,
|
||||
label="Addresses",
|
||||
)
|
||||
|
||||
|
||||
def _addresses_retirement_provider(session: object | None, module_id: str):
|
||||
plan = _addresses_table_retirement_provider(session, module_id)
|
||||
base_executor = plan.destroy_data_executor
|
||||
if base_executor is None:
|
||||
return plan
|
||||
|
||||
def executor(execute_session: object, execute_module_id: str) -> None:
|
||||
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
|
||||
raise RuntimeError("No database session is available for Addresses credential retirement.")
|
||||
if inspect(execute_session.get_bind()).has_table(addresses_models.AddressSyncSource.__tablename__):
|
||||
from govoplan_addresses.backend.service import audit_address_credentials_for_retirement
|
||||
|
||||
audit_address_credentials_for_retirement(execute_session)
|
||||
base_executor(execute_session, execute_module_id)
|
||||
|
||||
return replace(
|
||||
plan,
|
||||
destroy_data_warnings=(
|
||||
*plan.destroy_data_warnings,
|
||||
"Addresses-owned encrypted connector credentials are audited and deleted with the sync-source table.",
|
||||
),
|
||||
destroy_data_executor=executor,
|
||||
)
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
@@ -104,11 +149,12 @@ def _addresses_router(_context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="addresses",
|
||||
name="Addresses",
|
||||
version="0.1.8",
|
||||
version="0.1.9",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
|
||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
|
||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"),
|
||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
|
||||
),
|
||||
@@ -128,24 +174,12 @@ manifest = ModuleManifest(
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
addresses_models.AddressSyncDiagnostic,
|
||||
addresses_models.AddressSyncConflict,
|
||||
addresses_models.AddressSyncTombstone,
|
||||
addresses_models.AddressSyncSource,
|
||||
addresses_models.AddressListEntry,
|
||||
addresses_models.AddressList,
|
||||
addresses_models.ContactPostalAddress,
|
||||
addresses_models.ContactPhone,
|
||||
addresses_models.ContactEmail,
|
||||
addresses_models.Contact,
|
||||
addresses_models.AddressBook,
|
||||
label="Addresses",
|
||||
),
|
||||
retirement_provider=_addresses_retirement_provider,
|
||||
retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.",
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["lookup_capability"]).lookup_capability(context),
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["people_search_capability"]).people_search_capability(context),
|
||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE: lambda context: __import__(
|
||||
"govoplan_addresses.backend.capabilities",
|
||||
fromlist=["recipient_source_capability"],
|
||||
|
||||
@@ -41,6 +41,8 @@ from govoplan_addresses.backend.schemas import (
|
||||
AddressCardDavDiscoveryRequest,
|
||||
AddressCardDavDiscoveryResponse,
|
||||
AddressCardDavSourceCreateRequest,
|
||||
AddressCredentialEnvelopeListResponse,
|
||||
AddressCredentialEnvelopeResponse,
|
||||
AddressSyncAttemptFinishRequest,
|
||||
AddressSyncConflictCreateRequest,
|
||||
AddressSyncConflictListResponse,
|
||||
@@ -69,6 +71,7 @@ from govoplan_addresses.backend.schemas import (
|
||||
)
|
||||
from govoplan_addresses.backend.service import (
|
||||
AddressBookError,
|
||||
available_address_credentials,
|
||||
address_book_contact_counts,
|
||||
address_list_entry_counts,
|
||||
create_address_book,
|
||||
@@ -77,6 +80,7 @@ from govoplan_addresses.backend.service import (
|
||||
create_carddav_sync_source,
|
||||
create_contact,
|
||||
create_sync_source,
|
||||
count_contacts,
|
||||
delete_address_book,
|
||||
delete_address_list,
|
||||
delete_address_list_entry,
|
||||
@@ -103,6 +107,7 @@ from govoplan_addresses.backend.service import (
|
||||
restore_contact,
|
||||
resolve_sync_conflict,
|
||||
preview_sync_source,
|
||||
public_address_sync_metadata,
|
||||
run_sync_source,
|
||||
start_sync_attempt,
|
||||
update_address_book,
|
||||
@@ -217,7 +222,7 @@ def _sync_source_response(sync_source: AddressSyncSource) -> AddressSyncSourceRe
|
||||
"last_success_at": sync_source.last_success_at,
|
||||
"last_error": sync_source.last_error,
|
||||
"last_diagnostic": sync_source.last_diagnostic,
|
||||
"metadata": sync_source.metadata_ or {},
|
||||
"metadata": public_address_sync_metadata(sync_source.metadata_),
|
||||
"created_at": sync_source.created_at,
|
||||
"updated_at": sync_source.updated_at,
|
||||
}
|
||||
@@ -426,16 +431,41 @@ def api_restore_address_book(
|
||||
@router.get("/contacts", response_model=ContactListResponse)
|
||||
def api_list_contacts(
|
||||
address_book_id: str | None = Query(default=None),
|
||||
address_list_id: str | None = Query(default=None),
|
||||
query: str | None = Query(default=None),
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
include_deleted: bool = Query(default=False),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "addresses:contact:read")
|
||||
try:
|
||||
contacts = list_contacts(session, principal, address_book_id=address_book_id, query=query, limit=limit, include_deleted=include_deleted)
|
||||
return ContactListResponse(contacts=[_contact_response(contact) for contact in contacts])
|
||||
contacts = list_contacts(
|
||||
session,
|
||||
principal,
|
||||
address_book_id=address_book_id,
|
||||
address_list_id=address_list_id,
|
||||
query=query,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
total = count_contacts(
|
||||
session,
|
||||
principal,
|
||||
address_book_id=address_book_id,
|
||||
address_list_id=address_list_id,
|
||||
query=query,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
return ContactListResponse(
|
||||
contacts=[_contact_response(contact) for contact in contacts],
|
||||
total=total,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
has_more=offset + len(contacts) < total,
|
||||
)
|
||||
except AddressBookError as exc:
|
||||
raise _error(exc) from exc
|
||||
|
||||
@@ -638,6 +668,25 @@ def api_discover_carddav_address_books(
|
||||
raise _error(AddressBookError(str(exc))) from exc
|
||||
|
||||
|
||||
@router.get("/credentials", response_model=AddressCredentialEnvelopeListResponse)
|
||||
def api_list_address_credentials(
|
||||
source_id: str | None = Query(default=None),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "addresses:sync:write")
|
||||
return AddressCredentialEnvelopeListResponse(
|
||||
credentials=[
|
||||
AddressCredentialEnvelopeResponse.model_validate(item)
|
||||
for item in available_address_credentials(
|
||||
session,
|
||||
principal,
|
||||
source_id=source_id,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/address-books/{book_id}/carddav/sources", response_model=AddressSyncSourceResponse, status_code=status.HTTP_201_CREATED)
|
||||
def api_create_carddav_sync_source(
|
||||
book_id: str,
|
||||
|
||||
@@ -221,6 +221,10 @@ class ContactResponse(BaseModel):
|
||||
|
||||
class ContactListResponse(BaseModel):
|
||||
contacts: list[ContactResponse]
|
||||
total: int
|
||||
offset: int
|
||||
limit: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
class AddressLookupResponse(BaseModel):
|
||||
@@ -439,6 +443,26 @@ class AddressCardDavDiscoveryResponse(BaseModel):
|
||||
address_books: list[AddressCardDavAddressBookResponse]
|
||||
|
||||
|
||||
class AddressCredentialEnvelopeResponse(BaseModel):
|
||||
id: str
|
||||
scope_type: str
|
||||
scope_id: str | None = None
|
||||
name: str
|
||||
description: str | None = None
|
||||
credential_kind: str
|
||||
public_data: dict[str, Any] = Field(default_factory=dict)
|
||||
secret_keys: list[str] = Field(default_factory=list)
|
||||
secret_configured: bool = False
|
||||
allowed_modules: list[str] = Field(default_factory=list)
|
||||
inherit_to_lower_scopes: bool = False
|
||||
is_active: bool = True
|
||||
revision: str
|
||||
|
||||
|
||||
class AddressCredentialEnvelopeListResponse(BaseModel):
|
||||
credentials: list[AddressCredentialEnvelopeResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AddressCardDavSourceCreateRequest(BaseModel):
|
||||
collection_url: str = Field(min_length=1, max_length=2000)
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.base import utcnow
|
||||
from govoplan_core.security.credential_envelopes import (
|
||||
CredentialEnvelope,
|
||||
create_credential_envelope,
|
||||
)
|
||||
from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult
|
||||
from govoplan_addresses.backend.capabilities import (
|
||||
CAPABILITY_ADDRESSES_CONTACT_WRITER,
|
||||
@@ -15,6 +21,7 @@ from govoplan_addresses.backend.capabilities import (
|
||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
||||
AddressesContactWriterCapability,
|
||||
AddressesLookupCapability,
|
||||
AddressesPeopleSearchProvider,
|
||||
AddressesRecipientSourceCapability,
|
||||
AddressWriterError,
|
||||
)
|
||||
@@ -33,6 +40,7 @@ from govoplan_addresses.backend.db.models import (
|
||||
)
|
||||
from govoplan_addresses.backend.schemas import (
|
||||
AddressBookCreateRequest,
|
||||
AddressCardDavDiscoveryRequest,
|
||||
AddressListCreateRequest,
|
||||
AddressListEntryCreateRequest,
|
||||
AddressCardDavSourceCreateRequest,
|
||||
@@ -48,6 +56,7 @@ from govoplan_addresses.backend.schemas import (
|
||||
ContactPostalAddressPayload,
|
||||
)
|
||||
from govoplan_addresses.backend.manifest import manifest
|
||||
from govoplan_addresses.backend.router import _sync_source_response
|
||||
from govoplan_addresses.backend.service import (
|
||||
AddressBookError,
|
||||
address_book_contact_counts,
|
||||
@@ -58,9 +67,11 @@ from govoplan_addresses.backend.service import (
|
||||
create_carddav_sync_source,
|
||||
create_contact,
|
||||
create_sync_source,
|
||||
count_contacts,
|
||||
delete_address_list_entry,
|
||||
delete_contact,
|
||||
delete_sync_source,
|
||||
discover_carddav_address_books,
|
||||
export_address_book_vcard,
|
||||
import_vcards,
|
||||
list_address_list_entries,
|
||||
@@ -81,6 +92,8 @@ from govoplan_addresses.backend.service import (
|
||||
start_sync_attempt,
|
||||
finish_sync_attempt,
|
||||
update_sync_source,
|
||||
resolve_trusted_deployment_carddav_credential_ref,
|
||||
_carddav_client_for_source,
|
||||
)
|
||||
|
||||
|
||||
@@ -174,6 +187,7 @@ class AddressServiceTest(unittest.TestCase):
|
||||
AddressSyncConflict.__table__,
|
||||
AddressSyncDiagnostic.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
CredentialEnvelope.__table__,
|
||||
],
|
||||
)
|
||||
self.session = sessionmaker(bind=self.engine)()
|
||||
@@ -239,6 +253,50 @@ class AddressServiceTest(unittest.TestCase):
|
||||
self.session.commit()
|
||||
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
|
||||
|
||||
def test_contact_windows_report_exact_totals(self) -> None:
|
||||
book = create_address_book(
|
||||
self.session,
|
||||
self.principal,
|
||||
AddressBookCreateRequest(scope_type="user", name="Paged"),
|
||||
)
|
||||
self.session.flush()
|
||||
contacts = [
|
||||
create_contact(
|
||||
self.session,
|
||||
self.principal,
|
||||
book.id,
|
||||
ContactCreateRequest(
|
||||
display_name=name,
|
||||
emails=[ContactEmailPayload(email=f"{name.lower()}@example.local")],
|
||||
),
|
||||
)
|
||||
for name in ("Ada", "Barbara", "Claude", "Dorothy", "Edsger")
|
||||
]
|
||||
self.session.commit()
|
||||
|
||||
page = list_contacts(
|
||||
self.session,
|
||||
self.principal,
|
||||
address_book_id=book.id,
|
||||
limit=2,
|
||||
offset=2,
|
||||
)
|
||||
|
||||
self.assertEqual([contact.id for contact in page], [contacts[2].id, contacts[3].id])
|
||||
self.assertEqual(
|
||||
count_contacts(self.session, self.principal, address_book_id=book.id),
|
||||
5,
|
||||
)
|
||||
self.assertEqual(
|
||||
count_contacts(
|
||||
self.session,
|
||||
self.principal,
|
||||
address_book_id=book.id,
|
||||
query="example.local",
|
||||
),
|
||||
5,
|
||||
)
|
||||
|
||||
def test_vcard_import_and_export_preserves_common_fields(self) -> None:
|
||||
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported"))
|
||||
self.session.commit()
|
||||
@@ -340,8 +398,17 @@ END:VCARD
|
||||
self.assertEqual(len(lookup), 1)
|
||||
self.assertEqual(lookup[0].email, "ada@example.local")
|
||||
self.assertEqual(lookup[0].contact_id, contact.id)
|
||||
people_provider = AddressesPeopleSearchProvider()
|
||||
self.assertIsInstance(people_provider, PeopleSearchProvider)
|
||||
people = people_provider.search_people(self.session, self.principal, query="ada")
|
||||
self.assertEqual(people[0].key, "contacts")
|
||||
self.assertEqual(people[0].candidates[0].reference_id, contact.id)
|
||||
self.assertEqual(people[0].candidates[0].source_ref, f"addresses:contact:{contact.id}")
|
||||
self.assertEqual(people[0].candidates[0].metadata["address_book_id"], book.id)
|
||||
warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10)
|
||||
self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"])
|
||||
self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, manifest.capability_factories)
|
||||
self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
|
||||
|
||||
recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal)
|
||||
self.assertEqual(len(recipient_sources), 1)
|
||||
@@ -448,6 +515,27 @@ END:VCARD
|
||||
self.assertEqual(entries[0].contact_email.email, "ada.private@example.local")
|
||||
self.assertEqual(entries[1].target_kind, "postal_address")
|
||||
self.assertEqual(entries[1].contact_postal_address.locality, "Berlin")
|
||||
self.assertEqual(
|
||||
[
|
||||
item.id
|
||||
for item in list_contacts(
|
||||
self.session,
|
||||
self.principal,
|
||||
address_book_id=book.id,
|
||||
address_list_id=address_list.id,
|
||||
)
|
||||
],
|
||||
[contact.id],
|
||||
)
|
||||
self.assertEqual(
|
||||
count_contacts(
|
||||
self.session,
|
||||
self.principal,
|
||||
address_book_id=book.id,
|
||||
address_list_id=address_list.id,
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "same address book"):
|
||||
create_address_list_entry(
|
||||
@@ -787,6 +875,156 @@ END:VCARD
|
||||
self.assertEqual(contacts[0].display_name, "Ada Remote")
|
||||
self.assertEqual(contacts[0].emails[0].email, "ada.remote@example.local")
|
||||
|
||||
def test_carddav_credentials_are_server_managed_and_public_metadata_is_redacted(self) -> None:
|
||||
book = create_address_book(
|
||||
self.session,
|
||||
self.principal,
|
||||
AddressBookCreateRequest(scope_type="user", name="Secured remote"),
|
||||
)
|
||||
self.session.commit()
|
||||
self.session.refresh(book)
|
||||
|
||||
with self.assertRaisesRegex(AddressBookError, "Caller-supplied credential references"):
|
||||
create_carddav_sync_source(
|
||||
self.session,
|
||||
self.principal,
|
||||
book.id,
|
||||
AddressCardDavSourceCreateRequest(
|
||||
collection_url="https://attacker.example.test/addressbooks/personal/",
|
||||
auth_type="bearer",
|
||||
credential_ref="env:MASTER_KEY_B64",
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(AddressBookError, "Caller-supplied credential references"):
|
||||
discover_carddav_address_books(
|
||||
self.session,
|
||||
self.principal,
|
||||
AddressCardDavDiscoveryRequest(
|
||||
url="https://attacker.example.test/addressbooks/",
|
||||
auth_type="bearer",
|
||||
credential_ref="env:MASTER_KEY_B64",
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(AddressBookError, "server-managed"):
|
||||
create_sync_source(
|
||||
self.session,
|
||||
self.principal,
|
||||
book.id,
|
||||
AddressSyncSourceCreateRequest(
|
||||
connector_type="carddav",
|
||||
display_name="Injected",
|
||||
metadata={"carddav": {"auth_type": "bearer", "credential_ref": "env:DATABASE_URL"}},
|
||||
),
|
||||
)
|
||||
|
||||
def test_carddav_source_can_use_reusable_core_credential(self) -> None:
|
||||
book = create_address_book(
|
||||
self.session,
|
||||
self.principal,
|
||||
AddressBookCreateRequest(scope_type="user", name="Shared credential"),
|
||||
)
|
||||
credential = create_credential_envelope(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Shared DAV login",
|
||||
credential_kind="username_password",
|
||||
public_data={"username": "ada"},
|
||||
secret_data={"password": "secret"},
|
||||
allowed_modules=["addresses"],
|
||||
inherit_to_lower_scopes=True,
|
||||
)
|
||||
source = create_carddav_sync_source(
|
||||
self.session,
|
||||
self.principal,
|
||||
book.id,
|
||||
AddressCardDavSourceCreateRequest(
|
||||
collection_url="https://dav.example.test/addressbooks/personal/",
|
||||
auth_type="basic",
|
||||
credential_ref=f"credential-envelope:{credential.id}",
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
client = _carddav_client_for_source(self.session, source)
|
||||
response_auth = _sync_source_response(source).metadata["carddav"]
|
||||
|
||||
self.assertEqual(client.username, "ada")
|
||||
self.assertEqual(client.password, "secret")
|
||||
self.assertEqual(response_auth["credential_envelope_id"], credential.id)
|
||||
self.assertTrue(response_auth["has_credential"])
|
||||
|
||||
source = create_carddav_sync_source(
|
||||
self.session,
|
||||
self.principal,
|
||||
book.id,
|
||||
AddressCardDavSourceCreateRequest(
|
||||
collection_url="https://dav.example.test/addressbooks/personal/",
|
||||
auth_type="basic",
|
||||
username="ada",
|
||||
password="secret",
|
||||
),
|
||||
)
|
||||
stored_auth = dict((source.metadata_ or {})["carddav"])
|
||||
self.assertIn("secret_encrypted", stored_auth)
|
||||
self.assertNotEqual(stored_auth["secret_encrypted"], "secret")
|
||||
|
||||
response_auth = _sync_source_response(source).metadata["carddav"]
|
||||
self.assertNotIn("secret_encrypted", response_auth)
|
||||
self.assertNotIn("credential_ref", response_auth)
|
||||
self.assertTrue(response_auth["has_credential"])
|
||||
|
||||
with self.assertRaisesRegex(AddressBookError, "server-managed"):
|
||||
update_sync_source(
|
||||
self.session,
|
||||
self.principal,
|
||||
source.id,
|
||||
AddressSyncSourceUpdateRequest(
|
||||
metadata={"carddav": {"auth_type": "bearer", "secret_encrypted": "copied-ciphertext"}}
|
||||
),
|
||||
)
|
||||
|
||||
def test_legacy_carddav_env_reference_is_not_resolved_by_runtime_sync(self) -> None:
|
||||
book = create_address_book(
|
||||
self.session,
|
||||
self.principal,
|
||||
AddressBookCreateRequest(scope_type="user", name="Legacy remote"),
|
||||
)
|
||||
self.session.commit()
|
||||
self.session.refresh(book)
|
||||
source = create_sync_source(
|
||||
self.session,
|
||||
self.principal,
|
||||
book.id,
|
||||
AddressSyncSourceCreateRequest(
|
||||
connector_type="carddav",
|
||||
display_name="Legacy",
|
||||
external_address_book_ref="https://attacker.example.test/addressbooks/personal/",
|
||||
),
|
||||
)
|
||||
source.metadata_ = {
|
||||
"carddav": {
|
||||
"auth_type": "bearer",
|
||||
"credential_ref": "env:MASTER_KEY_B64",
|
||||
}
|
||||
}
|
||||
|
||||
with patch.dict("os.environ", {"MASTER_KEY_B64": "must-not-leave-process"}), self.assertRaisesRegex(
|
||||
AddressBookError,
|
||||
"not a server-owned credential",
|
||||
):
|
||||
run_sync_source(self.session, self.principal, source.id)
|
||||
|
||||
def test_trusted_deployment_carddav_env_resolution_is_explicit(self) -> None:
|
||||
with patch.dict("os.environ", {"CARDDAV_DEPLOYMENT_TOKEN": "trusted-token"}):
|
||||
self.assertEqual(
|
||||
resolve_trusted_deployment_carddav_credential_ref("env:CARDDAV_DEPLOYMENT_TOKEN"),
|
||||
"trusted-token",
|
||||
)
|
||||
with self.assertRaisesRegex(AddressBookError, "must use the env: prefix"):
|
||||
resolve_trusted_deployment_carddav_credential_ref("vault:token")
|
||||
|
||||
def test_carddav_two_way_pushes_local_creates_updates_and_deletes(self) -> None:
|
||||
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Writable Remote"))
|
||||
self.session.commit()
|
||||
@@ -934,6 +1172,107 @@ END:VCARD
|
||||
self.assertFalse(book.read_only)
|
||||
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
|
||||
|
||||
def test_delete_sync_source_deletes_and_audits_encrypted_credential_atomically(self) -> None:
|
||||
book = create_address_book(
|
||||
self.session,
|
||||
self.principal,
|
||||
AddressBookCreateRequest(scope_type="user", name="Audited remote"),
|
||||
)
|
||||
self.session.commit()
|
||||
self.session.refresh(book)
|
||||
source = create_carddav_sync_source(
|
||||
self.session,
|
||||
self.principal,
|
||||
book.id,
|
||||
AddressCardDavSourceCreateRequest(
|
||||
collection_url="https://carddav.example.local/addressbooks/audited/",
|
||||
auth_type="basic",
|
||||
username="ada",
|
||||
password="do-not-audit-this",
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
source_id = source.id
|
||||
|
||||
with patch("govoplan_addresses.backend.service.audit_event") as audit:
|
||||
delete_sync_source(self.session, self.principal, source_id)
|
||||
self.session.commit()
|
||||
|
||||
self.assertIsNone(self.session.get(AddressSyncSource, source_id))
|
||||
audit.assert_called_once()
|
||||
audit_call = audit.call_args.kwargs
|
||||
self.assertEqual(audit_call["action"], "addresses.sync_credential_deleted")
|
||||
self.assertEqual(audit_call["object_id"], source_id)
|
||||
self.assertEqual(audit_call["details"]["storage_backend"], "encrypted_database")
|
||||
self.assertNotIn("credential_ref", audit_call["details"])
|
||||
self.assertNotIn("secret_encrypted", audit_call["details"])
|
||||
self.assertNotIn("do-not-audit-this", repr(audit_call))
|
||||
|
||||
def test_delete_sync_source_is_not_staged_when_credential_audit_fails(self) -> None:
|
||||
book = create_address_book(
|
||||
self.session,
|
||||
self.principal,
|
||||
AddressBookCreateRequest(scope_type="user", name="Audit failure"),
|
||||
)
|
||||
self.session.commit()
|
||||
self.session.refresh(book)
|
||||
source = create_carddav_sync_source(
|
||||
self.session,
|
||||
self.principal,
|
||||
book.id,
|
||||
AddressCardDavSourceCreateRequest(
|
||||
collection_url="https://carddav.example.local/addressbooks/audit-failure/",
|
||||
auth_type="bearer",
|
||||
bearer_token="do-not-audit-this",
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
with patch(
|
||||
"govoplan_addresses.backend.service.audit_event",
|
||||
side_effect=RuntimeError("audit unavailable"),
|
||||
), self.assertRaisesRegex(RuntimeError, "audit unavailable"):
|
||||
delete_sync_source(self.session, self.principal, source.id)
|
||||
|
||||
self.session.rollback()
|
||||
persisted = self.session.get(AddressSyncSource, source.id)
|
||||
self.assertIsNotNone(persisted)
|
||||
self.assertIn("secret_encrypted", (persisted.metadata_ or {})["carddav"])
|
||||
|
||||
def test_destructive_module_retirement_audits_credentials_before_tables_are_dropped(self) -> None:
|
||||
book = create_address_book(
|
||||
self.session,
|
||||
self.principal,
|
||||
AddressBookCreateRequest(scope_type="user", name="Retired module"),
|
||||
)
|
||||
self.session.commit()
|
||||
self.session.refresh(book)
|
||||
source = create_carddav_sync_source(
|
||||
self.session,
|
||||
self.principal,
|
||||
book.id,
|
||||
AddressCardDavSourceCreateRequest(
|
||||
collection_url="https://carddav.example.local/addressbooks/module-retirement/",
|
||||
auth_type="bearer",
|
||||
bearer_token="do-not-audit-this",
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
source_id = source.id
|
||||
retirement_provider = manifest.migration_spec.retirement_provider
|
||||
assert retirement_provider is not None
|
||||
plan = retirement_provider(self.session, "addresses")
|
||||
assert plan.destroy_data_executor is not None
|
||||
|
||||
with patch("govoplan_addresses.backend.service.audit_event") as audit:
|
||||
plan.destroy_data_executor(self.session, "addresses")
|
||||
|
||||
audit.assert_called_once()
|
||||
audit_call = audit.call_args.kwargs
|
||||
self.assertEqual(audit_call["object_id"], source_id)
|
||||
self.assertEqual(audit_call["details"]["deletion_reason"], "module_data_retired")
|
||||
self.assertNotIn("do-not-audit-this", repr(audit_call))
|
||||
self.assertFalse(inspect(self.engine).has_table("addresses_sync_sources"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -37,6 +37,22 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
|
||||
|
||||
|
||||
class CardDAVUrlSecurityTests(unittest.TestCase):
|
||||
def test_transport_revalidates_dns_at_connection_time(self) -> None:
|
||||
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
|
||||
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
|
||||
), patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
side_effect=(public, private),
|
||||
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
|
||||
AddressCardDAVError,
|
||||
"non-public network",
|
||||
):
|
||||
urllib_transport("GET", "https://dav.example.test/contact.vcf", {}, None, 2)
|
||||
socket_factory.assert_not_called()
|
||||
|
||||
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
|
||||
base_url = "https://dav.example.test/addressbooks/ada/"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/addresses-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -13,12 +13,15 @@
|
||||
},
|
||||
"./styles/addresses.css": "./src/styles/addresses.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:ui-structure": "node scripts/test-selection-list-structure.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react-router-dom": ">=7.18.2 <8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
21
webui/scripts/test-selection-list-structure.mjs
Normal file
21
webui/scripts/test-selection-list-structure.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const pagePath = fileURLToPath(new URL("../src/features/addressbook/AddressBookPage.tsx", import.meta.url));
|
||||
const stylesPath = fileURLToPath(new URL("../src/styles/addresses.css", import.meta.url));
|
||||
const page = readFileSync(pagePath, "utf8");
|
||||
const styles = readFileSync(stylesPath, "utf8");
|
||||
|
||||
assert.match(page, /SegmentedControl,[\s\S]*SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
|
||||
assert.match(page, /<SelectionList label="Contacts" className="address-contact-selection-list">/);
|
||||
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selected\}[\s\S]*className=\{`address-contact-row/);
|
||||
assert.match(page, /draggable=\{!contact\.deleted_at && !saving\}/);
|
||||
assert.match(page, /<SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">/);
|
||||
assert.match(page, /selected=\{cardDavForm\.collection_url === item\.collection_url\}/);
|
||||
assert.match(page, /<SegmentedControl<ConflictMergeChoice>[\s\S]*role="group"[\s\S]*value=\{conflictMergeChoices\[row\.field\] \?\? "local"\}/);
|
||||
assert.doesNotMatch(page, /<button[\s\S]{0,160}(?:address-contact-row|address-sync-result-row)/);
|
||||
assert.doesNotMatch(styles, /\.address-conflict-choice button/);
|
||||
assert.doesNotMatch(styles, /\.address-contact-row:(?:hover|focus-visible)/);
|
||||
|
||||
console.log("Address-book flat selections use central components.");
|
||||
@@ -168,8 +168,12 @@ type AddressListEntryListResponse = {
|
||||
entries: AddressListEntry[];
|
||||
};
|
||||
|
||||
type ContactListResponse = {
|
||||
export type ContactListResponse = {
|
||||
contacts: Contact[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
type AddressBookWriteTargetsResponse = {
|
||||
@@ -216,6 +220,22 @@ export type AddressCardDavAddressBook = {
|
||||
sync_token?: string | null;
|
||||
};
|
||||
|
||||
export type AddressCredentialEnvelope = {
|
||||
id: string;
|
||||
scope_type: string;
|
||||
scope_id?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
credential_kind: string;
|
||||
public_data: Record<string, unknown>;
|
||||
secret_keys: string[];
|
||||
secret_configured: boolean;
|
||||
allowed_modules: string[];
|
||||
inherit_to_lower_scopes: boolean;
|
||||
is_active: boolean;
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export type AddressSyncPlanStats = {
|
||||
created: number;
|
||||
updated: number;
|
||||
@@ -305,6 +325,10 @@ type AddressCardDavDiscoveryResponse = {
|
||||
address_books: AddressCardDavAddressBook[];
|
||||
};
|
||||
|
||||
type AddressCredentialEnvelopeListResponse = {
|
||||
credentials: AddressCredentialEnvelope[];
|
||||
};
|
||||
|
||||
type AddressSyncDiagnosticListResponse = {
|
||||
diagnostics: AddressSyncDiagnostic[];
|
||||
};
|
||||
@@ -450,7 +474,14 @@ export async function listAddressSyncSources(
|
||||
|
||||
export function discoverCardDavAddressBooks(
|
||||
settings: ApiSettings,
|
||||
payload: { url: string; auth_type: "none" | "basic" | "bearer"; username?: string | null; password?: string | null; bearer_token?: string | null; credential_ref?: string | null }
|
||||
payload: {
|
||||
url: string;
|
||||
auth_type: "none" | "basic" | "bearer";
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
bearer_token?: string | null;
|
||||
credential_ref?: string | null;
|
||||
}
|
||||
): Promise<AddressCardDavAddressBook[]> {
|
||||
return apiFetch<AddressCardDavDiscoveryResponse>(settings, "/api/v1/addresses/carddav/discover", {
|
||||
method: "POST",
|
||||
@@ -482,6 +513,17 @@ export function createCardDavSyncSource(
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAddressCredentials(
|
||||
settings: ApiSettings,
|
||||
sourceId?: string | null
|
||||
): Promise<AddressCredentialEnvelope[]> {
|
||||
const response = await apiFetch<AddressCredentialEnvelopeListResponse>(
|
||||
settings,
|
||||
`/api/v1/addresses/credentials${queryString({ source_id: sourceId })}`
|
||||
);
|
||||
return response.credentials;
|
||||
}
|
||||
|
||||
export function updateAddressSyncSource(
|
||||
settings: ApiSettings,
|
||||
syncSourceId: string,
|
||||
@@ -550,11 +592,15 @@ export function resolveAddressSyncConflict(
|
||||
});
|
||||
}
|
||||
|
||||
export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;query?: string | null;limit?: number;includeDeleted?: boolean;} = {}): Promise<Contact[]> {
|
||||
const response = await apiFetch<ContactListResponse>(
|
||||
export function listContactsPage(settings: ApiSettings, options: {addressBookId?: string | null;addressListId?: string | null;query?: string | null;limit?: number;offset?: number;includeDeleted?: boolean;} = {}): Promise<ContactListResponse> {
|
||||
return apiFetch<ContactListResponse>(
|
||||
settings,
|
||||
`/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, query: options.query, limit: options.limit, include_deleted: options.includeDeleted ? "true" : null })}`
|
||||
`/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, address_list_id: options.addressListId, query: options.query, limit: options.limit, offset: options.offset, include_deleted: options.includeDeleted ? "true" : null })}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;addressListId?: string | null;query?: string | null;limit?: number;offset?: number;includeDeleted?: boolean;} = {}): Promise<Contact[]> {
|
||||
const response = await listContactsPage(settings, options);
|
||||
return response.contacts;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type ButtonHTMLAttributes, type DragEvent as ReactDragEvent, type FormEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
|
||||
import {
|
||||
ApiError,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DataGridPaginationBar,
|
||||
Dialog,
|
||||
DisabledActionTooltip,
|
||||
DismissibleAlert,
|
||||
ExplorerTree,
|
||||
fetchAuthGroups,
|
||||
formatDateTime,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
PasswordField,
|
||||
SegmentedControl,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type AuthUpdate
|
||||
type AuthUpdate,
|
||||
type FormatDateTimeOptions
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createAddressBook,
|
||||
@@ -33,6 +40,7 @@ import {
|
||||
exportContactVcard,
|
||||
importAddressBookVcards,
|
||||
listAddressBooks,
|
||||
listAddressCredentials,
|
||||
listAddressListEntries,
|
||||
listAddressLists,
|
||||
listAddressSyncConflicts,
|
||||
@@ -40,6 +48,7 @@ import {
|
||||
listAddressSyncSources,
|
||||
listAddressSyncTombstones,
|
||||
listContacts,
|
||||
listContactsPage,
|
||||
previewAddressSyncSource,
|
||||
restoreAddressBook,
|
||||
restoreAddressList,
|
||||
@@ -53,6 +62,7 @@ import {
|
||||
type AddressCardDavAddressBook,
|
||||
type AddressBook,
|
||||
type AddressBookScope,
|
||||
type AddressCredentialEnvelope,
|
||||
type AddressList,
|
||||
type AddressListEntry,
|
||||
type AddressSyncConflict,
|
||||
@@ -138,10 +148,10 @@ type CardDavFormState = {
|
||||
collection_url: string;
|
||||
display_name: string;
|
||||
auth_type: "none" | "basic" | "bearer";
|
||||
credential_envelope_id: string;
|
||||
username: string;
|
||||
password: string;
|
||||
bearer_token: string;
|
||||
credential_ref: string;
|
||||
sync_direction: "read_only" | "import" | "export" | "two_way";
|
||||
};
|
||||
|
||||
@@ -195,10 +205,10 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = {
|
||||
collection_url: "",
|
||||
display_name: "",
|
||||
auth_type: "basic",
|
||||
credential_envelope_id: "",
|
||||
username: "",
|
||||
password: "",
|
||||
bearer_token: "",
|
||||
credential_ref: "",
|
||||
sync_direction: "read_only"
|
||||
};
|
||||
|
||||
@@ -289,14 +299,17 @@ function syncSourceLabel(source: AddressSyncSource): string {
|
||||
return source.display_name || source.external_address_book_ref || source.connector_type;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null): string {
|
||||
if (!value) return "Never";
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
const ADDRESS_DATE_TIME_OPTIONS: FormatDateTimeOptions = {
|
||||
fallback: "Never",
|
||||
year: undefined,
|
||||
month: undefined,
|
||||
day: undefined,
|
||||
hour: undefined,
|
||||
minute: undefined,
|
||||
timeZoneName: undefined,
|
||||
dateStyle: "short",
|
||||
timeStyle: "short"
|
||||
};
|
||||
|
||||
function planSummary(plan: AddressSyncPlan | null): string {
|
||||
if (!plan) return "No preview loaded.";
|
||||
@@ -604,25 +617,15 @@ function disabledReason(...conditions: Array<[boolean, string]>): string {
|
||||
return conditions.find(([applies]) => applies)?.[1] ?? "";
|
||||
}
|
||||
|
||||
type ReasonedButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
disabledReason: string;
|
||||
};
|
||||
|
||||
function ReasonedButton({ disabledReason, disabled, children, ...props }: ReasonedButtonProps) {
|
||||
return (
|
||||
<DisabledActionTooltip reason={disabledReason}>
|
||||
<Button {...props} disabled={disabled || Boolean(disabledReason)}>{children}</Button>
|
||||
</DisabledActionTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
|
||||
const [books, setBooks] = useState<AddressBook[]>([]);
|
||||
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
|
||||
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
|
||||
const [syncSources, setSyncSources] = useState<AddressSyncSource[]>([]);
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [contactTotal, setContactTotal] = useState(0);
|
||||
const [contactPage, setContactPage] = useState(1);
|
||||
const [contactPageSize, setContactPageSize] = useState(50);
|
||||
const [selectedBookId, setSelectedBookId] = useState("");
|
||||
const [selectedListId, setSelectedListId] = useState("");
|
||||
const [selectedContactId, setSelectedContactId] = useState("");
|
||||
@@ -649,6 +652,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
const [cardDavOpen, setCardDavOpen] = useState(false);
|
||||
const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM);
|
||||
const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]);
|
||||
const [cardDavCredentials, setCardDavCredentials] = useState<AddressCredentialEnvelope[]>([]);
|
||||
const [cardDavCredentialsError, setCardDavCredentialsError] = useState("");
|
||||
const [syncInspector, setSyncInspector] = useState<SyncInspectorState>(null);
|
||||
const [syncPlan, setSyncPlan] = useState<AddressSyncPlan | null>(null);
|
||||
const [syncDiagnostics, setSyncDiagnostics] = useState<AddressSyncDiagnostic[]>([]);
|
||||
@@ -668,6 +673,37 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
const canReadSync = hasScope(auth, "addresses:sync:read");
|
||||
const canWriteSync = hasScope(auth, "addresses:sync:write");
|
||||
|
||||
useEffect(() => {
|
||||
if (!cardDavOpen || !canWriteSync) {
|
||||
setCardDavCredentials([]);
|
||||
setCardDavCredentialsError("");
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
listAddressCredentials(settings)
|
||||
.then((credentials) => {
|
||||
if (active) {
|
||||
setCardDavCredentials(credentials);
|
||||
setCardDavCredentialsError("");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (active) {
|
||||
setCardDavCredentials([]);
|
||||
setCardDavCredentialsError(errorMessage(err));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
canWriteSync,
|
||||
cardDavOpen,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.groups_loaded || !onAuthChange) return;
|
||||
let active = true;
|
||||
@@ -687,10 +723,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]);
|
||||
const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]);
|
||||
const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]);
|
||||
const visibleContacts = useMemo(
|
||||
() => selectedList ? contacts.filter((contact) => selectedListContactIds.has(contact.id)) : contacts,
|
||||
[contacts, selectedList, selectedListContactIds]
|
||||
);
|
||||
const visibleContacts = contacts;
|
||||
const memberCandidateContacts = useMemo(() => {
|
||||
const normalizedQuery = memberQuery.trim().toLowerCase();
|
||||
return memberCandidates
|
||||
@@ -954,10 +987,25 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
const refreshContacts = useCallback(async (bookId: string, search: string) => {
|
||||
if (!bookId) {
|
||||
setContacts([]);
|
||||
setContactTotal(0);
|
||||
return;
|
||||
}
|
||||
setContacts(await listContacts(settings, { addressBookId: bookId, query: search, limit: 500, includeDeleted: showArchived }));
|
||||
}, [settings, showArchived]);
|
||||
const response = await listContactsPage(settings, {
|
||||
addressBookId: bookId,
|
||||
addressListId: selectedListId || undefined,
|
||||
query: search,
|
||||
limit: contactPageSize,
|
||||
offset: (contactPage - 1) * contactPageSize,
|
||||
includeDeleted: showArchived
|
||||
});
|
||||
const pageCount = Math.max(1, Math.ceil(response.total / contactPageSize));
|
||||
if (contactPage > pageCount) {
|
||||
setContactPage(pageCount);
|
||||
return;
|
||||
}
|
||||
setContacts(response.contacts);
|
||||
setContactTotal(response.total);
|
||||
}, [contactPage, contactPageSize, selectedListId, settings, showArchived]);
|
||||
|
||||
const refreshListEntries = useCallback(async (listId: string) => {
|
||||
if (!listId) {
|
||||
@@ -1132,12 +1180,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
|
||||
function openTreeNode(node: AddressTreeNode) {
|
||||
if (node.kind === "book" && node.book) {
|
||||
setContactPage(1);
|
||||
setSelectedBookId(node.book.id);
|
||||
setSelectedListId("");
|
||||
setSelectedContactId("");
|
||||
return;
|
||||
}
|
||||
if (node.kind === "list" && node.list) {
|
||||
setContactPage(1);
|
||||
setSelectedBookId(node.list.address_book_id);
|
||||
setSelectedListId(node.list.id);
|
||||
setSelectedContactId("");
|
||||
@@ -1519,17 +1569,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
function openCardDavDialog() {
|
||||
setCardDavForm(EMPTY_CARDDAV_FORM);
|
||||
setCardDavDiscovery([]);
|
||||
setCardDavCredentialsError("");
|
||||
setCardDavOpen(true);
|
||||
}
|
||||
|
||||
function cardDavPayload() {
|
||||
const credentialRef = cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id
|
||||
? `credential-envelope:${cardDavForm.credential_envelope_id}`
|
||||
: null;
|
||||
return {
|
||||
url: cardDavForm.collection_url,
|
||||
auth_type: cardDavForm.auth_type,
|
||||
username: cardDavForm.username || null,
|
||||
password: cardDavForm.password || null,
|
||||
bearer_token: cardDavForm.bearer_token || null,
|
||||
credential_ref: cardDavForm.credential_ref || null
|
||||
password: credentialRef ? null : cardDavForm.password || null,
|
||||
bearer_token: credentialRef ? null : cardDavForm.bearer_token || null,
|
||||
credential_ref: credentialRef
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1568,9 +1622,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
display_name: cardDavForm.display_name || selectedBook.name,
|
||||
auth_type: cardDavForm.auth_type,
|
||||
username: cardDavForm.username || null,
|
||||
password: cardDavForm.password || null,
|
||||
bearer_token: cardDavForm.bearer_token || null,
|
||||
credential_ref: cardDavForm.credential_ref || null,
|
||||
password: cardDavForm.credential_envelope_id ? null : cardDavForm.password || null,
|
||||
bearer_token: cardDavForm.credential_envelope_id ? null : cardDavForm.bearer_token || null,
|
||||
credential_ref: cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id
|
||||
? `credential-envelope:${cardDavForm.credential_envelope_id}`
|
||||
: null,
|
||||
sync_direction: cardDavForm.sync_direction,
|
||||
read_only: cardDavForm.sync_direction === "read_only" || cardDavForm.sync_direction === "import"
|
||||
});
|
||||
@@ -1753,7 +1809,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
{selectedBookSyncSources.map((source) => (
|
||||
<p key={source.id}>
|
||||
{syncSourceLabel(source)} · {source.connector_type} · {source.status}
|
||||
{source.last_success_at ? ` · last success ${formatDateTime(source.last_success_at)}` : ""}
|
||||
{source.last_success_at ? ` · last success ${formatDateTime(source.last_success_at, ADDRESS_DATE_TIME_OPTIONS)}` : ""}
|
||||
{source.last_error ? ` · ${source.last_error}` : ""}
|
||||
</p>
|
||||
))}
|
||||
@@ -1777,27 +1833,27 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
function renderSelectedBookActions() {
|
||||
return (
|
||||
<>
|
||||
<ReasonedButton type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></ReasonedButton>
|
||||
<ReasonedButton type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></ReasonedButton>
|
||||
<ReasonedButton type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></ReasonedButton>
|
||||
<ReasonedButton type="button" title="Import vCard into selected address book" aria-label="Import vCard into selected address book" onClick={() => setImportOpen(true)} disabledReason={importBookReason}><Upload size={15} /></ReasonedButton>
|
||||
<ReasonedButton type="button" title="Export selected address book as vCard" aria-label="Export selected address book as vCard" onClick={() => void exportSelectedBook()} disabledReason={exportBookReason}><Download size={15} /></ReasonedButton>
|
||||
<ReasonedButton type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></ReasonedButton>
|
||||
<ReasonedButton type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></ReasonedButton>
|
||||
<ReasonedButton type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</ReasonedButton>
|
||||
<ReasonedButton type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</ReasonedButton>
|
||||
<Button type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></Button>
|
||||
<Button type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></Button>
|
||||
<Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
|
||||
<Button type="button" title="Import vCard into selected address book" aria-label="Import vCard into selected address book" onClick={() => setImportOpen(true)} disabledReason={importBookReason}><Upload size={15} /></Button>
|
||||
<Button type="button" title="Export selected address book as vCard" aria-label="Export selected address book as vCard" onClick={() => void exportSelectedBook()} disabledReason={exportBookReason}><Download size={15} /></Button>
|
||||
<Button type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></Button>
|
||||
<Button type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></Button>
|
||||
<Button type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</Button>
|
||||
<Button type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</Button>
|
||||
{selectedList ?
|
||||
selectedList.deleted_at ?
|
||||
<ReasonedButton type="button" title="Restore address list" aria-label="Restore address list" disabledReason={restoreListReason(selectedList)} onClick={() => void restoreDeletedList(selectedList)}><RotateCcw size={15} /></ReasonedButton> :
|
||||
<Button type="button" title="Restore address list" aria-label="Restore address list" disabledReason={restoreListReason(selectedList)} onClick={() => void restoreDeletedList(selectedList)}><RotateCcw size={15} /></Button> :
|
||||
<>
|
||||
<ReasonedButton type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></ReasonedButton>
|
||||
<ReasonedButton type="button" variant="danger" title="Delete address list" aria-label="Delete address list" disabledReason={deleteListReason(selectedList)} onClick={() => setConfirmState({ kind: "list", list: selectedList })}><Trash2 size={15} /></ReasonedButton>
|
||||
<Button type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></Button>
|
||||
<Button type="button" variant="danger" title="Delete address list" aria-label="Delete address list" disabledReason={deleteListReason(selectedList)} onClick={() => setConfirmState({ kind: "list", list: selectedList })}><Trash2 size={15} /></Button>
|
||||
</> :
|
||||
selectedBook?.deleted_at ?
|
||||
<ReasonedButton type="button" title="Restore address book" aria-label="Restore address book" disabledReason={restoreBookReason(selectedBook)} onClick={() => void restoreBook(selectedBook)}><RotateCcw size={15} /></ReasonedButton> :
|
||||
<Button type="button" title="Restore address book" aria-label="Restore address book" disabledReason={restoreBookReason(selectedBook)} onClick={() => void restoreBook(selectedBook)}><RotateCcw size={15} /></Button> :
|
||||
<>
|
||||
<ReasonedButton type="button" title="Edit address book" aria-label="Edit address book" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && openEditBookDialog(selectedBook)}><Edit3 size={15} /></ReasonedButton>
|
||||
<ReasonedButton type="button" variant="danger" title="Delete address book" aria-label="Delete address book" disabledReason={selectedBook ? deleteBookReason(selectedBook) : "Select an address book before deleting."} onClick={() => selectedBook && setConfirmState({ kind: "book", book: selectedBook })}><Trash2 size={15} /></ReasonedButton>
|
||||
<Button type="button" title="Edit address book" aria-label="Edit address book" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && openEditBookDialog(selectedBook)}><Edit3 size={15} /></Button>
|
||||
<Button type="button" variant="danger" title="Delete address book" aria-label="Delete address book" disabledReason={selectedBook ? deleteBookReason(selectedBook) : "Select an address book before deleting."} onClick={() => selectedBook && setConfirmState({ kind: "book", book: selectedBook })}><Trash2 size={15} /></Button>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
@@ -1807,10 +1863,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
function renderContactRow(contact: Contact) {
|
||||
const selected = selectedContactId === contact.id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<SelectionListItem
|
||||
key={contact.id}
|
||||
className={`address-contact-row ${selected ? "is-selected" : ""} ${contact.deleted_at ? "is-archived-row" : ""}`}
|
||||
selected={selected}
|
||||
className={`address-contact-row ${contact.deleted_at ? "is-archived-row" : ""}`}
|
||||
draggable={!contact.deleted_at && !saving}
|
||||
onClick={() => setSelectedContactId(contact.id)}
|
||||
onDragStart={(event) => handleContactDragStart(event, contact)}>
|
||||
@@ -1821,7 +1877,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<span className="address-contact-row-meta">
|
||||
{contact.deleted_at ? <StatusBadge status="archived" /> : contact.tags.slice(0, 2).map((tag) => <span key={tag} className="address-tag">{tag}</span>)}
|
||||
</span>
|
||||
</button>
|
||||
</SelectionListItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1843,11 +1899,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
{selectedContact.deleted_at ?
|
||||
<ReasonedButton type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</ReasonedButton> :
|
||||
<Button type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</Button> :
|
||||
<>
|
||||
<ReasonedButton type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</ReasonedButton>
|
||||
<ReasonedButton type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</ReasonedButton>
|
||||
<ReasonedButton type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</ReasonedButton>
|
||||
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
|
||||
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
|
||||
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
@@ -1864,13 +1920,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
{selectedContactListEntries.map((entry) => (
|
||||
<div className="address-membership-row" key={entry.id}>
|
||||
<p className="muted">{entryTargetLabel(entry)}</p>
|
||||
<ReasonedButton
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={() => void removeAddressListEntry(entry)}
|
||||
disabledReason={removeContactFromListReason()}>
|
||||
<X size={15} /> Remove
|
||||
</ReasonedButton>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1927,8 +1983,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
|
||||
return (
|
||||
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
|
||||
{error && <div className="alert alert-error address-error">{error}</div>}
|
||||
{notice && !error && <div className="alert alert-success address-error">{notice}</div>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading address books..." className="address-workspace-frame">
|
||||
<div className="address-book-workspace">
|
||||
@@ -1947,7 +2003,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
label="Show archived"
|
||||
checked={showArchived}
|
||||
disabled={Boolean(toggleArchiveReason)}
|
||||
onChange={() => setShowArchived((current) => !current)}
|
||||
onChange={() => {
|
||||
setContactPage(1);
|
||||
setShowArchived((current) => !current);
|
||||
}}
|
||||
help="Include archived address books, lists, and contacts."
|
||||
/>
|
||||
</div>
|
||||
@@ -1981,28 +2040,49 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<div>
|
||||
<h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2>
|
||||
<p>
|
||||
{visibleContacts.length} contact{visibleContacts.length === 1 ? "" : "s"}
|
||||
{contactTotal} contact{contactTotal === 1 ? "" : "s"}
|
||||
{selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
{selectedList &&
|
||||
<ReasonedButton type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</ReasonedButton>
|
||||
<Button type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</Button>
|
||||
}
|
||||
<ReasonedButton type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</ReasonedButton>
|
||||
<Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="address-contact-toolbar">
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search contacts" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setContactPage(1);
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
placeholder="Search contacts" />
|
||||
{selectedBook?.read_only && <StatusBadge status="read-only" />}
|
||||
{selectedBook?.deleted_at && <StatusBadge status="archived" />}
|
||||
</div>
|
||||
<div className="address-contact-list" role="list">
|
||||
<div className="address-contact-list">
|
||||
{visibleContacts.length === 0 ?
|
||||
<div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> :
|
||||
visibleContacts.map(renderContactRow)
|
||||
<SelectionList label="Contacts" className="address-contact-selection-list">
|
||||
{visibleContacts.map(renderContactRow)}
|
||||
</SelectionList>
|
||||
}
|
||||
</div>
|
||||
<DataGridPaginationBar
|
||||
page={contactPage}
|
||||
pageSize={contactPageSize}
|
||||
totalRows={contactTotal}
|
||||
pageSizeOptions={[25, 50, 100, 200]}
|
||||
disabled={loading || saving || !selectedBook}
|
||||
className="address-contact-pagination"
|
||||
ariaLabel="Contact pagination"
|
||||
onPageChange={setContactPage}
|
||||
onPageSizeChange={(pageSize) => {
|
||||
setContactPageSize(pageSize);
|
||||
setContactPage(1);
|
||||
}} />
|
||||
</section>
|
||||
|
||||
<section className="address-detail-panel" aria-label="Contact detail">
|
||||
@@ -2019,8 +2099,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<ReasonedButton type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
|
||||
<ReasonedButton type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</ReasonedButton>
|
||||
<Button type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||
<Button type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</Button>
|
||||
</>
|
||||
}>
|
||||
<form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
|
||||
@@ -2055,8 +2135,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<ReasonedButton type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
|
||||
<ReasonedButton type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</ReasonedButton>
|
||||
<Button type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||
<Button type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</Button>
|
||||
</>
|
||||
}>
|
||||
<form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
|
||||
@@ -2082,8 +2162,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<ReasonedButton type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
|
||||
<ReasonedButton type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</ReasonedButton>
|
||||
<Button type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||
<Button type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</Button>
|
||||
</>
|
||||
}>
|
||||
<form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
|
||||
@@ -2099,7 +2179,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<section className="address-form-section">
|
||||
<div className="address-form-section-heading">
|
||||
<strong>Email addresses</strong>
|
||||
<ReasonedButton type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</ReasonedButton>
|
||||
<Button type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</Button>
|
||||
</div>
|
||||
<div className="address-form-list">
|
||||
{contactForm.emails.map((email) => (
|
||||
@@ -2107,7 +2187,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<label className="address-primary-choice"><input type="radio" name="address-primary-email" checked={email.is_primary} onChange={() => setPrimaryEmailRow(email.rowId)} /> Primary</label>
|
||||
<input value={email.label} placeholder="Label" onChange={(event) => updateEmailRow(email.rowId, { label: event.target.value })} />
|
||||
<input type="email" value={email.email} placeholder="Email address" onChange={(event) => updateEmailRow(email.rowId, { email: event.target.value })} />
|
||||
<ReasonedButton type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></ReasonedButton>
|
||||
<Button type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2116,7 +2196,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<section className="address-form-section">
|
||||
<div className="address-form-section-heading">
|
||||
<strong>Phone numbers</strong>
|
||||
<ReasonedButton type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</ReasonedButton>
|
||||
<Button type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</Button>
|
||||
</div>
|
||||
<div className="address-form-list">
|
||||
{contactForm.phones.map((phone) => (
|
||||
@@ -2124,7 +2204,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<label className="address-primary-choice"><input type="radio" name="address-primary-phone" checked={phone.is_primary} onChange={() => setPrimaryPhoneRow(phone.rowId)} /> Primary</label>
|
||||
<input value={phone.label} placeholder="Label" onChange={(event) => updatePhoneRow(phone.rowId, { label: event.target.value })} />
|
||||
<input value={phone.phone} placeholder="Phone number" onChange={(event) => updatePhoneRow(phone.rowId, { phone: event.target.value })} />
|
||||
<ReasonedButton type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></ReasonedButton>
|
||||
<Button type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2133,7 +2213,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<section className="address-form-section">
|
||||
<div className="address-form-section-heading">
|
||||
<strong>Postal addresses</strong>
|
||||
<ReasonedButton type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</ReasonedButton>
|
||||
<Button type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</Button>
|
||||
</div>
|
||||
<div className="address-form-list">
|
||||
{contactForm.postal_addresses.map((address) => (
|
||||
@@ -2145,7 +2225,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<input value={address.locality} placeholder="Locality" onChange={(event) => updatePostalAddressRow(address.rowId, { locality: event.target.value })} />
|
||||
<input value={address.region} placeholder="Region" onChange={(event) => updatePostalAddressRow(address.rowId, { region: event.target.value })} />
|
||||
<input value={address.country} placeholder="Country" onChange={(event) => updatePostalAddressRow(address.rowId, { country: event.target.value })} />
|
||||
<ReasonedButton type="button" variant="danger" title="Remove postal address" aria-label="Remove postal address" disabledReason={removePostalAddressRowReason} onClick={() => removePostalAddressRow(address.rowId)}><Trash2 size={15} /></ReasonedButton>
|
||||
<Button type="button" variant="danger" title="Remove postal address" aria-label="Remove postal address" disabledReason={removePostalAddressRowReason} onClick={() => removePostalAddressRow(address.rowId)}><Trash2 size={15} /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2162,7 +2242,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
closeDisabled={saving}
|
||||
className="address-member-dialog"
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={<ReasonedButton type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</ReasonedButton>}>
|
||||
footer={<Button type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</Button>}>
|
||||
<div className="address-member-picker">
|
||||
<div className="address-contact-toolbar address-member-search">
|
||||
<input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus />
|
||||
@@ -2186,12 +2266,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
disabled={saving || targetOptions.length === 0}>
|
||||
{targetOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
|
||||
</select>
|
||||
<ReasonedButton
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => selectedList && void addContactToAddressList(selectedList, contact, selectedTarget)}
|
||||
disabledReason={addContactToListReason(selectedList, contact, selectedTarget?.key ?? "")}>
|
||||
<Plus size={15} /> Add
|
||||
</ReasonedButton>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -2209,8 +2289,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<ReasonedButton type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
|
||||
<ReasonedButton type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</ReasonedButton>
|
||||
<Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||
<Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>
|
||||
</>
|
||||
}>
|
||||
<form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
|
||||
@@ -2236,9 +2316,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<ReasonedButton type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
|
||||
<ReasonedButton type="button" onClick={() => void discoverCardDavSources()} disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV URL before discovery."])}><Search size={16} /> Discover</ReasonedButton>
|
||||
<ReasonedButton type="submit" form="address-carddav-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV address-book URL before saving."])}><Save size={16} /> Connect</ReasonedButton>
|
||||
<Button type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||
<Button type="button" onClick={() => void discoverCardDavSources()} disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV URL before discovery."])}><Search size={16} /> Discover</Button>
|
||||
<Button type="submit" form="address-carddav-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV address-book URL before saving."])}><Save size={16} /> Connect</Button>
|
||||
</>
|
||||
}>
|
||||
<form id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}>
|
||||
@@ -2267,33 +2347,62 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<input value={cardDavForm.display_name} onChange={(event) => setCardDavForm((current) => ({ ...current, display_name: event.target.value }))} placeholder={selectedBook?.name ?? "CardDAV"} />
|
||||
</FormField>
|
||||
</div>
|
||||
{cardDavForm.auth_type !== "none" &&
|
||||
<FormField label="Reusable credential">
|
||||
<select
|
||||
value={cardDavForm.credential_envelope_id}
|
||||
onChange={(event) => {
|
||||
const credentialId = event.target.value;
|
||||
const credential = cardDavCredentials.find((item) => item.id === credentialId);
|
||||
const username = credential?.public_data?.username;
|
||||
setCardDavForm((current) => ({
|
||||
...current,
|
||||
credential_envelope_id: credentialId,
|
||||
username: credentialId && username ? String(username) : "",
|
||||
password: "",
|
||||
bearer_token: ""
|
||||
}));
|
||||
}}>
|
||||
<option value="">Enter source-specific credentials below</option>
|
||||
{cardDavCredentials.map((credential) => (
|
||||
<option key={credential.id} value={credential.id}>
|
||||
{credential.name}{credential.public_data.username ? ` (${String(credential.public_data.username)})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
}
|
||||
{cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>}
|
||||
{cardDavForm.auth_type === "basic" &&
|
||||
<div className="form-grid two">
|
||||
<FormField label="Username">
|
||||
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} />
|
||||
</FormField>
|
||||
<FormField label="Password">
|
||||
<input type="password" value={cardDavForm.password} onChange={(event) => setCardDavForm((current) => ({ ...current, password: event.target.value }))} />
|
||||
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} disabled={Boolean(cardDavForm.credential_envelope_id)} />
|
||||
</FormField>
|
||||
{!cardDavForm.credential_envelope_id &&
|
||||
<FormField label="Password">
|
||||
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
|
||||
</FormField>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{cardDavForm.auth_type === "bearer" &&
|
||||
{cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id &&
|
||||
<FormField label="Bearer token">
|
||||
<input type="password" value={cardDavForm.bearer_token} onChange={(event) => setCardDavForm((current) => ({ ...current, bearer_token: event.target.value }))} />
|
||||
<PasswordField value={cardDavForm.bearer_token} onValueChange={(value) => setCardDavForm((current) => ({ ...current, bearer_token: value }))} autoComplete="new-password" />
|
||||
</FormField>
|
||||
}
|
||||
<FormField label="Credential reference">
|
||||
<input value={cardDavForm.credential_ref} onChange={(event) => setCardDavForm((current) => ({ ...current, credential_ref: event.target.value }))} placeholder="env:GOVOPLAN_CARDDAV_PASSWORD" />
|
||||
</FormField>
|
||||
{cardDavDiscovery.length > 0 &&
|
||||
<div className="address-sync-result-list">
|
||||
<SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">
|
||||
{cardDavDiscovery.map((item) => (
|
||||
<button type="button" className="address-sync-result-row" key={item.collection_url} onClick={() => useDiscoveredCardDavBook(item)}>
|
||||
<SelectionListItem
|
||||
selected={cardDavForm.collection_url === item.collection_url}
|
||||
className="address-sync-result-row"
|
||||
key={item.collection_url}
|
||||
onClick={() => useDiscoveredCardDavBook(item)}>
|
||||
<strong>{item.display_name || item.collection_url}</strong>
|
||||
<small>{item.collection_url}</small>
|
||||
</button>
|
||||
</SelectionListItem>
|
||||
))}
|
||||
</div>
|
||||
</SelectionList>
|
||||
}
|
||||
</form>
|
||||
</Dialog>
|
||||
@@ -2307,9 +2416,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<ReasonedButton type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</ReasonedButton>
|
||||
<ReasonedButton type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</ReasonedButton>
|
||||
<ReasonedButton type="button" variant="primary" onClick={() => syncInspector && void runSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [!canWriteSync, "You need permission to run address sync."], [saving, savingReason])}>Run sync</ReasonedButton>
|
||||
<Button type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</Button>
|
||||
<Button type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</Button>
|
||||
<Button type="button" variant="primary" onClick={() => syncInspector && void runSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [!canWriteSync, "You need permission to run address sync."], [saving, savingReason])}>Run sync</Button>
|
||||
</>
|
||||
}>
|
||||
{syncInspector &&
|
||||
@@ -2324,22 +2433,22 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<StatusBadge status={syncInspector.source.status} />
|
||||
{syncInspector.source.read_only && <StatusBadge status="read-only" />}
|
||||
</div>
|
||||
<p className="muted">Last attempt: {formatDateTime(syncInspector.source.last_attempted_at)} · Last success: {formatDateTime(syncInspector.source.last_success_at)}</p>
|
||||
{syncInspector.source.last_error && <p className="alert alert-error">{syncInspector.source.last_error}</p>}
|
||||
<p className="muted">Last attempt: {formatDateTime(syncInspector.source.last_attempted_at, ADDRESS_DATE_TIME_OPTIONS)} · Last success: {formatDateTime(syncInspector.source.last_success_at, ADDRESS_DATE_TIME_OPTIONS)}</p>
|
||||
{syncInspector.source.last_error && <DismissibleAlert tone="danger" resetKey={syncInspector.source.last_error}>{syncInspector.source.last_error}</DismissibleAlert>}
|
||||
<div className="button-row compact-actions">
|
||||
<ReasonedButton
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void toggleSyncSourceEnabled(syncInspector.source)}
|
||||
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
|
||||
{syncInspector.source.enabled ? "Disable" : "Enable"}
|
||||
</ReasonedButton>
|
||||
<ReasonedButton
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={() => setConfirmState({ kind: "sync-source", source: syncInspector.source })}
|
||||
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
|
||||
Disconnect
|
||||
</ReasonedButton>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2366,7 +2475,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
{syncConflicts.map((conflict) => (
|
||||
<div className="address-sync-record-row" key={conflict.id}>
|
||||
<span><strong>{conflict.field_path}</strong><small>{conflict.resource_href || conflict.remote_uid || conflict.contact_id}</small></span>
|
||||
<ReasonedButton type="button" onClick={() => openConflictReview(conflict)} disabledReason={disabledReason([saving, savingReason])}>Review</ReasonedButton>
|
||||
<Button type="button" onClick={() => openConflictReview(conflict)} disabledReason={disabledReason([saving, savingReason])}>Review</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2393,7 +2502,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<div className="address-sync-record-list">
|
||||
{syncTombstones.slice(0, 20).map((tombstone) => (
|
||||
<div className="address-sync-record-row" key={tombstone.id}>
|
||||
<span><strong>{tombstone.remote_uid || tombstone.resource_href || tombstone.contact_id}</strong><small>{formatDateTime(tombstone.synced_at || tombstone.remote_deleted_at || tombstone.local_deleted_at)}</small></span>
|
||||
<span><strong>{tombstone.remote_uid || tombstone.resource_href || tombstone.contact_id}</strong><small>{formatDateTime(tombstone.synced_at || tombstone.remote_deleted_at || tombstone.local_deleted_at, ADDRESS_DATE_TIME_OPTIONS)}</small></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2410,20 +2519,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
className="address-sync-dialog"
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={conflictDialog && <>
|
||||
<ReasonedButton type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</ReasonedButton>
|
||||
<ReasonedButton
|
||||
<Button type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void resolveConflictWith(conflictDialog, "ignored", "ignored")}
|
||||
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
|
||||
Ignore
|
||||
</ReasonedButton>
|
||||
<ReasonedButton
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void resolveConflictWith(conflictDialog, "keep_local")}
|
||||
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
|
||||
Keep local
|
||||
</ReasonedButton>
|
||||
<ReasonedButton
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void applyMergedConflict(conflictDialog)}
|
||||
disabledReason={disabledReason(
|
||||
@@ -2432,8 +2541,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
[saving, savingReason]
|
||||
)}>
|
||||
Apply merged
|
||||
</ReasonedButton>
|
||||
<ReasonedButton
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => void resolveConflictWith(conflictDialog, "use_remote")}
|
||||
@@ -2443,7 +2552,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
[saving, savingReason]
|
||||
)}>
|
||||
Use remote
|
||||
</ReasonedButton>
|
||||
</Button>
|
||||
</>}>
|
||||
{conflictDialog &&
|
||||
<div className="address-conflict-review">
|
||||
@@ -2457,7 +2566,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
{conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />}
|
||||
</div>
|
||||
{conflictDialog.metadata?.message && <p className="muted">{String(conflictDialog.metadata.message)}</p>}
|
||||
{!canApplyRemoteConflict(conflictDialog) && <p className="alert alert-warning">This conflict predates stored field payloads or came from a stale write. It can be marked resolved or ignored, but the remote value cannot be applied automatically.</p>}
|
||||
{!canApplyRemoteConflict(conflictDialog) && <DismissibleAlert tone="warning" dismissible={false}>This conflict predates stored field payloads or came from a stale write. It can be marked resolved or ignored, but the remote value cannot be applied automatically.</DismissibleAlert>}
|
||||
</div>
|
||||
<div className="address-conflict-grid">
|
||||
<div className="address-conflict-grid-header">Field</div>
|
||||
@@ -2469,15 +2578,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
<strong>{row.field}</strong>
|
||||
<span>{row.local}</span>
|
||||
<span>{row.remote}</span>
|
||||
<span className="address-conflict-choice">
|
||||
<div>
|
||||
{canMergeConflict(conflictDialog) ?
|
||||
<>
|
||||
<button type="button" className={conflictMergeChoices[row.field] !== "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "local")}>Local</button>
|
||||
<button type="button" className={conflictMergeChoices[row.field] === "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "remote")}>Remote</button>
|
||||
</> :
|
||||
<SegmentedControl<ConflictMergeChoice>
|
||||
className="address-conflict-choice"
|
||||
role="group"
|
||||
size="equal"
|
||||
ariaLabel={`Source for ${row.field}`}
|
||||
options={[{ id: "local", label: "Local" }, { id: "remote", label: "Remote" }]}
|
||||
value={conflictMergeChoices[row.field] ?? "local"}
|
||||
onChange={(choice) => setConflictMergeChoice(row.field, choice)}
|
||||
/> :
|
||||
"—"
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ const translations = {
|
||||
export const addressesModule: PlatformWebModule = {
|
||||
id: "addresses",
|
||||
label: "i18n:govoplan-addresses.address_book.f6327f59",
|
||||
version: "1.0.0",
|
||||
version: "0.1.9",
|
||||
dependencies: [],
|
||||
optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"],
|
||||
translations,
|
||||
|
||||
@@ -150,16 +150,19 @@
|
||||
width: min(960px, calc(100vw - 36px));
|
||||
}
|
||||
|
||||
.address-sync-record-list,
|
||||
.address-sync-plan-grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.address-sync-result-list,
|
||||
.address-sync-record-list,
|
||||
.address-sync-plan-grid {
|
||||
border: var(--border-line);
|
||||
display: grid;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.address-sync-result-row,
|
||||
.address-sync-record-row,
|
||||
.address-sync-plan-row {
|
||||
align-items: center;
|
||||
@@ -175,13 +178,11 @@
|
||||
}
|
||||
|
||||
.address-sync-result-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.address-sync-result-row:hover,
|
||||
.address-sync-result-row:focus-visible {
|
||||
background: var(--line);
|
||||
outline: none;
|
||||
align-items: center;
|
||||
border-bottom: var(--border-line);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.address-sync-result-row strong,
|
||||
@@ -255,37 +256,6 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.address-conflict-choice {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.address-conflict-choice button {
|
||||
background: var(--panel);
|
||||
border: var(--border-line);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
min-height: 28px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.address-conflict-choice button:first-child {
|
||||
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
||||
}
|
||||
|
||||
.address-conflict-choice button:last-child {
|
||||
border-left: 0;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
|
||||
.address-conflict-choice button.is-selected {
|
||||
background: var(--line);
|
||||
box-shadow: inset 0 1px 2px rgba(var(--shadow-color-rgb), .12);
|
||||
}
|
||||
|
||||
.address-list-panel,
|
||||
.address-detail-panel {
|
||||
display: flex;
|
||||
@@ -338,34 +308,20 @@
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.address-contact-pagination {
|
||||
border-top: var(--border-line);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.address-contact-selection-list {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.address-contact-row {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.address-contact-row[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.address-contact-row[draggable="true"]:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.address-contact-row:hover,
|
||||
.address-contact-row:focus-visible,
|
||||
.address-contact-row.is-selected {
|
||||
background: var(--line);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.address-contact-row-main {
|
||||
@@ -617,56 +573,10 @@
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-grid.two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.form-grid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.form-grid.four {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.inline-actions .btn {
|
||||
align-items: center;
|
||||
aspect-ratio: 1;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.strong-link {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-strong);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.is-selected-row {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
@@ -677,9 +587,6 @@
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.address-book-workspace,
|
||||
.form-grid.two,
|
||||
.form-grid.three,
|
||||
.form-grid.four,
|
||||
.address-form-row-email,
|
||||
.address-form-row-phone,
|
||||
.address-form-row-postal {
|
||||
|
||||
Reference in New Issue
Block a user