Compare commits
14 Commits
3d52cc86f9
...
v0.1.9
| Author | SHA1 | Date | |
|---|---|---|---|
| 93dddbb8c5 | |||
| 04accaa206 | |||
| 75c9ece709 | |||
| d005040a8e | |||
| 613fb15a80 | |||
| 5dc9392290 | |||
| 7237679a85 | |||
| 5ff154bc64 | |||
| 3ec4b3c4ad | |||
| 70ee3c0148 | |||
| 5d560d4c58 | |||
| 8b4cf362ca | |||
| b13e5760c8 | |||
| 22d12d674b |
@@ -43,6 +43,13 @@ inspection UI are implemented. The conflict review UI compares stored local and
|
|||||||
remote field payloads, can apply a stored remote vCard payload, and supports
|
remote field payloads, can apply a stored remote vCard payload, and supports
|
||||||
manual per-field local/remote merge choices.
|
manual per-field local/remote merge choices.
|
||||||
|
|
||||||
|
API-managed CardDAV credentials are encrypted inside the source record. Source
|
||||||
|
deletion physically removes that credential material and records a non-secret
|
||||||
|
audit event in the same database transaction; destructive module retirement
|
||||||
|
audits every remaining credential before the owning tables are dropped. Legacy
|
||||||
|
external references are detached but are never sent to a secret provider for
|
||||||
|
deletion because Addresses cannot prove that it owns them.
|
||||||
|
|
||||||
## Boundary
|
## Boundary
|
||||||
|
|
||||||
`govoplan-addresses` owns:
|
`govoplan-addresses` owns:
|
||||||
|
|||||||
@@ -148,7 +148,12 @@ a conflict instead of silently overwriting remote data. The first conflict
|
|||||||
review UI compares stored local and remote field payloads and can apply a
|
review UI compares stored local and remote field payloads and can apply a
|
||||||
stored remote vCard payload or a manual per-field local/remote merge payload.
|
stored remote vCard payload or a manual per-field local/remote merge payload.
|
||||||
Source disconnect/delete removes the source binding and related sync records
|
Source disconnect/delete removes the source binding and related sync records
|
||||||
while keeping local contacts.
|
while keeping local contacts. Because API-managed CardDAV credentials are
|
||||||
|
encrypted in the source row, the same transaction physically removes their
|
||||||
|
ciphertext and emits non-secret credential-deletion audit evidence. Destructive
|
||||||
|
module retirement audits all remaining owned credential material before table
|
||||||
|
removal. An unowned legacy reference is detached rather than passed to an
|
||||||
|
external secret provider.
|
||||||
|
|
||||||
## Connector Direction
|
## Connector Direction
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/addresses-webui",
|
"name": "@govoplan/addresses-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.11",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-addresses"
|
name = "govoplan-addresses"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
description = "GovOPlaN reusable address and recipient-source module."
|
description = "GovOPlaN reusable address and recipient-source module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"defusedxml>=0.7.1",
|
"defusedxml>=0.7.1",
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ from sqlalchemy import func
|
|||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal
|
from govoplan_core.auth import ApiPrincipal
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
|
from govoplan_core.core.people import (
|
||||||
|
PeopleSearchGroup,
|
||||||
|
PersonSearchCandidate,
|
||||||
|
person_selection_key,
|
||||||
|
)
|
||||||
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone
|
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone
|
||||||
from govoplan_addresses.backend.schemas import ContactCreateRequest
|
from govoplan_addresses.backend.schemas import ContactCreateRequest
|
||||||
from govoplan_addresses.backend.service import (
|
from govoplan_addresses.backend.service import (
|
||||||
@@ -137,6 +142,60 @@ class AddressesLookupCapability:
|
|||||||
return tuple(candidates)
|
return tuple(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressesPeopleSearchProvider:
|
||||||
|
"""Adapt visible address-book contacts to the shared people-search contract."""
|
||||||
|
|
||||||
|
def __init__(self, lookup: AddressesLookupCapability | None = None) -> None:
|
||||||
|
self._lookup = lookup or AddressesLookupCapability()
|
||||||
|
|
||||||
|
def search_people(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
limit: int = 25,
|
||||||
|
) -> tuple[PeopleSearchGroup, ...]:
|
||||||
|
if not hasattr(principal, "account_id") or not hasattr(principal, "tenant_id") or not hasattr(principal, "group_ids"):
|
||||||
|
raise AddressBookError("Address contact search requires an authenticated tenant principal.")
|
||||||
|
candidates = self._lookup.lookup(
|
||||||
|
session,
|
||||||
|
principal, # type: ignore[arg-type] - validated principal contract
|
||||||
|
query=query,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
PeopleSearchGroup(
|
||||||
|
key="contacts",
|
||||||
|
label="Contacts",
|
||||||
|
candidates=tuple(
|
||||||
|
PersonSearchCandidate(
|
||||||
|
selection_key=person_selection_key("contact", item.contact_id, email=item.email),
|
||||||
|
kind="contact",
|
||||||
|
reference_id=item.contact_id,
|
||||||
|
display_name=item.display_name,
|
||||||
|
email=item.email,
|
||||||
|
source_module="addresses",
|
||||||
|
source_label="Contacts",
|
||||||
|
source_ref=item.source_ref or f"addresses:contact:{item.contact_id}",
|
||||||
|
source_revision=item.source_revision,
|
||||||
|
description=" · ".join(part for part in (item.organization, item.role_title) if part) or None,
|
||||||
|
provenance=dict(item.provenance),
|
||||||
|
metadata={
|
||||||
|
"address_book_id": item.address_book_id,
|
||||||
|
"email_label": item.email_label,
|
||||||
|
"organization": item.organization,
|
||||||
|
"role_title": item.role_title,
|
||||||
|
"tags": tuple(item.tags),
|
||||||
|
"source_kind": item.source_kind,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for item in candidates
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AddressesContactWriterCapability:
|
class AddressesContactWriterCapability:
|
||||||
def list_write_targets(
|
def list_write_targets(
|
||||||
self,
|
self,
|
||||||
@@ -301,6 +360,10 @@ def lookup_capability(_context: Any) -> AddressesLookupCapability:
|
|||||||
return AddressesLookupCapability()
|
return AddressesLookupCapability()
|
||||||
|
|
||||||
|
|
||||||
|
def people_search_capability(_context: Any) -> AddressesPeopleSearchProvider:
|
||||||
|
return AddressesPeopleSearchProvider()
|
||||||
|
|
||||||
|
|
||||||
def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapability:
|
def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapability:
|
||||||
return AddressesRecipientSourceCapability()
|
return AddressesRecipientSourceCapability()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import posixpath
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -8,6 +9,12 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any, Mapping, Protocol
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
from defusedxml import ElementTree as SafeElementTree
|
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):
|
class AddressCardDAVError(RuntimeError):
|
||||||
@@ -269,7 +276,19 @@ class AddressCardDAVClient:
|
|||||||
return AddressCardDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
|
return AddressCardDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
|
||||||
|
|
||||||
def object_url(self, href: str) -> str:
|
def object_url(self, href: str) -> str:
|
||||||
return urllib.parse.urljoin(self.collection_url, href)
|
candidate = same_origin_dav_url(
|
||||||
|
self.collection_url,
|
||||||
|
href,
|
||||||
|
label="CardDAV object href",
|
||||||
|
)
|
||||||
|
collection_parts = urllib.parse.urlparse(self.collection_url)
|
||||||
|
candidate_parts = urllib.parse.urlparse(candidate)
|
||||||
|
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
|
||||||
|
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
|
||||||
|
collection_prefix = collection_path.rstrip("/") + "/"
|
||||||
|
if not candidate_path.startswith(collection_prefix) or candidate_path == collection_path:
|
||||||
|
raise AddressCardDAVError("CardDAV object href must remain inside the configured collection path")
|
||||||
|
return candidate
|
||||||
|
|
||||||
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes:
|
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes:
|
||||||
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
|
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
|
||||||
@@ -313,14 +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]:
|
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)
|
url = validate_http_url(url)
|
||||||
try:
|
try:
|
||||||
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
|
url = validate_outbound_http_url(url, label="CardDAV URL")
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated CardDAV HTTP(S) URL. # nosec B310
|
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
|
||||||
return response.status, dict(response.headers.items()), response.read()
|
url,
|
||||||
|
data=body,
|
||||||
|
headers=dict(headers),
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
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:
|
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:
|
except urllib.error.URLError as exc:
|
||||||
raise AddressCardDAVError(f"{method} {url} failed: {exc.reason}") from 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
|
raise AddressCardDAVError(f"{method} {url} failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
@@ -431,16 +467,73 @@ def ensure_collection_url(value: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def validate_http_url(value: str) -> str:
|
def validate_http_url(value: str) -> str:
|
||||||
parsed = urllib.parse.urlparse(value)
|
parsed = urllib.parse.urlparse(value.strip())
|
||||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
|
||||||
raise AddressCardDAVError("CardDAV URL must be an absolute HTTP(S) URL")
|
raise AddressCardDAVError("CardDAV URL must be an absolute HTTP(S) URL")
|
||||||
if parsed.username or parsed.password:
|
if parsed.username or parsed.password:
|
||||||
raise AddressCardDAVError("CardDAV URL must not include embedded credentials")
|
raise AddressCardDAVError("CardDAV URL must not include embedded credentials")
|
||||||
|
if parsed.query or parsed.fragment:
|
||||||
|
raise AddressCardDAVError("CardDAV URL must not include a query or fragment")
|
||||||
|
_url_origin(parsed)
|
||||||
return urllib.parse.urlunparse(parsed)
|
return urllib.parse.urlunparse(parsed)
|
||||||
|
|
||||||
|
|
||||||
def absolute_dav_url(base_url: str, href: str) -> str:
|
def absolute_dav_url(base_url: str, href: str) -> str:
|
||||||
return urllib.parse.urljoin(ensure_collection_url(base_url), href)
|
return same_origin_dav_url(base_url, href, label="CardDAV discovery href")
|
||||||
|
|
||||||
|
|
||||||
|
def same_origin_dav_url(base_url: str, href: str, *, label: str) -> str:
|
||||||
|
base = ensure_collection_url(base_url)
|
||||||
|
candidate = validate_http_url(urllib.parse.urljoin(base, href))
|
||||||
|
if _url_origin(urllib.parse.urlparse(candidate)) != _url_origin(urllib.parse.urlparse(base)):
|
||||||
|
raise AddressCardDAVError(f"{label} must use the configured collection origin")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _url_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
|
||||||
|
try:
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AddressCardDAVError("CardDAV URL has an invalid port") from exc
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
if port is None:
|
||||||
|
port = 443 if scheme == "https" else 80
|
||||||
|
return scheme, (parsed.hostname or "").lower(), port
|
||||||
|
|
||||||
|
|
||||||
|
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
def __init__(self, source_url: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._source_origin = _url_origin(urllib.parse.urlparse(validate_http_url(source_url)))
|
||||||
|
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
||||||
|
del fp, msg, headers
|
||||||
|
try:
|
||||||
|
candidate = validate_http_url(newurl)
|
||||||
|
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
|
||||||
|
method = req.get_method()
|
||||||
|
data = req.data
|
||||||
|
if code == 303 and method != "HEAD":
|
||||||
|
method, data = "GET", None
|
||||||
|
elif code in {301, 302} and method == "POST":
|
||||||
|
method, data = "GET", None
|
||||||
|
forwarded_headers = {
|
||||||
|
key: value
|
||||||
|
for key, value in req.header_items()
|
||||||
|
if key.casefold() not in {"host", "content-length"}
|
||||||
|
}
|
||||||
|
return urllib.request.Request( # noqa: S310 - candidate is validated and same-origin.
|
||||||
|
candidate,
|
||||||
|
data=data,
|
||||||
|
headers=forwarded_headers,
|
||||||
|
origin_req_host=req.origin_req_host,
|
||||||
|
unverifiable=True,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def strip_weak_etag(value: str | None) -> str | None:
|
def strip_weak_etag(value: str | None) -> str | None:
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
from govoplan_addresses.backend.capabilities import (
|
from govoplan_addresses.backend.capabilities import (
|
||||||
CAPABILITY_ADDRESSES_CONTACT_WRITER,
|
CAPABILITY_ADDRESSES_CONTACT_WRITER,
|
||||||
CAPABILITY_ADDRESSES_LOOKUP,
|
CAPABILITY_ADDRESSES_LOOKUP,
|
||||||
@@ -10,6 +13,7 @@ from govoplan_addresses.backend.capabilities import (
|
|||||||
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata
|
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
|
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -25,6 +29,47 @@ from govoplan_core.core.modules import (
|
|||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
_addresses_table_retirement_provider = drop_table_retirement_provider(
|
||||||
|
addresses_models.AddressSyncDiagnostic,
|
||||||
|
addresses_models.AddressSyncConflict,
|
||||||
|
addresses_models.AddressSyncTombstone,
|
||||||
|
addresses_models.AddressSyncSource,
|
||||||
|
addresses_models.AddressListEntry,
|
||||||
|
addresses_models.AddressList,
|
||||||
|
addresses_models.ContactPostalAddress,
|
||||||
|
addresses_models.ContactPhone,
|
||||||
|
addresses_models.ContactEmail,
|
||||||
|
addresses_models.Contact,
|
||||||
|
addresses_models.AddressBook,
|
||||||
|
label="Addresses",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _addresses_retirement_provider(session: object | None, module_id: str):
|
||||||
|
plan = _addresses_table_retirement_provider(session, module_id)
|
||||||
|
base_executor = plan.destroy_data_executor
|
||||||
|
if base_executor is None:
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def executor(execute_session: object, execute_module_id: str) -> None:
|
||||||
|
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
|
||||||
|
raise RuntimeError("No database session is available for Addresses credential retirement.")
|
||||||
|
if inspect(execute_session.get_bind()).has_table(addresses_models.AddressSyncSource.__tablename__):
|
||||||
|
from govoplan_addresses.backend.service import audit_address_credentials_for_retirement
|
||||||
|
|
||||||
|
audit_address_credentials_for_retirement(execute_session)
|
||||||
|
base_executor(execute_session, execute_module_id)
|
||||||
|
|
||||||
|
return replace(
|
||||||
|
plan,
|
||||||
|
destroy_data_warnings=(
|
||||||
|
*plan.destroy_data_warnings,
|
||||||
|
"Addresses-owned encrypted connector credentials are audited and deleted with the sync-source table.",
|
||||||
|
),
|
||||||
|
destroy_data_executor=executor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
@@ -104,11 +149,12 @@ def _addresses_router(_context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="addresses",
|
id="addresses",
|
||||||
name="Addresses",
|
name="Addresses",
|
||||||
version="0.1.8",
|
version="0.1.9",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
|
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
|
||||||
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"),
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
|
||||||
),
|
),
|
||||||
@@ -128,24 +174,12 @@ manifest = ModuleManifest(
|
|||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
retirement_supported=True,
|
retirement_supported=True,
|
||||||
retirement_provider=drop_table_retirement_provider(
|
retirement_provider=_addresses_retirement_provider,
|
||||||
addresses_models.AddressSyncDiagnostic,
|
|
||||||
addresses_models.AddressSyncConflict,
|
|
||||||
addresses_models.AddressSyncTombstone,
|
|
||||||
addresses_models.AddressSyncSource,
|
|
||||||
addresses_models.AddressListEntry,
|
|
||||||
addresses_models.AddressList,
|
|
||||||
addresses_models.ContactPostalAddress,
|
|
||||||
addresses_models.ContactPhone,
|
|
||||||
addresses_models.ContactEmail,
|
|
||||||
addresses_models.Contact,
|
|
||||||
addresses_models.AddressBook,
|
|
||||||
label="Addresses",
|
|
||||||
),
|
|
||||||
retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.",
|
retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.",
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["lookup_capability"]).lookup_capability(context),
|
CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["lookup_capability"]).lookup_capability(context),
|
||||||
|
CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["people_search_capability"]).people_search_capability(context),
|
||||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE: lambda context: __import__(
|
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE: lambda context: __import__(
|
||||||
"govoplan_addresses.backend.capabilities",
|
"govoplan_addresses.backend.capabilities",
|
||||||
fromlist=["recipient_source_capability"],
|
fromlist=["recipient_source_capability"],
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ from govoplan_addresses.backend.service import (
|
|||||||
restore_contact,
|
restore_contact,
|
||||||
resolve_sync_conflict,
|
resolve_sync_conflict,
|
||||||
preview_sync_source,
|
preview_sync_source,
|
||||||
|
public_address_sync_metadata,
|
||||||
run_sync_source,
|
run_sync_source,
|
||||||
start_sync_attempt,
|
start_sync_attempt,
|
||||||
update_address_book,
|
update_address_book,
|
||||||
@@ -217,7 +218,7 @@ def _sync_source_response(sync_source: AddressSyncSource) -> AddressSyncSourceRe
|
|||||||
"last_success_at": sync_source.last_success_at,
|
"last_success_at": sync_source.last_success_at,
|
||||||
"last_error": sync_source.last_error,
|
"last_error": sync_source.last_error,
|
||||||
"last_diagnostic": sync_source.last_diagnostic,
|
"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,
|
"created_at": sync_source.created_at,
|
||||||
"updated_at": sync_source.updated_at,
|
"updated_at": sync_source.updated_at,
|
||||||
}
|
}
|
||||||
@@ -618,7 +619,7 @@ def api_discover_carddav_address_books(
|
|||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_scope(principal, "addresses:sync:read")
|
_require_scope(principal, "addresses:sync:write")
|
||||||
try:
|
try:
|
||||||
address_books = discover_carddav_address_books(session, principal, payload)
|
address_books = discover_carddav_address_books(session, principal, payload)
|
||||||
return AddressCardDavDiscoveryResponse(
|
return AddressCardDavDiscoveryResponse(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -10,6 +12,7 @@ from sqlalchemy import and_, false, func, or_
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.audit.logging import audit_event
|
||||||
from govoplan_core.core.change_sequence import record_change
|
from govoplan_core.core.change_sequence import record_change
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
@@ -17,6 +20,7 @@ from govoplan_addresses.backend.carddav import (
|
|||||||
AddressCardDAVAddressBook,
|
AddressCardDAVAddressBook,
|
||||||
AddressCardDAVClient,
|
AddressCardDAVClient,
|
||||||
AddressCardDAVError,
|
AddressCardDAVError,
|
||||||
|
AddressCardDAVObject,
|
||||||
AddressCardDAVPreconditionFailed,
|
AddressCardDAVPreconditionFailed,
|
||||||
AddressCardDAVReportResult,
|
AddressCardDAVReportResult,
|
||||||
AddressCardDAVSyncUnsupported,
|
AddressCardDAVSyncUnsupported,
|
||||||
@@ -57,7 +61,7 @@ from govoplan_addresses.backend.schemas import (
|
|||||||
ContactPostalAddressPayload,
|
ContactPostalAddressPayload,
|
||||||
ContactUpdateRequest,
|
ContactUpdateRequest,
|
||||||
)
|
)
|
||||||
from govoplan_addresses.backend.vcard import ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues
|
from govoplan_addresses.backend.vcard import ParsedVCard, ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues
|
||||||
|
|
||||||
|
|
||||||
class AddressBookError(ValueError):
|
class AddressBookError(ValueError):
|
||||||
@@ -99,7 +103,7 @@ REMOTE_SYNC_ACTIONS = {"remote_create", "remote_update", "remote_delete"}
|
|||||||
ADDRESS_MODULE_ID = "addresses"
|
ADDRESS_MODULE_ID = "addresses"
|
||||||
ADDRESS_CONTACTS_COLLECTION = "addresses.contacts"
|
ADDRESS_CONTACTS_COLLECTION = "addresses.contacts"
|
||||||
ADDRESS_CONTACT_RESOURCE = "address_contact"
|
ADDRESS_CONTACT_RESOURCE = "address_contact"
|
||||||
CARDDAV_SECRET_ENV_PREFIX = "env:"
|
CARDDAV_SECRET_ENV_PREFIX = "env:" # noqa: S105 # nosec B105 - reference prefix, not a credential.
|
||||||
|
|
||||||
|
|
||||||
def _trim(value: str | None) -> str | None:
|
def _trim(value: str | None) -> str | None:
|
||||||
@@ -256,6 +260,8 @@ def create_sync_source(
|
|||||||
principal: ApiPrincipal,
|
principal: ApiPrincipal,
|
||||||
address_book_id: str,
|
address_book_id: str,
|
||||||
payload: AddressSyncSourceCreateRequest,
|
payload: AddressSyncSourceCreateRequest,
|
||||||
|
*,
|
||||||
|
trusted_connector_metadata: bool = False,
|
||||||
) -> AddressSyncSource:
|
) -> AddressSyncSource:
|
||||||
book = get_visible_address_book(session, principal, address_book_id)
|
book = get_visible_address_book(session, principal, address_book_id)
|
||||||
if book.deleted_at is not None:
|
if book.deleted_at is not None:
|
||||||
@@ -266,6 +272,8 @@ def create_sync_source(
|
|||||||
raise AddressBookError("Sync connector type is required.")
|
raise AddressBookError("Sync connector type is required.")
|
||||||
if not display_name:
|
if not display_name:
|
||||||
raise AddressBookError("Sync source display name is required.")
|
raise AddressBookError("Sync source display name is required.")
|
||||||
|
if connector_type.casefold() == "carddav" and not trusted_connector_metadata:
|
||||||
|
_assert_api_carddav_metadata_safe(payload.metadata)
|
||||||
read_only = _read_only_from_sync_direction(payload.sync_direction, payload.read_only)
|
read_only = _read_only_from_sync_direction(payload.sync_direction, payload.read_only)
|
||||||
sync_source = AddressSyncSource(
|
sync_source = AddressSyncSource(
|
||||||
tenant_id=book.tenant_id,
|
tenant_id=book.tenant_id,
|
||||||
@@ -325,7 +333,11 @@ def update_sync_source(
|
|||||||
if "remote_revision" in payload.model_fields_set:
|
if "remote_revision" in payload.model_fields_set:
|
||||||
sync_source.remote_revision = _trim(payload.remote_revision)
|
sync_source.remote_revision = _trim(payload.remote_revision)
|
||||||
if "metadata" in payload.model_fields_set:
|
if "metadata" in payload.model_fields_set:
|
||||||
sync_source.metadata_ = payload.metadata or {}
|
metadata = payload.metadata or {}
|
||||||
|
if sync_source.connector_type.casefold() == "carddav":
|
||||||
|
_assert_api_carddav_metadata_safe(metadata)
|
||||||
|
metadata = _merge_server_owned_carddav_metadata(sync_source.metadata_, metadata)
|
||||||
|
sync_source.metadata_ = metadata
|
||||||
sync_source.updated_by_account_id = _account_id(principal)
|
sync_source.updated_by_account_id = _account_id(principal)
|
||||||
_apply_sync_source_to_book(sync_source.address_book, sync_source)
|
_apply_sync_source_to_book(sync_source.address_book, sync_source)
|
||||||
return sync_source
|
return sync_source
|
||||||
@@ -341,9 +353,89 @@ def delete_sync_source(session: Session, principal: ApiPrincipal, sync_source_id
|
|||||||
book.sync_status = None
|
book.sync_status = None
|
||||||
book.sync_error = None
|
book.sync_error = None
|
||||||
book.updated_by_account_id = _account_id(principal)
|
book.updated_by_account_id = _account_id(principal)
|
||||||
|
_audit_sync_credential_deletion(session, principal, sync_source)
|
||||||
session.delete(sync_source)
|
session.delete(sync_source)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_sync_credential_deletion(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
sync_source: AddressSyncSource,
|
||||||
|
) -> None:
|
||||||
|
"""Audit removal of source-owned credential material in the DB transaction.
|
||||||
|
|
||||||
|
CardDAV credentials are encrypted inside the sync-source row. Deleting that
|
||||||
|
row therefore deletes the credential atomically with the connector. Legacy
|
||||||
|
credential references are detached from the source, but are never resolved
|
||||||
|
or sent to an external provider because ownership cannot be proven.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tenant_id = _tenant_id_or_none(principal)
|
||||||
|
user = getattr(principal, "user", None)
|
||||||
|
_record_sync_credential_deletion_audit(
|
||||||
|
session,
|
||||||
|
sync_source=sync_source,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=getattr(user, "id", None),
|
||||||
|
api_key_id=getattr(principal, "api_key_id", None),
|
||||||
|
deletion_reason="sync_source_deleted",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_sync_credential_deletion_audit(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
sync_source: AddressSyncSource,
|
||||||
|
tenant_id: str | None,
|
||||||
|
user_id: str | None,
|
||||||
|
api_key_id: str | None,
|
||||||
|
deletion_reason: str,
|
||||||
|
) -> bool:
|
||||||
|
auth = _carddav_auth_metadata(sync_source.metadata_)
|
||||||
|
if auth.get("secret_encrypted"):
|
||||||
|
storage_backend = "encrypted_database"
|
||||||
|
elif auth.get("credential_ref"):
|
||||||
|
storage_backend = "legacy_reference"
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
action="addresses.sync_credential_deleted",
|
||||||
|
scope="tenant" if tenant_id is not None else "system",
|
||||||
|
object_type="address_sync_credential",
|
||||||
|
object_id=sync_source.id,
|
||||||
|
details={
|
||||||
|
"sync_source_id": sync_source.id,
|
||||||
|
"connector_type": sync_source.connector_type,
|
||||||
|
"storage_backend": storage_backend,
|
||||||
|
"deletion_reason": deletion_reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def audit_address_credentials_for_retirement(session: Session) -> int:
|
||||||
|
"""Audit credentials that the ensuing destructive table drop deletes."""
|
||||||
|
|
||||||
|
sources = session.query(AddressSyncSource).order_by(AddressSyncSource.id.asc()).all()
|
||||||
|
audited = 0
|
||||||
|
for source in sources:
|
||||||
|
if _record_sync_credential_deletion_audit(
|
||||||
|
session,
|
||||||
|
sync_source=source,
|
||||||
|
tenant_id=source.tenant_id,
|
||||||
|
user_id=None,
|
||||||
|
api_key_id=None,
|
||||||
|
deletion_reason="module_data_retired",
|
||||||
|
):
|
||||||
|
audited += 1
|
||||||
|
session.flush()
|
||||||
|
return audited
|
||||||
|
|
||||||
|
|
||||||
def start_sync_attempt(session: Session, principal: ApiPrincipal, sync_source_id: str) -> AddressSyncSource:
|
def start_sync_attempt(session: Session, principal: ApiPrincipal, sync_source_id: str) -> AddressSyncSource:
|
||||||
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
||||||
if not sync_source.enabled:
|
if not sync_source.enabled:
|
||||||
@@ -588,6 +680,7 @@ def discover_carddav_address_books(
|
|||||||
principal: ApiPrincipal,
|
principal: ApiPrincipal,
|
||||||
payload: AddressCardDavDiscoveryRequest,
|
payload: AddressCardDavDiscoveryRequest,
|
||||||
) -> list[AddressCardDAVAddressBook]:
|
) -> list[AddressCardDAVAddressBook]:
|
||||||
|
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
|
||||||
source = get_visible_sync_source(session, principal, payload.source_id) if payload.source_id else None
|
source = get_visible_sync_source(session, principal, payload.source_id) if payload.source_id else None
|
||||||
client = _carddav_client_from_payload(session, principal, payload, source=source)
|
client = _carddav_client_from_payload(session, principal, payload, source=source)
|
||||||
return client.discover_addressbooks()
|
return client.discover_addressbooks()
|
||||||
@@ -599,6 +692,7 @@ def create_carddav_sync_source(
|
|||||||
address_book_id: str,
|
address_book_id: str,
|
||||||
payload: AddressCardDavSourceCreateRequest,
|
payload: AddressCardDavSourceCreateRequest,
|
||||||
) -> AddressSyncSource:
|
) -> AddressSyncSource:
|
||||||
|
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
|
||||||
collection_url = ensure_collection_url(payload.collection_url)
|
collection_url = ensure_collection_url(payload.collection_url)
|
||||||
display_name = _trim(payload.display_name) or "CardDAV address book"
|
display_name = _trim(payload.display_name) or "CardDAV address book"
|
||||||
metadata = _carddav_metadata(
|
metadata = _carddav_metadata(
|
||||||
@@ -606,7 +700,7 @@ def create_carddav_sync_source(
|
|||||||
username=payload.username,
|
username=payload.username,
|
||||||
password=_secret_value(payload.password),
|
password=_secret_value(payload.password),
|
||||||
bearer_token=_secret_value(payload.bearer_token),
|
bearer_token=_secret_value(payload.bearer_token),
|
||||||
credential_ref=payload.credential_ref,
|
credential_ref=None,
|
||||||
collection_url=collection_url,
|
collection_url=collection_url,
|
||||||
)
|
)
|
||||||
return create_sync_source(
|
return create_sync_source(
|
||||||
@@ -624,6 +718,7 @@ def create_carddav_sync_source(
|
|||||||
remote_revision=payload.remote_revision,
|
remote_revision=payload.remote_revision,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
),
|
),
|
||||||
|
trusted_connector_metadata=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -759,152 +854,358 @@ def _plan_carddav_report(
|
|||||||
for item in report.objects:
|
for item in report.objects:
|
||||||
href = item.href
|
href = item.href
|
||||||
seen_hrefs.add(href)
|
seen_hrefs.add(href)
|
||||||
local = local_by_href.get(href)
|
_plan_carddav_report_object(
|
||||||
if item.deleted:
|
sync_source=sync_source,
|
||||||
if local is not None and local.deleted_at is None:
|
client=client,
|
||||||
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
|
item=item,
|
||||||
_add_sync_plan_item(
|
local=local_by_href.get(href),
|
||||||
plan,
|
reads_remote=reads_remote,
|
||||||
AddressSyncPlanItem(
|
plan=plan,
|
||||||
action="conflict",
|
)
|
||||||
href=href,
|
|
||||||
contact_id=local.id,
|
|
||||||
display_name=local.display_name,
|
|
||||||
etag=item.etag,
|
|
||||||
message="Remote object was deleted while the local contact changed since the last successful sync.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
elif reads_remote:
|
|
||||||
_add_sync_plan_item(plan, AddressSyncPlanItem(action="delete", href=href, contact_id=local.id, display_name=local.display_name, etag=item.etag))
|
|
||||||
else:
|
|
||||||
_add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, etag=item.etag, message="Remote delete already reflected locally."))
|
|
||||||
continue
|
|
||||||
|
|
||||||
raw_vcard = item.address_data
|
if plan.stats.full_sync:
|
||||||
if not raw_vcard:
|
_plan_carddav_full_sync_absences(
|
||||||
try:
|
sync_source=sync_source,
|
||||||
raw_vcard = client.fetch_object(href)
|
local_by_href=local_by_href,
|
||||||
except AddressCardDAVError as exc:
|
seen_hrefs=seen_hrefs,
|
||||||
_add_sync_plan_item(plan, AddressSyncPlanItem(action="error", href=href, etag=item.etag, message=str(exc)))
|
reads_remote=reads_remote,
|
||||||
continue
|
plan=plan,
|
||||||
parsed, error_message = _parse_single_remote_vcard(raw_vcard)
|
)
|
||||||
if parsed is None:
|
|
||||||
_add_sync_plan_item(plan, AddressSyncPlanItem(action="error", href=href, etag=item.etag, message=error_message or "Remote vCard could not be parsed."))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if local is None:
|
_plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan)
|
||||||
if reads_remote:
|
|
||||||
_add_sync_plan_item(
|
|
||||||
plan,
|
|
||||||
AddressSyncPlanItem(
|
|
||||||
action="create",
|
|
||||||
href=href,
|
|
||||||
remote_uid=parsed.source_ref,
|
|
||||||
display_name=parsed.payload.display_name,
|
|
||||||
etag=item.etag,
|
|
||||||
raw_vcard=raw_vcard,
|
|
||||||
parsed_payload=parsed.payload,
|
|
||||||
source_revision=item.etag or parsed.source_revision,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, etag=item.etag, message="Remote object ignored by export-only sync source."))
|
|
||||||
continue
|
|
||||||
|
|
||||||
remote_revision = item.etag or parsed.source_revision
|
|
||||||
if local.deleted_at is not None and _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
|
def _plan_carddav_report_object(
|
||||||
|
*,
|
||||||
|
sync_source: AddressSyncSource,
|
||||||
|
client: AddressCardDAVClient,
|
||||||
|
item: AddressCardDAVObject,
|
||||||
|
local: Contact | None,
|
||||||
|
reads_remote: bool,
|
||||||
|
plan: AddressSyncPlan,
|
||||||
|
) -> None:
|
||||||
|
if item.deleted:
|
||||||
|
_plan_carddav_remote_deletion(
|
||||||
|
sync_source=sync_source,
|
||||||
|
item=item,
|
||||||
|
local=local,
|
||||||
|
reads_remote=reads_remote,
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
loaded = _load_carddav_report_vcard(client=client, item=item, plan=plan)
|
||||||
|
if loaded is None:
|
||||||
|
return
|
||||||
|
raw_vcard, parsed = loaded
|
||||||
|
_plan_carddav_live_object(
|
||||||
|
sync_source=sync_source,
|
||||||
|
item=item,
|
||||||
|
local=local,
|
||||||
|
reads_remote=reads_remote,
|
||||||
|
raw_vcard=raw_vcard,
|
||||||
|
parsed=parsed,
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_carddav_remote_deletion(
|
||||||
|
*,
|
||||||
|
sync_source: AddressSyncSource,
|
||||||
|
item: AddressCardDAVObject,
|
||||||
|
local: Contact | None,
|
||||||
|
reads_remote: bool,
|
||||||
|
plan: AddressSyncPlan,
|
||||||
|
) -> None:
|
||||||
|
if local is None or local.deleted_at is not None:
|
||||||
|
_add_sync_plan_item(
|
||||||
|
plan,
|
||||||
|
AddressSyncPlanItem(
|
||||||
|
action="unchanged",
|
||||||
|
href=item.href,
|
||||||
|
etag=item.etag,
|
||||||
|
message="Remote delete already reflected locally.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
|
||||||
|
_add_sync_plan_item(
|
||||||
|
plan,
|
||||||
|
AddressSyncPlanItem(
|
||||||
|
action="conflict",
|
||||||
|
href=item.href,
|
||||||
|
contact_id=local.id,
|
||||||
|
display_name=local.display_name,
|
||||||
|
etag=item.etag,
|
||||||
|
message="Remote object was deleted while the local contact changed since the last successful sync.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif reads_remote:
|
||||||
|
_add_sync_plan_item(
|
||||||
|
plan,
|
||||||
|
AddressSyncPlanItem(
|
||||||
|
action="delete",
|
||||||
|
href=item.href,
|
||||||
|
contact_id=local.id,
|
||||||
|
display_name=local.display_name,
|
||||||
|
etag=item.etag,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_carddav_report_vcard(
|
||||||
|
*,
|
||||||
|
client: AddressCardDAVClient,
|
||||||
|
item: AddressCardDAVObject,
|
||||||
|
plan: AddressSyncPlan,
|
||||||
|
) -> tuple[str, ParsedVCard] | None:
|
||||||
|
raw_vcard = item.address_data
|
||||||
|
if not raw_vcard:
|
||||||
|
try:
|
||||||
|
raw_vcard = client.fetch_object(item.href)
|
||||||
|
except AddressCardDAVError as exc:
|
||||||
_add_sync_plan_item(
|
_add_sync_plan_item(
|
||||||
plan,
|
plan,
|
||||||
AddressSyncPlanItem(
|
AddressSyncPlanItem(action="error", href=item.href, etag=item.etag, message=str(exc)),
|
||||||
action="remote_delete",
|
|
||||||
href=href,
|
|
||||||
remote_uid=parsed.source_ref,
|
|
||||||
contact_id=local.id,
|
|
||||||
display_name=local.display_name,
|
|
||||||
etag=item.etag or local.source_revision,
|
|
||||||
message="Local delete will be pushed to CardDAV.",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
continue
|
return None
|
||||||
|
parsed, error_message = _parse_single_remote_vcard(raw_vcard)
|
||||||
|
if parsed is None:
|
||||||
|
_add_sync_plan_item(
|
||||||
|
plan,
|
||||||
|
AddressSyncPlanItem(
|
||||||
|
action="error",
|
||||||
|
href=item.href,
|
||||||
|
etag=item.etag,
|
||||||
|
message=error_message or "Remote vCard could not be parsed.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return raw_vcard, parsed
|
||||||
|
|
||||||
if local.deleted_at is None and local.source_revision == remote_revision:
|
|
||||||
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
|
|
||||||
_add_sync_plan_item(
|
|
||||||
plan,
|
|
||||||
AddressSyncPlanItem(
|
|
||||||
action="remote_update",
|
|
||||||
href=href,
|
|
||||||
remote_uid=parsed.source_ref,
|
|
||||||
contact_id=local.id,
|
|
||||||
display_name=local.display_name,
|
|
||||||
etag=item.etag or local.source_revision,
|
|
||||||
raw_vcard=_carddav_contact_vcard(local, href=href),
|
|
||||||
source_revision=item.etag or local.source_revision,
|
|
||||||
message="Local update will be pushed to CardDAV.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
_add_sync_plan_item(
|
|
||||||
plan,
|
|
||||||
AddressSyncPlanItem(action="unchanged", href=href, remote_uid=parsed.source_ref, contact_id=local.id, display_name=local.display_name, etag=item.etag),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if _local_contact_changed_after_last_sync(local, sync_source):
|
def _plan_carddav_live_object(
|
||||||
|
*,
|
||||||
|
sync_source: AddressSyncSource,
|
||||||
|
item: AddressCardDAVObject,
|
||||||
|
local: Contact | None,
|
||||||
|
reads_remote: bool,
|
||||||
|
raw_vcard: str,
|
||||||
|
parsed: ParsedVCard,
|
||||||
|
plan: AddressSyncPlan,
|
||||||
|
) -> None:
|
||||||
|
if local is None:
|
||||||
|
_plan_new_carddav_remote_object(
|
||||||
|
item=item,
|
||||||
|
reads_remote=reads_remote,
|
||||||
|
raw_vcard=raw_vcard,
|
||||||
|
parsed=parsed,
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
remote_revision = item.etag or parsed.source_revision
|
||||||
|
if _carddav_local_delete_needs_push(local, sync_source):
|
||||||
|
_add_sync_plan_item(
|
||||||
|
plan,
|
||||||
|
AddressSyncPlanItem(
|
||||||
|
action="remote_delete",
|
||||||
|
href=item.href,
|
||||||
|
remote_uid=parsed.source_ref,
|
||||||
|
contact_id=local.id,
|
||||||
|
display_name=local.display_name,
|
||||||
|
etag=item.etag or local.source_revision,
|
||||||
|
message="Local delete will be pushed to CardDAV.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if local.deleted_at is None and local.source_revision == remote_revision:
|
||||||
|
_plan_matching_carddav_revision(
|
||||||
|
sync_source=sync_source,
|
||||||
|
item=item,
|
||||||
|
local=local,
|
||||||
|
parsed=parsed,
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if _local_contact_changed_after_last_sync(local, sync_source):
|
||||||
|
_add_sync_plan_item(
|
||||||
|
plan,
|
||||||
|
AddressSyncPlanItem(
|
||||||
|
action="conflict",
|
||||||
|
href=item.href,
|
||||||
|
remote_uid=parsed.source_ref,
|
||||||
|
contact_id=local.id,
|
||||||
|
display_name=local.display_name,
|
||||||
|
etag=item.etag,
|
||||||
|
raw_vcard=raw_vcard,
|
||||||
|
parsed_payload=parsed.payload,
|
||||||
|
source_revision=item.etag or parsed.source_revision,
|
||||||
|
message="Local contact changed since the last successful sync.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_plan_carddav_remote_update(
|
||||||
|
item=item,
|
||||||
|
local=local,
|
||||||
|
reads_remote=reads_remote,
|
||||||
|
raw_vcard=raw_vcard,
|
||||||
|
parsed=parsed,
|
||||||
|
remote_revision=remote_revision,
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_new_carddav_remote_object(
|
||||||
|
*,
|
||||||
|
item: AddressCardDAVObject,
|
||||||
|
reads_remote: bool,
|
||||||
|
raw_vcard: str,
|
||||||
|
parsed: ParsedVCard,
|
||||||
|
plan: AddressSyncPlan,
|
||||||
|
) -> None:
|
||||||
|
if reads_remote:
|
||||||
|
_add_sync_plan_item(
|
||||||
|
plan,
|
||||||
|
AddressSyncPlanItem(
|
||||||
|
action="create",
|
||||||
|
href=item.href,
|
||||||
|
remote_uid=parsed.source_ref,
|
||||||
|
display_name=parsed.payload.display_name,
|
||||||
|
etag=item.etag,
|
||||||
|
raw_vcard=raw_vcard,
|
||||||
|
parsed_payload=parsed.payload,
|
||||||
|
source_revision=item.etag or parsed.source_revision,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_add_sync_plan_item(
|
||||||
|
plan,
|
||||||
|
AddressSyncPlanItem(
|
||||||
|
action="unchanged",
|
||||||
|
href=item.href,
|
||||||
|
etag=item.etag,
|
||||||
|
message="Remote object ignored by export-only sync source.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _carddav_local_delete_needs_push(local: Contact, sync_source: AddressSyncSource) -> bool:
|
||||||
|
return bool(
|
||||||
|
local.deleted_at is not None
|
||||||
|
and _sync_source_writes_remote(sync_source)
|
||||||
|
and _local_contact_changed_after_last_sync(local, sync_source)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_matching_carddav_revision(
|
||||||
|
*,
|
||||||
|
sync_source: AddressSyncSource,
|
||||||
|
item: AddressCardDAVObject,
|
||||||
|
local: Contact,
|
||||||
|
parsed: ParsedVCard,
|
||||||
|
plan: AddressSyncPlan,
|
||||||
|
) -> None:
|
||||||
|
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
|
||||||
|
_add_sync_plan_item(
|
||||||
|
plan,
|
||||||
|
AddressSyncPlanItem(
|
||||||
|
action="remote_update",
|
||||||
|
href=item.href,
|
||||||
|
remote_uid=parsed.source_ref,
|
||||||
|
contact_id=local.id,
|
||||||
|
display_name=local.display_name,
|
||||||
|
etag=item.etag or local.source_revision,
|
||||||
|
raw_vcard=_carddav_contact_vcard(local, href=item.href),
|
||||||
|
source_revision=item.etag or local.source_revision,
|
||||||
|
message="Local update will be pushed to CardDAV.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_add_sync_plan_item(
|
||||||
|
plan,
|
||||||
|
AddressSyncPlanItem(
|
||||||
|
action="unchanged",
|
||||||
|
href=item.href,
|
||||||
|
remote_uid=parsed.source_ref,
|
||||||
|
contact_id=local.id,
|
||||||
|
display_name=local.display_name,
|
||||||
|
etag=item.etag,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_carddav_remote_update(
|
||||||
|
*,
|
||||||
|
item: AddressCardDAVObject,
|
||||||
|
local: Contact,
|
||||||
|
reads_remote: bool,
|
||||||
|
raw_vcard: str,
|
||||||
|
parsed: ParsedVCard,
|
||||||
|
remote_revision: str | None,
|
||||||
|
plan: AddressSyncPlan,
|
||||||
|
) -> None:
|
||||||
|
if reads_remote:
|
||||||
|
_add_sync_plan_item(
|
||||||
|
plan,
|
||||||
|
AddressSyncPlanItem(
|
||||||
|
action="update",
|
||||||
|
href=item.href,
|
||||||
|
remote_uid=parsed.source_ref,
|
||||||
|
contact_id=local.id,
|
||||||
|
display_name=parsed.payload.display_name or local.display_name,
|
||||||
|
etag=item.etag,
|
||||||
|
raw_vcard=raw_vcard,
|
||||||
|
parsed_payload=parsed.payload,
|
||||||
|
source_revision=remote_revision,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_add_sync_plan_item(
|
||||||
|
plan,
|
||||||
|
AddressSyncPlanItem(
|
||||||
|
action="unchanged",
|
||||||
|
href=item.href,
|
||||||
|
contact_id=local.id,
|
||||||
|
display_name=local.display_name,
|
||||||
|
etag=item.etag,
|
||||||
|
message="Remote update ignored by export-only sync source.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_carddav_full_sync_absences(
|
||||||
|
*,
|
||||||
|
sync_source: AddressSyncSource,
|
||||||
|
local_by_href: dict[str, Contact],
|
||||||
|
seen_hrefs: set[str],
|
||||||
|
reads_remote: bool,
|
||||||
|
plan: AddressSyncPlan,
|
||||||
|
) -> None:
|
||||||
|
for href, contact in local_by_href.items():
|
||||||
|
if href in seen_hrefs or contact.deleted_at is not None:
|
||||||
|
continue
|
||||||
|
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(contact, sync_source):
|
||||||
_add_sync_plan_item(
|
_add_sync_plan_item(
|
||||||
plan,
|
plan,
|
||||||
AddressSyncPlanItem(
|
AddressSyncPlanItem(
|
||||||
action="conflict",
|
action="conflict",
|
||||||
href=href,
|
href=href,
|
||||||
remote_uid=parsed.source_ref,
|
contact_id=contact.id,
|
||||||
contact_id=local.id,
|
display_name=contact.display_name,
|
||||||
display_name=local.display_name,
|
etag=contact.source_revision,
|
||||||
etag=item.etag,
|
message="Remote object is absent from full sync, but the local contact changed since the last successful sync.",
|
||||||
raw_vcard=raw_vcard,
|
|
||||||
parsed_payload=parsed.payload,
|
|
||||||
source_revision=item.etag or parsed.source_revision,
|
|
||||||
message="Local contact changed since the last successful sync.",
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
continue
|
elif reads_remote:
|
||||||
|
|
||||||
if reads_remote:
|
|
||||||
_add_sync_plan_item(
|
_add_sync_plan_item(
|
||||||
plan,
|
plan,
|
||||||
AddressSyncPlanItem(
|
AddressSyncPlanItem(
|
||||||
action="update",
|
action="delete",
|
||||||
href=href,
|
href=href,
|
||||||
remote_uid=parsed.source_ref,
|
contact_id=contact.id,
|
||||||
contact_id=local.id,
|
display_name=contact.display_name,
|
||||||
display_name=parsed.payload.display_name or local.display_name,
|
message="Remote object is absent from full sync.",
|
||||||
etag=item.etag,
|
|
||||||
raw_vcard=raw_vcard,
|
|
||||||
parsed_payload=parsed.payload,
|
|
||||||
source_revision=remote_revision,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
_add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, contact_id=local.id, display_name=local.display_name, etag=item.etag, message="Remote update ignored by export-only sync source."))
|
|
||||||
|
|
||||||
if plan.stats.full_sync:
|
|
||||||
for href, contact in local_by_href.items():
|
|
||||||
if href not in seen_hrefs and contact.deleted_at is None:
|
|
||||||
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(contact, sync_source):
|
|
||||||
_add_sync_plan_item(
|
|
||||||
plan,
|
|
||||||
AddressSyncPlanItem(
|
|
||||||
action="conflict",
|
|
||||||
href=href,
|
|
||||||
contact_id=contact.id,
|
|
||||||
display_name=contact.display_name,
|
|
||||||
etag=contact.source_revision,
|
|
||||||
message="Remote object is absent from full sync, but the local contact changed since the last successful sync.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
elif reads_remote:
|
|
||||||
_add_sync_plan_item(plan, AddressSyncPlanItem(action="delete", href=href, contact_id=contact.id, display_name=contact.display_name, message="Remote object is absent from full sync."))
|
|
||||||
|
|
||||||
_plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan)
|
|
||||||
|
|
||||||
|
|
||||||
def _plan_carddav_outbound_local_changes(
|
def _plan_carddav_outbound_local_changes(
|
||||||
@@ -1308,6 +1609,7 @@ def _carddav_client_from_payload(
|
|||||||
*,
|
*,
|
||||||
source: AddressSyncSource | None = None,
|
source: AddressSyncSource | None = None,
|
||||||
) -> AddressCardDAVClient:
|
) -> AddressCardDAVClient:
|
||||||
|
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
|
||||||
url = payload.url or (source.external_address_book_ref if source else "")
|
url = payload.url or (source.external_address_book_ref if source else "")
|
||||||
metadata = dict(source.metadata_ or {}) if source else {}
|
metadata = dict(source.metadata_ or {}) if source else {}
|
||||||
auth = dict(metadata.get("carddav") or {})
|
auth = dict(metadata.get("carddav") or {})
|
||||||
@@ -1391,13 +1693,83 @@ def _resolve_carddav_secret(
|
|||||||
return password
|
return password
|
||||||
if auth_type == "bearer" and bearer_token:
|
if auth_type == "bearer" and bearer_token:
|
||||||
return bearer_token
|
return bearer_token
|
||||||
if credential_ref and credential_ref.startswith(CARDDAV_SECRET_ENV_PREFIX):
|
if credential_ref:
|
||||||
return os.environ.get(credential_ref.removeprefix(CARDDAV_SECRET_ENV_PREFIX))
|
raise AddressBookError(
|
||||||
|
"The CardDAV credential reference is not a server-owned credential; provide a replacement password or token"
|
||||||
|
)
|
||||||
if encrypted:
|
if encrypted:
|
||||||
return decrypt_secret(encrypted)
|
return decrypt_secret(encrypted)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_trusted_deployment_carddav_credential_ref(credential_ref: str) -> str | None:
|
||||||
|
"""Resolve env-backed credentials only for trusted deployment code.
|
||||||
|
|
||||||
|
API-managed discovery and sync-source paths deliberately never call this
|
||||||
|
function, so a tenant user cannot select arbitrary process environment
|
||||||
|
variables as connector credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not credential_ref.startswith(CARDDAV_SECRET_ENV_PREFIX):
|
||||||
|
raise AddressBookError("Trusted deployment credential references must use the env: prefix")
|
||||||
|
env_name = credential_ref.removeprefix(CARDDAV_SECRET_ENV_PREFIX)
|
||||||
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_name):
|
||||||
|
raise AddressBookError("Trusted deployment credential reference contains an invalid environment variable name")
|
||||||
|
return os.environ.get(env_name)
|
||||||
|
|
||||||
|
|
||||||
|
def public_address_sync_metadata(metadata: object) -> dict[str, Any]:
|
||||||
|
payload = copy.deepcopy(metadata) if isinstance(metadata, dict) else {}
|
||||||
|
auth = payload.get("carddav")
|
||||||
|
if not isinstance(auth, dict):
|
||||||
|
return payload
|
||||||
|
had_credential = bool(auth.get("secret_encrypted") or auth.get("credential_ref"))
|
||||||
|
auth.pop("secret_encrypted", None)
|
||||||
|
auth.pop("credential_ref", None)
|
||||||
|
auth["has_credential"] = had_credential
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_caller_carddav_credential_ref(credential_ref: str | None) -> None:
|
||||||
|
if _trim(credential_ref):
|
||||||
|
raise AddressBookError(
|
||||||
|
"Caller-supplied credential references are not accepted; provide a password or bearer token"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _carddav_auth_metadata(metadata: object) -> dict[str, Any]:
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return {}
|
||||||
|
carddav = metadata.get("carddav")
|
||||||
|
return carddav if isinstance(carddav, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_api_carddav_metadata_safe(metadata: object) -> None:
|
||||||
|
auth = _carddav_auth_metadata(metadata)
|
||||||
|
if auth.get("credential_ref") or auth.get("secret_encrypted"):
|
||||||
|
raise AddressBookError(
|
||||||
|
"CardDAV credential references and encrypted secrets are server-managed; provide credentials through the CardDAV source endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_server_owned_carddav_metadata(existing: object, incoming: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
merged = copy.deepcopy(incoming)
|
||||||
|
existing_auth = _carddav_auth_metadata(existing)
|
||||||
|
server_owned = {
|
||||||
|
key: existing_auth[key]
|
||||||
|
for key in ("credential_ref", "secret_encrypted")
|
||||||
|
if existing_auth.get(key)
|
||||||
|
}
|
||||||
|
if not server_owned:
|
||||||
|
return merged
|
||||||
|
auth = merged.get("carddav")
|
||||||
|
if not isinstance(auth, dict):
|
||||||
|
auth = {}
|
||||||
|
merged["carddav"] = auth
|
||||||
|
auth.update(server_owned)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _secret_value(value: Any | None) -> str | None:
|
def _secret_value(value: Any | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, inspect
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult
|
from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult
|
||||||
@@ -15,6 +17,7 @@ from govoplan_addresses.backend.capabilities import (
|
|||||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
||||||
AddressesContactWriterCapability,
|
AddressesContactWriterCapability,
|
||||||
AddressesLookupCapability,
|
AddressesLookupCapability,
|
||||||
|
AddressesPeopleSearchProvider,
|
||||||
AddressesRecipientSourceCapability,
|
AddressesRecipientSourceCapability,
|
||||||
AddressWriterError,
|
AddressWriterError,
|
||||||
)
|
)
|
||||||
@@ -33,6 +36,7 @@ from govoplan_addresses.backend.db.models import (
|
|||||||
)
|
)
|
||||||
from govoplan_addresses.backend.schemas import (
|
from govoplan_addresses.backend.schemas import (
|
||||||
AddressBookCreateRequest,
|
AddressBookCreateRequest,
|
||||||
|
AddressCardDavDiscoveryRequest,
|
||||||
AddressListCreateRequest,
|
AddressListCreateRequest,
|
||||||
AddressListEntryCreateRequest,
|
AddressListEntryCreateRequest,
|
||||||
AddressCardDavSourceCreateRequest,
|
AddressCardDavSourceCreateRequest,
|
||||||
@@ -48,6 +52,7 @@ from govoplan_addresses.backend.schemas import (
|
|||||||
ContactPostalAddressPayload,
|
ContactPostalAddressPayload,
|
||||||
)
|
)
|
||||||
from govoplan_addresses.backend.manifest import manifest
|
from govoplan_addresses.backend.manifest import manifest
|
||||||
|
from govoplan_addresses.backend.router import _sync_source_response
|
||||||
from govoplan_addresses.backend.service import (
|
from govoplan_addresses.backend.service import (
|
||||||
AddressBookError,
|
AddressBookError,
|
||||||
address_book_contact_counts,
|
address_book_contact_counts,
|
||||||
@@ -61,6 +66,7 @@ from govoplan_addresses.backend.service import (
|
|||||||
delete_address_list_entry,
|
delete_address_list_entry,
|
||||||
delete_contact,
|
delete_contact,
|
||||||
delete_sync_source,
|
delete_sync_source,
|
||||||
|
discover_carddav_address_books,
|
||||||
export_address_book_vcard,
|
export_address_book_vcard,
|
||||||
import_vcards,
|
import_vcards,
|
||||||
list_address_list_entries,
|
list_address_list_entries,
|
||||||
@@ -81,6 +87,7 @@ from govoplan_addresses.backend.service import (
|
|||||||
start_sync_attempt,
|
start_sync_attempt,
|
||||||
finish_sync_attempt,
|
finish_sync_attempt,
|
||||||
update_sync_source,
|
update_sync_source,
|
||||||
|
resolve_trusted_deployment_carddav_credential_ref,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -158,9 +165,9 @@ class AddressBookAdminPrincipal(NoGroupPrincipal):
|
|||||||
|
|
||||||
class AddressServiceTest(unittest.TestCase):
|
class AddressServiceTest(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
engine = create_engine("sqlite:///:memory:")
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
Base.metadata.create_all(
|
Base.metadata.create_all(
|
||||||
engine,
|
self.engine,
|
||||||
tables=[
|
tables=[
|
||||||
AddressBook.__table__,
|
AddressBook.__table__,
|
||||||
AddressList.__table__,
|
AddressList.__table__,
|
||||||
@@ -176,9 +183,14 @@ class AddressServiceTest(unittest.TestCase):
|
|||||||
ChangeSequenceEntry.__table__,
|
ChangeSequenceEntry.__table__,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.session = sessionmaker(bind=engine)()
|
self.session = sessionmaker(bind=self.engine)()
|
||||||
self.principal = Principal()
|
self.principal = Principal()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
def test_group_book_creation_requires_membership_or_admin_scope(self) -> None:
|
def test_group_book_creation_requires_membership_or_admin_scope(self) -> None:
|
||||||
with self.assertRaisesRegex(AddressBookError, "selected group is not visible"):
|
with self.assertRaisesRegex(AddressBookError, "selected group is not visible"):
|
||||||
create_address_book(
|
create_address_book(
|
||||||
@@ -335,8 +347,17 @@ END:VCARD
|
|||||||
self.assertEqual(len(lookup), 1)
|
self.assertEqual(len(lookup), 1)
|
||||||
self.assertEqual(lookup[0].email, "ada@example.local")
|
self.assertEqual(lookup[0].email, "ada@example.local")
|
||||||
self.assertEqual(lookup[0].contact_id, contact.id)
|
self.assertEqual(lookup[0].contact_id, contact.id)
|
||||||
|
people_provider = AddressesPeopleSearchProvider()
|
||||||
|
self.assertIsInstance(people_provider, PeopleSearchProvider)
|
||||||
|
people = people_provider.search_people(self.session, self.principal, query="ada")
|
||||||
|
self.assertEqual(people[0].key, "contacts")
|
||||||
|
self.assertEqual(people[0].candidates[0].reference_id, contact.id)
|
||||||
|
self.assertEqual(people[0].candidates[0].source_ref, f"addresses:contact:{contact.id}")
|
||||||
|
self.assertEqual(people[0].candidates[0].metadata["address_book_id"], book.id)
|
||||||
warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10)
|
warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10)
|
||||||
self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"])
|
self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"])
|
||||||
|
self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, manifest.capability_factories)
|
||||||
|
self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
|
||||||
|
|
||||||
recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal)
|
recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal)
|
||||||
self.assertEqual(len(recipient_sources), 1)
|
self.assertEqual(len(recipient_sources), 1)
|
||||||
@@ -782,6 +803,118 @@ END:VCARD
|
|||||||
self.assertEqual(contacts[0].display_name, "Ada Remote")
|
self.assertEqual(contacts[0].display_name, "Ada Remote")
|
||||||
self.assertEqual(contacts[0].emails[0].email, "ada.remote@example.local")
|
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"}},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
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"))
|
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Writable Remote"))
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
@@ -929,6 +1062,107 @@ END:VCARD
|
|||||||
self.assertFalse(book.read_only)
|
self.assertFalse(book.read_only)
|
||||||
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
|
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
|
||||||
|
|
||||||
|
def test_delete_sync_source_deletes_and_audits_encrypted_credential_atomically(self) -> None:
|
||||||
|
book = create_address_book(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
AddressBookCreateRequest(scope_type="user", name="Audited remote"),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(book)
|
||||||
|
source = create_carddav_sync_source(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
book.id,
|
||||||
|
AddressCardDavSourceCreateRequest(
|
||||||
|
collection_url="https://carddav.example.local/addressbooks/audited/",
|
||||||
|
auth_type="basic",
|
||||||
|
username="ada",
|
||||||
|
password="do-not-audit-this",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
source_id = source.id
|
||||||
|
|
||||||
|
with patch("govoplan_addresses.backend.service.audit_event") as audit:
|
||||||
|
delete_sync_source(self.session, self.principal, source_id)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertIsNone(self.session.get(AddressSyncSource, source_id))
|
||||||
|
audit.assert_called_once()
|
||||||
|
audit_call = audit.call_args.kwargs
|
||||||
|
self.assertEqual(audit_call["action"], "addresses.sync_credential_deleted")
|
||||||
|
self.assertEqual(audit_call["object_id"], source_id)
|
||||||
|
self.assertEqual(audit_call["details"]["storage_backend"], "encrypted_database")
|
||||||
|
self.assertNotIn("credential_ref", audit_call["details"])
|
||||||
|
self.assertNotIn("secret_encrypted", audit_call["details"])
|
||||||
|
self.assertNotIn("do-not-audit-this", repr(audit_call))
|
||||||
|
|
||||||
|
def test_delete_sync_source_is_not_staged_when_credential_audit_fails(self) -> None:
|
||||||
|
book = create_address_book(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
AddressBookCreateRequest(scope_type="user", name="Audit failure"),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(book)
|
||||||
|
source = create_carddav_sync_source(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
book.id,
|
||||||
|
AddressCardDavSourceCreateRequest(
|
||||||
|
collection_url="https://carddav.example.local/addressbooks/audit-failure/",
|
||||||
|
auth_type="bearer",
|
||||||
|
bearer_token="do-not-audit-this",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_addresses.backend.service.audit_event",
|
||||||
|
side_effect=RuntimeError("audit unavailable"),
|
||||||
|
), self.assertRaisesRegex(RuntimeError, "audit unavailable"):
|
||||||
|
delete_sync_source(self.session, self.principal, source.id)
|
||||||
|
|
||||||
|
self.session.rollback()
|
||||||
|
persisted = self.session.get(AddressSyncSource, source.id)
|
||||||
|
self.assertIsNotNone(persisted)
|
||||||
|
self.assertIn("secret_encrypted", (persisted.metadata_ or {})["carddav"])
|
||||||
|
|
||||||
|
def test_destructive_module_retirement_audits_credentials_before_tables_are_dropped(self) -> None:
|
||||||
|
book = create_address_book(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
AddressBookCreateRequest(scope_type="user", name="Retired module"),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(book)
|
||||||
|
source = create_carddav_sync_source(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
book.id,
|
||||||
|
AddressCardDavSourceCreateRequest(
|
||||||
|
collection_url="https://carddav.example.local/addressbooks/module-retirement/",
|
||||||
|
auth_type="bearer",
|
||||||
|
bearer_token="do-not-audit-this",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
source_id = source.id
|
||||||
|
retirement_provider = manifest.migration_spec.retirement_provider
|
||||||
|
assert retirement_provider is not None
|
||||||
|
plan = retirement_provider(self.session, "addresses")
|
||||||
|
assert plan.destroy_data_executor is not None
|
||||||
|
|
||||||
|
with patch("govoplan_addresses.backend.service.audit_event") as audit:
|
||||||
|
plan.destroy_data_executor(self.session, "addresses")
|
||||||
|
|
||||||
|
audit.assert_called_once()
|
||||||
|
audit_call = audit.call_args.kwargs
|
||||||
|
self.assertEqual(audit_call["object_id"], source_id)
|
||||||
|
self.assertEqual(audit_call["details"]["deletion_reason"], "module_data_retired")
|
||||||
|
self.assertNotIn("do-not-audit-this", repr(audit_call))
|
||||||
|
self.assertFalse(inspect(self.engine).has_table("addresses_sync_sources"))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
182
tests/test_carddav_security.py
Normal file
182
tests/test_carddav_security.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
|
||||||
|
from govoplan_addresses.backend.carddav import (
|
||||||
|
AddressCardDAVClient,
|
||||||
|
AddressCardDAVError,
|
||||||
|
absolute_dav_url,
|
||||||
|
urllib_transport,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.router import api_discover_carddav_address_books
|
||||||
|
from govoplan_addresses.backend.schemas import AddressCardDavDiscoveryRequest
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
host, port = server.server_address
|
||||||
|
yield f"http://{host}:{port}"
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
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/"
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
absolute_dav_url(base_url, "/principals/users/ada/"),
|
||||||
|
"https://dav.example.test/principals/users/ada/",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(AddressCardDAVError, "configured collection origin"):
|
||||||
|
absolute_dav_url(base_url, "https://evil.example.test/steal/")
|
||||||
|
with self.assertRaisesRegex(AddressCardDAVError, "query or fragment"):
|
||||||
|
absolute_dav_url(base_url, "/principals/users/ada/?token=secret")
|
||||||
|
|
||||||
|
def test_object_href_must_remain_inside_configured_collection(self) -> None:
|
||||||
|
client = AddressCardDAVClient(collection_url="https://dav.example.test/addressbooks/ada")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(AddressCardDAVError, "collection origin"):
|
||||||
|
client.object_url("https://evil.example.test/steal.vcf")
|
||||||
|
with self.assertRaisesRegex(AddressCardDAVError, "collection path"):
|
||||||
|
client.object_url("https://dav.example.test/addressbooks/other/steal.vcf")
|
||||||
|
with self.assertRaisesRegex(AddressCardDAVError, "collection path"):
|
||||||
|
client.object_url("/addressbooks/ada/%2e%2e/other/steal.vcf")
|
||||||
|
with self.assertRaisesRegex(AddressCardDAVError, "query or fragment"):
|
||||||
|
client.object_url("/addressbooks/ada/contact.vcf?download=1")
|
||||||
|
self.assertEqual(
|
||||||
|
client.object_url("/addressbooks/ada/contact.vcf"),
|
||||||
|
"https://dav.example.test/addressbooks/ada/contact.vcf",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_transport_refuses_redirect_before_forwarding_authorization(self) -> None:
|
||||||
|
forwarded_authorization: list[str | None] = []
|
||||||
|
|
||||||
|
class TargetHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||||
|
forwarded_authorization.append(self.headers.get("Authorization"))
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, _format: str, *_args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
with running_http_server(TargetHandler) as target_url:
|
||||||
|
class RedirectHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", f"{target_url}/stolen.vcf")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, _format: str, *_args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
with running_http_server(RedirectHandler) as redirect_url:
|
||||||
|
status, _headers, _body = urllib_transport(
|
||||||
|
"GET",
|
||||||
|
f"{redirect_url}/contact.vcf",
|
||||||
|
{"Authorization": "Bearer top-secret"},
|
||||||
|
None,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(status, 302)
|
||||||
|
self.assertEqual(forwarded_authorization, [])
|
||||||
|
|
||||||
|
def test_transport_preserves_same_origin_redirects(self) -> None:
|
||||||
|
forwarded_authorization: list[str | None] = []
|
||||||
|
|
||||||
|
class RedirectHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||||
|
if self.path == "/contact.vcf":
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", "/redirected.vcf")
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
forwarded_authorization.append(self.headers.get("Authorization"))
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"contact")
|
||||||
|
|
||||||
|
def log_message(self, _format: str, *_args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
with running_http_server(RedirectHandler) as source_url:
|
||||||
|
status, _headers, body = urllib_transport(
|
||||||
|
"GET",
|
||||||
|
f"{source_url}/contact.vcf",
|
||||||
|
{"Authorization": "Bearer expected"},
|
||||||
|
None,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(body, b"contact")
|
||||||
|
self.assertEqual(forwarded_authorization, ["Bearer expected"])
|
||||||
|
|
||||||
|
|
||||||
|
class CardDAVDiscoveryAuthorizationTests(unittest.TestCase):
|
||||||
|
def test_sync_read_alone_cannot_start_authenticated_discovery(self) -> None:
|
||||||
|
principal = ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-read-only",
|
||||||
|
membership_id="membership-read-only",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset({"addresses:sync:read"}),
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("govoplan_addresses.backend.router.discover_carddav_address_books") as discover:
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
api_discover_carddav_address_books(
|
||||||
|
AddressCardDavDiscoveryRequest(
|
||||||
|
url="https://dav.example.test/",
|
||||||
|
auth_type="basic",
|
||||||
|
username="reader",
|
||||||
|
password="secret",
|
||||||
|
),
|
||||||
|
principal,
|
||||||
|
object(), # type: ignore[arg-type] - scope rejection precedes session use
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 403)
|
||||||
|
self.assertEqual(raised.exception.detail, "Missing scope: addresses:sync:write")
|
||||||
|
discover.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/addresses-webui",
|
"name": "@govoplan/addresses-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -13,8 +13,11 @@
|
|||||||
},
|
},
|
||||||
"./styles/addresses.css": "./src/styles/addresses.css"
|
"./styles/addresses.css": "./src/styles/addresses.css"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:ui-structure": "node scripts/test-selection-list-structure.mjs"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.11",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
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.");
|
||||||
@@ -450,7 +450,7 @@ export async function listAddressSyncSources(
|
|||||||
|
|
||||||
export function discoverCardDavAddressBooks(
|
export function discoverCardDavAddressBooks(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
payload: { url: string; auth_type: "none" | "basic" | "bearer"; username?: string | null; password?: string | null; bearer_token?: string | null; credential_ref?: string | null }
|
payload: { url: string; auth_type: "none" | "basic" | "bearer"; username?: string | null; password?: string | null; bearer_token?: string | null }
|
||||||
): Promise<AddressCardDavAddressBook[]> {
|
): Promise<AddressCardDavAddressBook[]> {
|
||||||
return apiFetch<AddressCardDavDiscoveryResponse>(settings, "/api/v1/addresses/carddav/discover", {
|
return apiFetch<AddressCardDavDiscoveryResponse>(settings, "/api/v1/addresses/carddav/discover", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -468,7 +468,6 @@ export function createCardDavSyncSource(
|
|||||||
username?: string | null;
|
username?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
bearer_token?: string | null;
|
bearer_token?: string | null;
|
||||||
credential_ref?: string | null;
|
|
||||||
sync_direction: "read_only" | "import" | "export" | "two_way";
|
sync_direction: "read_only" | "import" | "export" | "two_way";
|
||||||
read_only?: boolean | null;
|
read_only?: boolean | null;
|
||||||
sync_token?: string | null;
|
sync_token?: string | null;
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react";
|
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 {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Dialog,
|
Dialog,
|
||||||
DisabledActionTooltip,
|
DismissibleAlert,
|
||||||
ExplorerTree,
|
ExplorerTree,
|
||||||
fetchAuthGroups,
|
fetchAuthGroups,
|
||||||
|
formatDateTime,
|
||||||
FormField,
|
FormField,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
|
PasswordField,
|
||||||
|
SegmentedControl,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
hasScope,
|
hasScope,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo,
|
type AuthInfo,
|
||||||
type AuthUpdate
|
type AuthUpdate,
|
||||||
|
type FormatDateTimeOptions
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
createAddressBook,
|
createAddressBook,
|
||||||
@@ -141,7 +147,6 @@ type CardDavFormState = {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
bearer_token: string;
|
bearer_token: string;
|
||||||
credential_ref: string;
|
|
||||||
sync_direction: "read_only" | "import" | "export" | "two_way";
|
sync_direction: "read_only" | "import" | "export" | "two_way";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,7 +203,6 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = {
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
bearer_token: "",
|
bearer_token: "",
|
||||||
credential_ref: "",
|
|
||||||
sync_direction: "read_only"
|
sync_direction: "read_only"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -289,14 +293,17 @@ function syncSourceLabel(source: AddressSyncSource): string {
|
|||||||
return source.display_name || source.external_address_book_ref || source.connector_type;
|
return source.display_name || source.external_address_book_ref || source.connector_type;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value?: string | null): string {
|
const ADDRESS_DATE_TIME_OPTIONS: FormatDateTimeOptions = {
|
||||||
if (!value) return "Never";
|
fallback: "Never",
|
||||||
try {
|
year: undefined,
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
month: undefined,
|
||||||
} catch {
|
day: undefined,
|
||||||
return value;
|
hour: undefined,
|
||||||
}
|
minute: undefined,
|
||||||
}
|
timeZoneName: undefined,
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short"
|
||||||
|
};
|
||||||
|
|
||||||
function planSummary(plan: AddressSyncPlan | null): string {
|
function planSummary(plan: AddressSyncPlan | null): string {
|
||||||
if (!plan) return "No preview loaded.";
|
if (!plan) return "No preview loaded.";
|
||||||
@@ -604,19 +611,6 @@ function disabledReason(...conditions: Array<[boolean, string]>): string {
|
|||||||
return conditions.find(([applies]) => applies)?.[1] ?? "";
|
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) {
|
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
|
||||||
const [books, setBooks] = useState<AddressBook[]>([]);
|
const [books, setBooks] = useState<AddressBook[]>([]);
|
||||||
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
|
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
|
||||||
@@ -1528,8 +1522,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
auth_type: cardDavForm.auth_type,
|
auth_type: cardDavForm.auth_type,
|
||||||
username: cardDavForm.username || null,
|
username: cardDavForm.username || null,
|
||||||
password: cardDavForm.password || null,
|
password: cardDavForm.password || null,
|
||||||
bearer_token: cardDavForm.bearer_token || null,
|
bearer_token: cardDavForm.bearer_token || null
|
||||||
credential_ref: cardDavForm.credential_ref || null
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1570,7 +1563,6 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
username: cardDavForm.username || null,
|
username: cardDavForm.username || null,
|
||||||
password: cardDavForm.password || null,
|
password: cardDavForm.password || null,
|
||||||
bearer_token: cardDavForm.bearer_token || null,
|
bearer_token: cardDavForm.bearer_token || null,
|
||||||
credential_ref: cardDavForm.credential_ref || null,
|
|
||||||
sync_direction: cardDavForm.sync_direction,
|
sync_direction: cardDavForm.sync_direction,
|
||||||
read_only: cardDavForm.sync_direction === "read_only" || cardDavForm.sync_direction === "import"
|
read_only: cardDavForm.sync_direction === "read_only" || cardDavForm.sync_direction === "import"
|
||||||
});
|
});
|
||||||
@@ -1753,7 +1745,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
{selectedBookSyncSources.map((source) => (
|
{selectedBookSyncSources.map((source) => (
|
||||||
<p key={source.id}>
|
<p key={source.id}>
|
||||||
{syncSourceLabel(source)} · {source.connector_type} · {source.status}
|
{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}` : ""}
|
{source.last_error ? ` · ${source.last_error}` : ""}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
@@ -1777,27 +1769,27 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
function renderSelectedBookActions() {
|
function renderSelectedBookActions() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ReasonedButton type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></ReasonedButton>
|
<Button type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></Button>
|
||||||
<ReasonedButton type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></ReasonedButton>
|
<Button type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></Button>
|
||||||
<ReasonedButton type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></ReasonedButton>
|
<Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<ReasonedButton type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></ReasonedButton>
|
<Button type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></Button>
|
||||||
<ReasonedButton type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></ReasonedButton>
|
<Button type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></Button>
|
||||||
<ReasonedButton type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</ReasonedButton>
|
<Button type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</Button>
|
||||||
<ReasonedButton type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</ReasonedButton>
|
<Button type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</Button>
|
||||||
{selectedList ?
|
{selectedList ?
|
||||||
selectedList.deleted_at ?
|
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>
|
<Button type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></Button>
|
||||||
<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" 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 ?
|
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>
|
<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>
|
||||||
<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" 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 +1799,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
function renderContactRow(contact: Contact) {
|
function renderContactRow(contact: Contact) {
|
||||||
const selected = selectedContactId === contact.id;
|
const selected = selectedContactId === contact.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<SelectionListItem
|
||||||
type="button"
|
|
||||||
key={contact.id}
|
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}
|
draggable={!contact.deleted_at && !saving}
|
||||||
onClick={() => setSelectedContactId(contact.id)}
|
onClick={() => setSelectedContactId(contact.id)}
|
||||||
onDragStart={(event) => handleContactDragStart(event, contact)}>
|
onDragStart={(event) => handleContactDragStart(event, contact)}>
|
||||||
@@ -1821,7 +1813,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<span className="address-contact-row-meta">
|
<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>)}
|
{contact.deleted_at ? <StatusBadge status="archived" /> : contact.tags.slice(0, 2).map((tag) => <span key={tag} className="address-tag">{tag}</span>)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</SelectionListItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1843,11 +1835,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
{selectedContact.deleted_at ?
|
{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>
|
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
|
||||||
<ReasonedButton type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</ReasonedButton>
|
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
|
||||||
<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" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1864,13 +1856,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
{selectedContactListEntries.map((entry) => (
|
{selectedContactListEntries.map((entry) => (
|
||||||
<div className="address-membership-row" key={entry.id}>
|
<div className="address-membership-row" key={entry.id}>
|
||||||
<p className="muted">{entryTargetLabel(entry)}</p>
|
<p className="muted">{entryTargetLabel(entry)}</p>
|
||||||
<ReasonedButton
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => void removeAddressListEntry(entry)}
|
onClick={() => void removeAddressListEntry(entry)}
|
||||||
disabledReason={removeContactFromListReason()}>
|
disabledReason={removeContactFromListReason()}>
|
||||||
<X size={15} /> Remove
|
<X size={15} /> Remove
|
||||||
</ReasonedButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1927,8 +1919,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
|
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
|
||||||
{error && <div className="alert alert-error address-error">{error}</div>}
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
{notice && !error && <div className="alert alert-success address-error">{notice}</div>}
|
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="Loading address books..." className="address-workspace-frame">
|
<LoadingFrame loading={loading} label="Loading address books..." className="address-workspace-frame">
|
||||||
<div className="address-book-workspace">
|
<div className="address-book-workspace">
|
||||||
@@ -1987,9 +1979,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
{selectedList &&
|
{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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="address-contact-toolbar">
|
<div className="address-contact-toolbar">
|
||||||
@@ -1997,10 +1989,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
{selectedBook?.read_only && <StatusBadge status="read-only" />}
|
{selectedBook?.read_only && <StatusBadge status="read-only" />}
|
||||||
{selectedBook?.deleted_at && <StatusBadge status="archived" />}
|
{selectedBook?.deleted_at && <StatusBadge status="archived" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="address-contact-list" role="list">
|
<div className="address-contact-list">
|
||||||
{visibleContacts.length === 0 ?
|
{visibleContacts.length === 0 ?
|
||||||
<div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> :
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -2019,8 +2013,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
footerClassName="button-row compact-actions"
|
footerClassName="button-row compact-actions"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<ReasonedButton type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
|
<Button type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||||
<ReasonedButton type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</ReasonedButton>
|
<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)}>
|
<form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
|
||||||
@@ -2055,8 +2049,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
footerClassName="button-row compact-actions"
|
footerClassName="button-row compact-actions"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<ReasonedButton type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
|
<Button type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||||
<ReasonedButton type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</ReasonedButton>
|
<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)}>
|
<form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
|
||||||
@@ -2082,8 +2076,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
footerClassName="button-row compact-actions"
|
footerClassName="button-row compact-actions"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<ReasonedButton type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
|
<Button type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||||
<ReasonedButton type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</ReasonedButton>
|
<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)}>
|
<form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
|
||||||
@@ -2099,7 +2093,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<section className="address-form-section">
|
<section className="address-form-section">
|
||||||
<div className="address-form-section-heading">
|
<div className="address-form-section-heading">
|
||||||
<strong>Email addresses</strong>
|
<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>
|
||||||
<div className="address-form-list">
|
<div className="address-form-list">
|
||||||
{contactForm.emails.map((email) => (
|
{contactForm.emails.map((email) => (
|
||||||
@@ -2107,7 +2101,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>
|
<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 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 })} />
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2116,7 +2110,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<section className="address-form-section">
|
<section className="address-form-section">
|
||||||
<div className="address-form-section-heading">
|
<div className="address-form-section-heading">
|
||||||
<strong>Phone numbers</strong>
|
<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>
|
||||||
<div className="address-form-list">
|
<div className="address-form-list">
|
||||||
{contactForm.phones.map((phone) => (
|
{contactForm.phones.map((phone) => (
|
||||||
@@ -2124,7 +2118,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>
|
<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.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 })} />
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2133,7 +2127,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<section className="address-form-section">
|
<section className="address-form-section">
|
||||||
<div className="address-form-section-heading">
|
<div className="address-form-section-heading">
|
||||||
<strong>Postal addresses</strong>
|
<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>
|
||||||
<div className="address-form-list">
|
<div className="address-form-list">
|
||||||
{contactForm.postal_addresses.map((address) => (
|
{contactForm.postal_addresses.map((address) => (
|
||||||
@@ -2145,7 +2139,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.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.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 })} />
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2162,7 +2156,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
closeDisabled={saving}
|
closeDisabled={saving}
|
||||||
className="address-member-dialog"
|
className="address-member-dialog"
|
||||||
footerClassName="button-row compact-actions"
|
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-member-picker">
|
||||||
<div className="address-contact-toolbar address-member-search">
|
<div className="address-contact-toolbar address-member-search">
|
||||||
<input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus />
|
<input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus />
|
||||||
@@ -2186,12 +2180,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
disabled={saving || targetOptions.length === 0}>
|
disabled={saving || targetOptions.length === 0}>
|
||||||
{targetOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
|
{targetOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<ReasonedButton
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectedList && void addContactToAddressList(selectedList, contact, selectedTarget)}
|
onClick={() => selectedList && void addContactToAddressList(selectedList, contact, selectedTarget)}
|
||||||
disabledReason={addContactToListReason(selectedList, contact, selectedTarget?.key ?? "")}>
|
disabledReason={addContactToListReason(selectedList, contact, selectedTarget?.key ?? "")}>
|
||||||
<Plus size={15} /> Add
|
<Plus size={15} /> Add
|
||||||
</ReasonedButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -2209,8 +2203,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
footerClassName="button-row compact-actions"
|
footerClassName="button-row compact-actions"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<ReasonedButton type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
|
<Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||||
<ReasonedButton type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</ReasonedButton>
|
<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)}>
|
<form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
|
||||||
@@ -2236,9 +2230,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
footerClassName="button-row compact-actions"
|
footerClassName="button-row compact-actions"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<ReasonedButton type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton>
|
<Button type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||||
<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>
|
<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>
|
||||||
<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="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)}>
|
<form id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}>
|
||||||
@@ -2273,27 +2267,28 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} />
|
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Password">
|
<FormField label="Password">
|
||||||
<input type="password" value={cardDavForm.password} onChange={(event) => setCardDavForm((current) => ({ ...current, password: event.target.value }))} />
|
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{cardDavForm.auth_type === "bearer" &&
|
{cardDavForm.auth_type === "bearer" &&
|
||||||
<FormField label="Bearer token">
|
<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>
|
||||||
}
|
}
|
||||||
<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 &&
|
{cardDavDiscovery.length > 0 &&
|
||||||
<div className="address-sync-result-list">
|
<SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">
|
||||||
{cardDavDiscovery.map((item) => (
|
{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>
|
<strong>{item.display_name || item.collection_url}</strong>
|
||||||
<small>{item.collection_url}</small>
|
<small>{item.collection_url}</small>
|
||||||
</button>
|
</SelectionListItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</SelectionList>
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -2307,9 +2302,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
footerClassName="button-row compact-actions"
|
footerClassName="button-row compact-actions"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<ReasonedButton type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</ReasonedButton>
|
<Button type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</Button>
|
||||||
<ReasonedButton type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</ReasonedButton>
|
<Button type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</Button>
|
||||||
<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" 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 &&
|
{syncInspector &&
|
||||||
@@ -2324,22 +2319,22 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<StatusBadge status={syncInspector.source.status} />
|
<StatusBadge status={syncInspector.source.status} />
|
||||||
{syncInspector.source.read_only && <StatusBadge status="read-only" />}
|
{syncInspector.source.read_only && <StatusBadge status="read-only" />}
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">Last attempt: {formatDateTime(syncInspector.source.last_attempted_at)} · Last success: {formatDateTime(syncInspector.source.last_success_at)}</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 && <p className="alert alert-error">{syncInspector.source.last_error}</p>}
|
{syncInspector.source.last_error && <DismissibleAlert tone="danger" resetKey={syncInspector.source.last_error}>{syncInspector.source.last_error}</DismissibleAlert>}
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<ReasonedButton
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void toggleSyncSourceEnabled(syncInspector.source)}
|
onClick={() => void toggleSyncSourceEnabled(syncInspector.source)}
|
||||||
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
|
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
|
||||||
{syncInspector.source.enabled ? "Disable" : "Enable"}
|
{syncInspector.source.enabled ? "Disable" : "Enable"}
|
||||||
</ReasonedButton>
|
</Button>
|
||||||
<ReasonedButton
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => setConfirmState({ kind: "sync-source", source: syncInspector.source })}
|
onClick={() => setConfirmState({ kind: "sync-source", source: syncInspector.source })}
|
||||||
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
|
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
|
||||||
Disconnect
|
Disconnect
|
||||||
</ReasonedButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2366,7 +2361,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
{syncConflicts.map((conflict) => (
|
{syncConflicts.map((conflict) => (
|
||||||
<div className="address-sync-record-row" key={conflict.id}>
|
<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>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2393,7 +2388,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<div className="address-sync-record-list">
|
<div className="address-sync-record-list">
|
||||||
{syncTombstones.slice(0, 20).map((tombstone) => (
|
{syncTombstones.slice(0, 20).map((tombstone) => (
|
||||||
<div className="address-sync-record-row" key={tombstone.id}>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2410,20 +2405,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
className="address-sync-dialog"
|
className="address-sync-dialog"
|
||||||
footerClassName="button-row compact-actions"
|
footerClassName="button-row compact-actions"
|
||||||
footer={conflictDialog && <>
|
footer={conflictDialog && <>
|
||||||
<ReasonedButton type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</ReasonedButton>
|
<Button type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</Button>
|
||||||
<ReasonedButton
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void resolveConflictWith(conflictDialog, "ignored", "ignored")}
|
onClick={() => void resolveConflictWith(conflictDialog, "ignored", "ignored")}
|
||||||
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
|
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
|
||||||
Ignore
|
Ignore
|
||||||
</ReasonedButton>
|
</Button>
|
||||||
<ReasonedButton
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void resolveConflictWith(conflictDialog, "keep_local")}
|
onClick={() => void resolveConflictWith(conflictDialog, "keep_local")}
|
||||||
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
|
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
|
||||||
Keep local
|
Keep local
|
||||||
</ReasonedButton>
|
</Button>
|
||||||
<ReasonedButton
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void applyMergedConflict(conflictDialog)}
|
onClick={() => void applyMergedConflict(conflictDialog)}
|
||||||
disabledReason={disabledReason(
|
disabledReason={disabledReason(
|
||||||
@@ -2432,8 +2427,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
[saving, savingReason]
|
[saving, savingReason]
|
||||||
)}>
|
)}>
|
||||||
Apply merged
|
Apply merged
|
||||||
</ReasonedButton>
|
</Button>
|
||||||
<ReasonedButton
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => void resolveConflictWith(conflictDialog, "use_remote")}
|
onClick={() => void resolveConflictWith(conflictDialog, "use_remote")}
|
||||||
@@ -2443,7 +2438,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
[saving, savingReason]
|
[saving, savingReason]
|
||||||
)}>
|
)}>
|
||||||
Use remote
|
Use remote
|
||||||
</ReasonedButton>
|
</Button>
|
||||||
</>}>
|
</>}>
|
||||||
{conflictDialog &&
|
{conflictDialog &&
|
||||||
<div className="address-conflict-review">
|
<div className="address-conflict-review">
|
||||||
@@ -2457,7 +2452,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
{conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />}
|
{conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />}
|
||||||
</div>
|
</div>
|
||||||
{conflictDialog.metadata?.message && <p className="muted">{String(conflictDialog.metadata.message)}</p>}
|
{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>
|
||||||
<div className="address-conflict-grid">
|
<div className="address-conflict-grid">
|
||||||
<div className="address-conflict-grid-header">Field</div>
|
<div className="address-conflict-grid-header">Field</div>
|
||||||
@@ -2469,15 +2464,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<strong>{row.field}</strong>
|
<strong>{row.field}</strong>
|
||||||
<span>{row.local}</span>
|
<span>{row.local}</span>
|
||||||
<span>{row.remote}</span>
|
<span>{row.remote}</span>
|
||||||
<span className="address-conflict-choice">
|
<div>
|
||||||
{canMergeConflict(conflictDialog) ?
|
{canMergeConflict(conflictDialog) ?
|
||||||
<>
|
<SegmentedControl<ConflictMergeChoice>
|
||||||
<button type="button" className={conflictMergeChoices[row.field] !== "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "local")}>Local</button>
|
className="address-conflict-choice"
|
||||||
<button type="button" className={conflictMergeChoices[row.field] === "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "remote")}>Remote</button>
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const translations = {
|
|||||||
export const addressesModule: PlatformWebModule = {
|
export const addressesModule: PlatformWebModule = {
|
||||||
id: "addresses",
|
id: "addresses",
|
||||||
label: "i18n:govoplan-addresses.address_book.f6327f59",
|
label: "i18n:govoplan-addresses.address_book.f6327f59",
|
||||||
version: "1.0.0",
|
version: "0.1.9",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"],
|
optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"],
|
||||||
translations,
|
translations,
|
||||||
|
|||||||
@@ -150,16 +150,19 @@
|
|||||||
width: min(960px, calc(100vw - 36px));
|
width: min(960px, calc(100vw - 36px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.address-sync-record-list,
|
||||||
|
.address-sync-plan-grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
.address-sync-result-list,
|
.address-sync-result-list,
|
||||||
.address-sync-record-list,
|
.address-sync-record-list,
|
||||||
.address-sync-plan-grid {
|
.address-sync-plan-grid {
|
||||||
border: var(--border-line);
|
border: var(--border-line);
|
||||||
display: grid;
|
|
||||||
max-height: 260px;
|
max-height: 260px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.address-sync-result-row,
|
|
||||||
.address-sync-record-row,
|
.address-sync-record-row,
|
||||||
.address-sync-plan-row {
|
.address-sync-plan-row {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -175,13 +178,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.address-sync-result-row {
|
.address-sync-result-row {
|
||||||
cursor: pointer;
|
align-items: center;
|
||||||
}
|
border-bottom: var(--border-line);
|
||||||
|
display: grid;
|
||||||
.address-sync-result-row:hover,
|
gap: 10px;
|
||||||
.address-sync-result-row:focus-visible {
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
background: var(--line);
|
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.address-sync-result-row strong,
|
.address-sync-result-row strong,
|
||||||
@@ -255,37 +256,6 @@
|
|||||||
overflow-wrap: anywhere;
|
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-list-panel,
|
||||||
.address-detail-panel {
|
.address-detail-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -338,34 +308,15 @@
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.address-contact-selection-list {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.address-contact-row {
|
.address-contact-row {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
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 {
|
.address-contact-row-main {
|
||||||
@@ -617,56 +568,10 @@
|
|||||||
width: 30px;
|
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 {
|
.strong-link {
|
||||||
font-weight: 700;
|
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 {
|
.is-selected-row {
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
@@ -677,9 +582,6 @@
|
|||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.address-book-workspace,
|
.address-book-workspace,
|
||||||
.form-grid.two,
|
|
||||||
.form-grid.three,
|
|
||||||
.form-grid.four,
|
|
||||||
.address-form-row-email,
|
.address-form-row-email,
|
||||||
.address-form-row-phone,
|
.address-form-row-phone,
|
||||||
.address-form-row-postal {
|
.address-form-row-postal {
|
||||||
|
|||||||
Reference in New Issue
Block a user