Compare commits

..

10 Commits

14 changed files with 1276 additions and 544 deletions

View File

@@ -40,9 +40,13 @@ The first backend implementation stores VEVENT data in two layers:
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
records the remote collection URL, sync token, ETag/ctag state, username, records the remote collection URL, sync token, ETag/ctag state, username,
credential reference, sync interval, sync direction, and conflict policy. credential reference, sync interval, sync direction, and conflict policy.
Credentials can be supplied transiently for manual sync, referenced from Credentials can be supplied transiently for manual sync. Persisted API-managed
environment variables with `env:NAME`, stored through a platform secret provider sources accept only a password or token: Calendar stores an opaque, tenant- and
when one is available, or stored as encrypted calendar-owned credentials. source-bound local reference, backed by the platform secret provider when one is
available or by an encrypted calendar-owned credential otherwise. Caller-selected
environment and external-provider references are rejected. Trusted deployment
code may resolve an `env:NAME` reference only through the separate deployment
configuration helper.
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection` Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
when a sync token exists. It imports all VEVENT components in a resource and when a sync token exists. It imports all VEVENT components in a resource and

View File

@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -11,7 +11,7 @@ requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.9",
"govoplan-access>=0.1.8", "govoplan-access>=0.1.8",
"defusedxml>=0.7,<1", "defusedxml>=0.7,<1",
"icalendar>=7.2", "icalendar>=7.2",

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.4" __version__ = "0.1.8"

View File

@@ -9,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 CalDAVError(RuntimeError): class CalDAVError(RuntimeError):
@@ -353,20 +359,31 @@ class CalDAVClient:
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:
url = validate_outbound_http_url(url, label="CalDAV URL")
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined. request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
url, url,
data=body, data=body,
headers=dict(headers), headers=dict(headers),
method=method, method=method,
) )
opener = urllib.request.build_opener(_SameOriginRedirectHandler(url)) opener = build_outbound_http_opener(_SameOriginRedirectHandler(url))
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV URL; redirects remain on origin. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV URL; redirects remain on origin. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
return response.status, dict(response.headers.items()), response.read() response_headers = dict(response.headers.items())
return response.status, response_headers, bounded_response_bytes(
response,
headers=response_headers,
label="CalDAV 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="CalDAV error response")
except OutboundHttpError as policy_exc:
raise CalDAVError(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 CalDAVError(f"{method} {url} failed: {exc.reason}") from exc raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
except ValueError as exc: except (OutboundHttpError, ValueError) as exc:
raise CalDAVError(f"{method} {url} failed: {exc}") from exc raise CalDAVError(f"{method} {url} failed: {exc}") from exc
@@ -542,7 +559,8 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
del fp, msg, headers del fp, msg, headers
try: try:
candidate = validate_http_url(newurl) candidate = validate_http_url(newurl)
except CalDAVError: candidate = validate_outbound_http_url(candidate, label="CalDAV redirect URL")
except (CalDAVError, OutboundHttpError):
return None return None
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin: if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
return None return None

View File

@@ -858,15 +858,18 @@ def _fail_operation(
session.flush() session.flush()
def execute_calendar_outbox_operation( def _lock_leased_outbox_operation(
session: Session, session: Session,
*, *,
operation_id: str, operation_id: str,
lease_token: str, lease_token: str,
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None, ) -> tuple[CalendarOutboxOperation, CalendarSyncSource | None]:
) -> CalendarOutboxOperation:
operation = session.get(CalendarOutboxOperation, operation_id) operation = session.get(CalendarOutboxOperation, operation_id)
if operation is None or operation.status != "in_progress" or operation.lease_token != lease_token: if (
operation is None
or operation.status != "in_progress"
or operation.lease_token != lease_token
):
raise ValueError("Calendar outbox lease is no longer valid") raise ValueError("Calendar outbox lease is no longer valid")
source = ( source = (
session.query(CalendarSyncSource) session.query(CalendarSyncSource)
@@ -882,151 +885,283 @@ def execute_calendar_outbox_operation(
) )
if operation.status != "in_progress" or operation.lease_token != lease_token: if operation.status != "in_progress" or operation.lease_token != lease_token:
raise ValueError("Calendar outbox lease is no longer valid") raise ValueError("Calendar outbox lease is no longer valid")
operation_metadata = operation.metadata_ or {} return operation, source
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason:
if source is not None and source.tenant_id == operation.tenant_id:
_fail_operation(
session,
operation=operation,
source=source,
error=unavailable_reason,
terminal_status="cancelled",
)
else:
operation.status = "cancelled"
operation.completed_at = utcnow()
operation.last_error = unavailable_reason
operation.lease_token = None
operation.lease_expires_at = None
session.flush()
return operation
overwrite = bool(operation_metadata.get("overwrite")) if isinstance(operation_metadata, dict) else False
client: object | None = None
try:
if client_factory is None:
from govoplan_calendar.backend.service import caldav_client_for_source
client = caldav_client_for_source(session, source) def _cancel_undeliverable_operation(
else: session: Session,
client = client_factory(session, source) *,
if operation.operation_kind == "put": operation: CalendarOutboxOperation,
result = client.put_object( source: CalendarSyncSource | None,
operation.resource_href, reason: str,
operation.payload_ics or "", ) -> None:
etag=operation.expected_etag, if source is not None and source.tenant_id == operation.tenant_id:
create=not bool(operation.expected_etag), _fail_operation(
overwrite=overwrite, session,
) operation=operation,
remote_etag = result.etag source=source,
reconciled = False error=reason,
if not remote_etag: terminal_status="cancelled",
try:
matched, remote_etag = _remote_matches_operation(client, operation)
except Exception:
matched, remote_etag = False, None
if not matched or not remote_etag:
_fail_operation(
session,
operation=operation,
source=source,
error="CalDAV PUT succeeded without an ETag and could not be reconciled safely",
)
return operation
reconciled = True
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=reconciled,
)
return operation
if not operation.expected_etag and not overwrite:
matched, remote_etag = _remote_matches_operation(client, operation)
if matched:
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
)
return operation
_fail_operation(
session,
operation=operation,
source=source,
error=(
"CalDAV DELETE has no known ETag and the remote resource exists; "
"refusing to delete an unknown remote version"
),
terminal_status="conflict",
)
return operation
result = client.delete_object(
operation.resource_href,
etag=operation.expected_etag,
overwrite=overwrite,
) )
return
operation.status = "cancelled"
operation.completed_at = utcnow()
operation.last_error = reason
operation.lease_token = None
operation.lease_expires_at = None
session.flush()
def _calendar_outbox_client(
session: Session,
*,
source: CalendarSyncSource,
client_factory: Callable[[Session, CalendarSyncSource], object] | None,
) -> object:
if client_factory is not None:
return client_factory(session, source)
from govoplan_calendar.backend.service import caldav_client_for_source
return caldav_client_for_source(session, source)
def _try_remote_match(
client: object,
operation: CalendarOutboxOperation,
) -> tuple[bool, str | None] | None:
try:
return _remote_matches_operation(client, operation)
except Exception:
return None
def _complete_if_remote_match_is_usable(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
remote_match: tuple[bool, str | None] | None,
) -> bool:
if remote_match is None:
return False
matched, remote_etag = remote_match
if not matched or (operation.operation_kind != "delete" and not remote_etag):
return False
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
)
return True
def _execute_calendar_outbox_put(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object,
overwrite: bool,
) -> None:
result = client.put_object(
operation.resource_href,
operation.payload_ics or "",
etag=operation.expected_etag,
create=not bool(operation.expected_etag),
overwrite=overwrite,
)
remote_etag = result.etag
if remote_etag:
_complete_operation( _complete_operation(
session, session,
operation=operation, operation=operation,
source=source, source=source,
remote_etag=result.etag, remote_etag=remote_etag,
reconciled=result.status == 404, reconciled=False,
)
return
remote_match = _try_remote_match(client, operation)
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
_fail_operation(
session,
operation=operation,
source=source,
error="CalDAV PUT succeeded without an ETag and could not be reconciled safely",
)
def _execute_calendar_outbox_delete(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object,
overwrite: bool,
) -> None:
if not operation.expected_etag and not overwrite:
remote_match = _remote_matches_operation(client, operation)
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
_fail_operation(
session,
operation=operation,
source=source,
error=(
"CalDAV DELETE has no known ETag and the remote resource exists; "
"refusing to delete an unknown remote version"
),
terminal_status="conflict",
)
return
result = client.delete_object(
operation.resource_href,
etag=operation.expected_etag,
overwrite=overwrite,
)
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=result.etag,
reconciled=result.status == 404,
)
def _handle_outbox_precondition_failure(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object | None,
error: CalDAVPreconditionFailed,
) -> None:
remote_match = _try_remote_match(client, operation) if client is not None else None
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
if remote_match is None:
_fail_operation(session, operation=operation, source=source, error=error)
return
matched, _remote_etag = remote_match
_fail_operation(
session,
operation=operation,
source=source,
error=(
"Matching CalDAV resource has no usable ETag; retrying reconciliation"
if matched
else "CalDAV resource changed remotely; reconcile or sync before retrying"
),
terminal_status=None if matched else "conflict",
)
def _handle_outbox_delivery_failure(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object | None,
error: Exception,
) -> None:
remote_match = _try_remote_match(client, operation) if client is not None else None
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
_fail_operation(session, operation=operation, source=source, error=error)
def execute_calendar_outbox_operation(
session: Session,
*,
operation_id: str,
lease_token: str,
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None,
) -> CalendarOutboxOperation:
operation, source = _lock_leased_outbox_operation(
session,
operation_id=operation_id,
lease_token=lease_token,
)
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason:
_cancel_undeliverable_operation(
session,
operation=operation,
source=source,
reason=unavailable_reason,
) )
return operation return operation
except CalDAVPreconditionFailed as exc:
if client is None: if source is None:
_fail_operation(session, operation=operation, source=source, error=exc) raise ValueError("Calendar outbox source is no longer available")
return operation operation_metadata = operation.metadata_ or {}
try: overwrite = (
matched, remote_etag = _remote_matches_operation(client, operation) bool(operation_metadata.get("overwrite"))
except Exception: if isinstance(operation_metadata, dict)
_fail_operation(session, operation=operation, source=source, error=exc) else False
else: )
if matched and (operation.operation_kind == "delete" or remote_etag): client: object | None = None
_complete_operation( try:
session, client = _calendar_outbox_client(
operation=operation, session,
source=source, source=source,
remote_etag=remote_etag, client_factory=client_factory,
reconciled=True, )
) if operation.operation_kind == "put":
else: _execute_calendar_outbox_put(
_fail_operation(
session,
operation=operation,
source=source,
error=(
"Matching CalDAV resource has no usable ETag; retrying reconciliation"
if matched
else "CalDAV resource changed remotely; reconcile or sync before retrying"
),
terminal_status=None if matched else "conflict",
)
return operation
except Exception as exc:
matched, remote_etag = False, None
if client is not None:
try:
matched, remote_etag = _remote_matches_operation(client, operation)
except Exception:
matched, remote_etag = False, None
if matched and (operation.operation_kind == "delete" or remote_etag):
_complete_operation(
session, session,
operation=operation, operation=operation,
source=source, source=source,
remote_etag=remote_etag, client=client,
reconciled=True, overwrite=overwrite,
) )
else: else:
_fail_operation(session, operation=operation, source=source, error=exc) _execute_calendar_outbox_delete(
return operation session,
operation=operation,
source=source,
client=client,
overwrite=overwrite,
)
except CalDAVPreconditionFailed as exc:
_handle_outbox_precondition_failure(
session,
operation=operation,
source=source,
client=client,
error=exc,
)
except Exception as exc:
_handle_outbox_delivery_failure(
session,
operation=operation,
source=source,
client=client,
error=exc,
)
return operation
def dispatch_calendar_outbox( def dispatch_calendar_outbox(

File diff suppressed because it is too large Load Diff

View File

@@ -11,20 +11,32 @@ from govoplan_access.backend.db import models as access_models # noqa: F401 - p
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest from govoplan_calendar.backend.schemas import (
CalendarCalDavDiscoveryRequest,
CalendarCalDavSourceCreateRequest,
CalendarCalDavSourceUpdateRequest,
CalendarCollectionCreateRequest,
CalendarEventCreateRequest,
CalendarEventUpdateRequest,
)
from govoplan_calendar.backend.service import ( from govoplan_calendar.backend.service import (
CALDAV_INTERNAL_CREDENTIAL_PREFIX, CALDAV_INTERNAL_CREDENTIAL_PREFIX,
CalendarError, CalendarError,
caldav_client_for_source, caldav_client_for_source,
caldav_source_response,
create_calendar, create_calendar,
create_caldav_source, create_caldav_source,
create_event, create_event,
delete_calendar, delete_calendar,
delete_event, delete_event,
discover_caldav_calendars,
list_caldav_sources, list_caldav_sources,
list_freebusy, list_freebusy,
resolve_caldav_credential_ref,
resolve_trusted_deployment_caldav_credential_ref,
sync_caldav_source, sync_caldav_source,
sync_due_caldav_sources, sync_due_caldav_sources,
update_caldav_source,
update_event, update_event,
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -286,6 +298,201 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(client.username, "ada") self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret") self.assertEqual(client.password, "secret")
def test_api_source_and_discovery_reject_caller_selected_credential_references(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://attacker.example.test/cal",
auth_type="bearer",
credential_ref="env:MASTER_KEY_B64",
),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
discover_caldav_calendars(
session,
tenant_id="tenant-1",
payload=CalendarCalDavDiscoveryRequest(
url="https://attacker.example.test/cal",
auth_type="bearer",
credential_ref="vault:another-tenant",
),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(
credential_ref="env:MASTER_KEY_B64",
),
)
def test_provider_credentials_are_wrapped_in_tenant_and_source_owned_rows(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deleted: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
auth_type="basic",
username="ada",
password="secret",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
self.assertTrue(source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX))
self.assertNotEqual(source.credential_ref, provider_ref)
self.assertEqual(caldav_client_for_source(session, source).password, "secret")
self.assertIsNone(caldav_source_response(source)["credential_ref"])
self.assertTrue(caldav_source_response(source)["has_credential"])
self.assertIsNone(
resolve_caldav_credential_ref(
session,
tenant_id="another-tenant",
source_id=source.id,
credential_ref=source.credential_ref,
)
)
self.assertIsNone(
resolve_caldav_credential_ref(
session,
tenant_id="tenant-1",
source_id="another-source",
credential_ref=source.credential_ref,
)
)
other_calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Other remote"),
)
other_source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=other_calendar.id,
collection_url="https://dav.example.test/other-cal",
),
)
other_source.auth_type = "basic"
other_source.username = "mallory"
other_source.credential_ref = source.credential_ref
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
caldav_client_for_source(session, other_source)
delete_calendar(session, tenant_id="tenant-1", calendar_id=other_calendar.id)
self.assertEqual(provider.deleted, [])
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.deleted, [provider_ref])
def test_legacy_unowned_refs_are_neither_read_nor_deleted(self) -> None:
class Provider:
def __init__(self) -> None:
self.read: list[str] = []
self.deleted: list[str] = []
def read_secret(self, secret_ref: str) -> str | None:
self.read.append(secret_ref)
return "other-tenant-secret"
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
),
)
source.auth_type = "bearer"
source.credential_ref = "vault:another-tenant"
session.commit()
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
caldav_client_for_source(session, source)
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.read, [])
self.assertEqual(provider.deleted, [])
def test_trusted_deployment_env_resolution_is_explicit_and_separate(self) -> None:
with patch.dict("os.environ", {"CALDAV_DEPLOYMENT_TOKEN": "trusted-token"}):
self.assertEqual(
resolve_trusted_deployment_caldav_credential_ref("env:CALDAV_DEPLOYMENT_TOKEN"),
"trusted-token",
)
with self.assertRaisesRegex(CalendarError, "must use the env: prefix"):
resolve_trusted_deployment_caldav_credential_ref("vault:token")
def test_delete_calendar_retires_caldav_source_and_credential(self) -> None: def test_delete_calendar_retires_caldav_source_and_credential(self) -> None:
session = self.session() session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))

View File

@@ -5,6 +5,7 @@ import threading
import unittest import unittest
from collections.abc import Iterator from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import patch
from govoplan_calendar.backend.caldav import ( from govoplan_calendar.backend.caldav import (
CalDAVClient, CalDAVClient,
@@ -29,6 +30,22 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
class CalDAVUrlSecurityTests(unittest.TestCase): class CalDAVUrlSecurityTests(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(
CalDAVError,
"non-public network",
):
urllib_transport("GET", "https://dav.example.test/event.ics", {}, None, 2)
socket_factory.assert_not_called()
def test_discovery_href_must_remain_on_configured_origin(self) -> None: def test_discovery_href_must_remain_on_configured_origin(self) -> None:
base_url = "https://dav.example.test/calendars/ada/" base_url = "https://dav.example.test/calendars/ada/"

View File

@@ -17,7 +17,7 @@
"test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs" "test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -78,7 +78,6 @@ export type CalendarSyncSource = {
display_name?: string | null; display_name?: string | null;
auth_type: CalendarCalDavAuthType; auth_type: CalendarCalDavAuthType;
username?: string | null; username?: string | null;
credential_ref?: string | null;
has_credential: boolean; has_credential: boolean;
sync_enabled: boolean; sync_enabled: boolean;
sync_interval_seconds: number; sync_interval_seconds: number;
@@ -114,7 +113,6 @@ export type CalendarCalDavDiscoveryPayload = {
source_id?: string | null; source_id?: string | null;
auth_type?: CalendarCalDavAuthType | null; auth_type?: CalendarCalDavAuthType | null;
username?: string | null; username?: string | null;
credential_ref?: string | null;
password?: string | null; password?: string | null;
bearer_token?: string | null; bearer_token?: string | null;
}; };
@@ -128,7 +126,6 @@ export type CalendarSyncSourceCreatePayload = {
display_name?: string | null; display_name?: string | null;
auth_type?: CalendarCalDavAuthType; auth_type?: CalendarCalDavAuthType;
username?: string | null; username?: string | null;
credential_ref?: string | null;
password?: string | null; password?: string | null;
bearer_token?: string | null; bearer_token?: string | null;
sync_enabled?: boolean; sync_enabled?: boolean;

View File

@@ -11,13 +11,16 @@ import {
"react"; "react";
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { import {
AdminIconButton,
Button, Button,
ColorPickerField, ColorPickerField,
DateField, DateField,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
LoadingFrame, LoadingFrame,
PasswordField,
SegmentedControl, SegmentedControl,
TableActionGroup,
TimeField, TimeField,
ToggleSwitch, ToggleSwitch,
hasScope, hasScope,
@@ -84,7 +87,6 @@ type CalendarCalDavFormPayload = {
display_name: string; display_name: string;
auth_type: CalendarCalDavAuthType; auth_type: CalendarCalDavAuthType;
username: string; username: string;
credential_ref: string;
password: string; password: string;
bearer_token: string; bearer_token: string;
sync_enabled: boolean; sync_enabled: boolean;
@@ -684,31 +686,24 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
<button type="button" className="calendar-list-name" onClick={() => setEventCalendarId(calendar.id)}> <button type="button" className="calendar-list-name" onClick={() => setEventCalendarId(calendar.id)}>
{calendar.name} {calendar.name}
</button> </button>
<div className="calendar-list-actions"> <TableActionGroup
{source && canSyncCalendars && className="calendar-list-actions"
<Button actions={[
type="button" source && canSyncCalendars && {
className={syncing ? "calendar-row-icon-button is-syncing" : "calendar-row-icon-button"} id: "sync",
title={syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name })} label: syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name }),
aria-label={syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name })} icon: <RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} />,
onClick={() => void handleSyncSource(source)} onClick: () => void handleSyncSource(source),
disabled={saving || syncing}> disabled: saving || syncing
},
<RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} /> (canManageCalendars || canDeleteCalendars) && {
</Button> id: "edit",
} label: i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name }),
{(canManageCalendars || canDeleteCalendars) && icon: <Pencil size={15} />,
<Button onClick: () => openCalendarEditor(calendar)
type="button" }
className="calendar-row-icon-button" ]}
title={i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name })} />
aria-label={i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name })}
onClick={() => openCalendarEditor(calendar)}>
<Pencil size={15} />
</Button>
}
</div>
</div>); </div>);
})} })}
@@ -764,13 +759,19 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
<div className="calendar-toolbar-center"> <div className="calendar-toolbar-center">
<div className="calendar-icon-group" aria-label="i18n:govoplan-calendar.calendar_navigation.7ba43cd2"> <div className="calendar-icon-group" aria-label="i18n:govoplan-calendar.calendar_navigation.7ba43cd2">
<Button type="button" className="calendar-icon-button" title="i18n:govoplan-calendar.previous.50f94286" aria-label="i18n:govoplan-calendar.previous.50f94286" onClick={() => moveFocus(-1)} disabled={mode === "continuous"}> <AdminIconButton
<ChevronLeft size={18} /> label="i18n:govoplan-calendar.previous.50f94286"
</Button> icon={<ChevronLeft size={18} />}
onClick={() => moveFocus(-1)}
disabled={mode === "continuous"}
/>
<Button type="button" onClick={handleToday}>i18n:govoplan-calendar.today.24345a14</Button> <Button type="button" onClick={handleToday}>i18n:govoplan-calendar.today.24345a14</Button>
<Button type="button" className="calendar-icon-button" title="i18n:govoplan-calendar.next.bc981983" aria-label="i18n:govoplan-calendar.next.bc981983" onClick={() => moveFocus(1)} disabled={mode === "continuous"}> <AdminIconButton
<ChevronRight size={18} /> label="i18n:govoplan-calendar.next.bc981983"
</Button> icon={<ChevronRight size={18} />}
onClick={() => moveFocus(1)}
disabled={mode === "continuous"}
/>
<div className="calendar-range-label"> <div className="calendar-range-label">
<CalendarDays size={18} aria-hidden="true" /> <CalendarDays size={18} aria-hidden="true" />
<strong>{heading}</strong> <strong>{heading}</strong>
@@ -779,9 +780,11 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
</div> </div>
<div className="calendar-toolbar-right"> <div className="calendar-toolbar-right">
<Button type="button" className="calendar-icon-button" title="i18n:govoplan-calendar.refresh.56e3badc" aria-label="i18n:govoplan-calendar.refresh.56e3badc" onClick={() => void loadEvents()}> <AdminIconButton
<RefreshCw size={18} /> label="i18n:govoplan-calendar.refresh.56e3badc"
</Button> icon={<RefreshCw size={18} />}
onClick={() => void loadEvents()}
/>
{canWrite && {canWrite &&
<Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}> <Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}>
<Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7 <Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7
@@ -1343,7 +1346,6 @@ function CalendarCollectionDialog({
const [displayName, setDisplayName] = useState(source?.display_name ?? ""); const [displayName, setDisplayName] = useState(source?.display_name ?? "");
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic"); const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
const [username, setUsername] = useState(source?.username ?? ""); const [username, setUsername] = useState(source?.username ?? "");
const [credentialRef, setCredentialRef] = useState(source?.credential_ref ?? "");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [bearerToken, setBearerToken] = useState(""); const [bearerToken, setBearerToken] = useState("");
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true); const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
@@ -1361,8 +1363,8 @@ function CalendarCollectionDialog({
const effectiveCollectionUrl = (collectionUrl || davUrl).trim(); const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType; const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
const needsSourceSecret = sourceMode !== "local" && ( const needsSourceSecret = sourceMode !== "local" && (
effectiveAuthType === "basic" && !source?.has_credential && !credentialRef.trim() && !password.trim() || effectiveAuthType === "basic" && !source?.has_credential && !password.trim() ||
effectiveAuthType === "bearer" && !source?.has_credential && !credentialRef.trim() && !bearerToken.trim()); effectiveAuthType === "bearer" && !source?.has_credential && !bearerToken.trim());
const sourceDetailsInvalid = sourceMode !== "local" && ( const sourceDetailsInvalid = sourceMode !== "local" && (
!isExistingSyncSource && !canEditSource || !isExistingSyncSource && !canEditSource ||
@@ -1385,7 +1387,6 @@ function CalendarCollectionDialog({
displayName, displayName,
authType, authType,
username, username,
credentialRef,
password, password,
bearerToken, bearerToken,
syncEnabled, syncEnabled,
@@ -1413,7 +1414,6 @@ function CalendarCollectionDialog({
display_name: displayName, display_name: displayName,
auth_type: effectiveAuthType, auth_type: effectiveAuthType,
username, username,
credential_ref: credentialRef,
password, password,
bearer_token: bearerToken, bearer_token: bearerToken,
sync_enabled: syncEnabled, sync_enabled: syncEnabled,
@@ -1494,8 +1494,7 @@ function CalendarCollectionDialog({
url, url,
source_id: source?.id ?? null, source_id: source?.id ?? null,
auth_type: authType, auth_type: authType,
username: authType === "basic" ? username.trim() || null : null, username: authType === "basic" ? username.trim() || null : null
credential_ref: credentialRef.trim() || null
}; };
if (authType === "basic" && password.trim()) payload.password = password; if (authType === "basic" && password.trim()) payload.password = password;
if (authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken; if (authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
@@ -1613,15 +1612,15 @@ function CalendarCollectionDialog({
<input value={username} onChange={(item) => setUsername(item.target.value)} required={sourceMode !== "local" && effectiveAuthType === "basic"} maxLength={255} disabled={saving || !canEditSource} /> <input value={username} onChange={(item) => setUsername(item.target.value)} required={sourceMode !== "local" && effectiveAuthType === "basic"} maxLength={255} disabled={saving || !canEditSource} />
</label> </label>
<label> <label>
<span>{source?.has_credential || credentialRef.trim() ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span> <span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
<input type="password" value={password} onChange={(item) => setPassword(item.target.value)} disabled={saving || !canEditSource} autoComplete="new-password" /> <PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
</label> </label>
</> </>
} }
{effectiveAuthType === "bearer" && {effectiveAuthType === "bearer" &&
<label> <label>
<span>{source?.has_credential || credentialRef.trim() ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span> <span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
<input type="password" value={bearerToken} onChange={(item) => setBearerToken(item.target.value)} disabled={saving || !canEditSource} autoComplete="new-password" /> <PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
</label> </label>
} }
<div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}> <div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
@@ -1653,10 +1652,6 @@ function CalendarCollectionDialog({
<span>i18n:govoplan-calendar.display_name.c7874aaa</span> <span>i18n:govoplan-calendar.display_name.c7874aaa</span>
<input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} /> <input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} />
</label> </label>
<label>
<span>i18n:govoplan-calendar.credential_reference.a7e92de5</span>
<input value={credentialRef} onChange={(item) => setCredentialRef(item.target.value)} placeholder="env:CALDAV_PASSWORD" maxLength={255} disabled={saving || !canEditSource} />
</label>
</div> </div>
<div className="calendar-sync-settings"> <div className="calendar-sync-settings">
<ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} /> <ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} />
@@ -1686,7 +1681,7 @@ function CalendarCollectionDialog({
<div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div> <div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div> <div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div> <div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div>
<div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential || source.credential_ref ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div> <div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div>
</dl> </dl>
{source.last_error && <p className="calendar-form-error">{source.last_error}</p>} {source.last_error && <p className="calendar-form-error">{source.last_error}</p>}
<div className="calendar-sync-actions"> <div className="calendar-sync-actions">
@@ -1704,7 +1699,7 @@ function CalendarCollectionDialog({
</div> </div>
</div> </div>
} }
{sourceMode !== "local" && effectiveAuthType !== "none" && !source?.has_credential && !credentialRef.trim() && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce <code>env:CALENDAR_SYNC_SECRET</code>.</p>} {needsSourceSecret && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54</p>}
</section> </section>
} }
</form> </form>
@@ -1812,10 +1807,7 @@ function CalendarCollectionDeleteDialog({
</select> </select>
</label> </label>
{calendar.is_default && {calendar.is_default &&
<label className="calendar-checkbox-row"> <ToggleSwitch label="i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b" checked={makeTargetDefault} onChange={setMakeTargetDefault} />
<input type="checkbox" checked={makeTargetDefault} onChange={(item) => setMakeTargetDefault(item.target.checked)} />
<span>i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b</span>
</label>
} }
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p> <p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
</> </>
@@ -2420,7 +2412,6 @@ function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: Calend
display_name: payload.display_name.trim() || null, display_name: payload.display_name.trim() || null,
auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type, auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type,
username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null, username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null,
credential_ref: payload.credential_ref.trim() || null,
sync_enabled: payload.sync_enabled, sync_enabled: payload.sync_enabled,
sync_interval_seconds: Math.max(60, payload.sync_interval_seconds), sync_interval_seconds: Math.max(60, payload.sync_interval_seconds),
sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound", sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound",
@@ -2436,8 +2427,7 @@ function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload):
const result: CalendarSyncSourceUpdatePayload = { const result: CalendarSyncSourceUpdatePayload = {
collection_url: payload.collection_url.trim(), collection_url: payload.collection_url.trim(),
auth_type: payload.auth_type, auth_type: payload.auth_type,
username: payload.auth_type === "basic" ? payload.username.trim() || null : null, username: payload.auth_type === "basic" ? payload.username.trim() || null : null
credential_ref: payload.credential_ref.trim() || null
}; };
if (payload.auth_type === "basic" && payload.password.trim()) result.password = payload.password; if (payload.auth_type === "basic" && payload.password.trim()) result.password = payload.password;
if (payload.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token; if (payload.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token;

View File

@@ -39,7 +39,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED", "i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy", "i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous", "i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential", "i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL", "i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Day", "i18n:govoplan-calendar.day.987b9ced": "Day",
@@ -63,7 +62,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.end_date.89d10cd6": "End date", "i18n:govoplan-calendar.end_date.89d10cd6": "End date",
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode", "i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
"i18n:govoplan-calendar.end_time.cd7800da": "End time", "i18n:govoplan-calendar.end_time.cd7800da": "End time",
"i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as", "i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Enter a password or token for this source.",
"i18n:govoplan-calendar.etag.11d00f6e": "ETag", "i18n:govoplan-calendar.etag.11d00f6e": "ETag",
"i18n:govoplan-calendar.event": "event", "i18n:govoplan-calendar.event": "event",
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.", "i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
@@ -228,7 +227,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED", "i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy", "i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous", "i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential", "i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL", "i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Tag", "i18n:govoplan-calendar.day.987b9ced": "Tag",
@@ -252,7 +250,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.end_date.89d10cd6": "End date", "i18n:govoplan-calendar.end_date.89d10cd6": "End date",
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode", "i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
"i18n:govoplan-calendar.end_time.cd7800da": "End time", "i18n:govoplan-calendar.end_time.cd7800da": "End time",
"i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as", "i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Geben Sie ein Passwort oder Token für diese Quelle ein.",
"i18n:govoplan-calendar.etag.11d00f6e": "ETag", "i18n:govoplan-calendar.etag.11d00f6e": "ETag",
"i18n:govoplan-calendar.event": "event", "i18n:govoplan-calendar.event": "event",
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.", "i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",

View File

@@ -89,21 +89,6 @@
gap: 6px; gap: 6px;
} }
.calendar-icon-button.btn {
width: 36px;
min-width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.calendar-icon-button.btn:disabled {
opacity: .42;
cursor: default;
}
.calendar-mode-switch { .calendar-mode-switch {
flex: 0 1 520px; flex: 0 1 520px;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -250,40 +235,7 @@
} }
.calendar-list-actions { .calendar-list-actions {
display: flex; width: auto;
align-items: center;
justify-content: flex-end;
gap: 2px;
min-width: 28px;
}
.calendar-row-icon-button.btn {
width: 28px;
min-width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-color: transparent;
background: transparent;
opacity: .72;
}
.calendar-row-icon-button.btn:hover,
.calendar-row-icon-button.btn:focus-visible {
border-color: var(--line-dark);
background: var(--surface);
opacity: 1;
}
.calendar-row-icon-button.btn.is-syncing,
.calendar-row-icon-button.btn.is-syncing:disabled {
border-color: var(--green);
background: var(--surface);
color: var(--green);
opacity: 1;
cursor: progress;
} }
.calendar-sync-spin { .calendar-sync-spin {