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
records the remote collection URL, sync token, ETag/ctag state, username,
credential reference, sync interval, sync direction, and conflict policy.
Credentials can be supplied transiently for manual sync, referenced from
environment variables with `env:NAME`, stored through a platform secret provider
when one is available, or stored as encrypted calendar-owned credentials.
Credentials can be supplied transiently for manual sync. Persisted API-managed
sources accept only a password or token: Calendar stores an opaque, tenant- and
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`
when a sync token exists. It imports all VEVENT components in a resource and

View File

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

View File

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

View File

@@ -2,4 +2,4 @@
__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 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):
@@ -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]:
url = validate_http_url(url)
try:
url = validate_outbound_http_url(url, label="CalDAV URL")
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
url,
data=body,
headers=dict(headers),
method=method,
)
opener = urllib.request.build_opener(_SameOriginRedirectHandler(url))
opener = build_outbound_http_opener(_SameOriginRedirectHandler(url))
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated 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:
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:
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
@@ -542,7 +559,8 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
del fp, msg, headers
try:
candidate = validate_http_url(newurl)
except CalDAVError:
candidate = validate_outbound_http_url(candidate, label="CalDAV redirect URL")
except (CalDAVError, OutboundHttpError):
return None
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
return None

View File

@@ -858,15 +858,18 @@ def _fail_operation(
session.flush()
def execute_calendar_outbox_operation(
def _lock_leased_outbox_operation(
session: Session,
*,
operation_id: str,
lease_token: str,
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None,
) -> CalendarOutboxOperation:
) -> tuple[CalendarOutboxOperation, CalendarSyncSource | None]:
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")
source = (
session.query(CalendarSyncSource)
@@ -882,151 +885,283 @@ def execute_calendar_outbox_operation(
)
if operation.status != "in_progress" or operation.lease_token != lease_token:
raise ValueError("Calendar outbox lease is no longer valid")
operation_metadata = operation.metadata_ or {}
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
return operation, source
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)
else:
client = client_factory(session, source)
if operation.operation_kind == "put":
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
reconciled = False
if not remote_etag:
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,
def _cancel_undeliverable_operation(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource | None,
reason: str,
) -> None:
if source is not None and source.tenant_id == operation.tenant_id:
_fail_operation(
session,
operation=operation,
source=source,
error=reason,
terminal_status="cancelled",
)
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(
session,
operation=operation,
source=source,
remote_etag=result.etag,
reconciled=result.status == 404,
remote_etag=remote_etag,
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
except CalDAVPreconditionFailed as exc:
if client is None:
_fail_operation(session, operation=operation, source=source, error=exc)
return operation
try:
matched, remote_etag = _remote_matches_operation(client, operation)
except Exception:
_fail_operation(session, operation=operation, source=source, error=exc)
else:
if matched and (operation.operation_kind == "delete" or remote_etag):
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
)
else:
_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(
if source is None:
raise ValueError("Calendar outbox source is no longer available")
operation_metadata = operation.metadata_ or {}
overwrite = (
bool(operation_metadata.get("overwrite"))
if isinstance(operation_metadata, dict)
else False
)
client: object | None = None
try:
client = _calendar_outbox_client(
session,
source=source,
client_factory=client_factory,
)
if operation.operation_kind == "put":
_execute_calendar_outbox_put(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
client=client,
overwrite=overwrite,
)
else:
_fail_operation(session, operation=operation, source=source, error=exc)
return operation
_execute_calendar_outbox_delete(
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(

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.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
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 (
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
CalendarError,
caldav_client_for_source,
caldav_source_response,
create_calendar,
create_caldav_source,
create_event,
delete_calendar,
delete_event,
discover_caldav_calendars,
list_caldav_sources,
list_freebusy,
resolve_caldav_credential_ref,
resolve_trusted_deployment_caldav_credential_ref,
sync_caldav_source,
sync_due_caldav_sources,
update_caldav_source,
update_event,
)
from govoplan_core.db.base import Base
@@ -286,6 +298,201 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(client.username, "ada")
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:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))

View File

@@ -5,6 +5,7 @@ import threading
import unittest
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import patch
from govoplan_calendar.backend.caldav import (
CalDAVClient,
@@ -29,6 +30,22 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
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:
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"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

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

View File

@@ -11,13 +11,16 @@ import {
"react";
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
AdminIconButton,
Button,
ColorPickerField,
DateField,
Dialog,
DismissibleAlert,
LoadingFrame,
PasswordField,
SegmentedControl,
TableActionGroup,
TimeField,
ToggleSwitch,
hasScope,
@@ -84,7 +87,6 @@ type CalendarCalDavFormPayload = {
display_name: string;
auth_type: CalendarCalDavAuthType;
username: string;
credential_ref: string;
password: string;
bearer_token: string;
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)}>
{calendar.name}
</button>
<div className="calendar-list-actions">
{source && canSyncCalendars &&
<Button
type="button"
className={syncing ? "calendar-row-icon-button is-syncing" : "calendar-row-icon-button"}
title={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 })}
onClick={() => void handleSyncSource(source)}
disabled={saving || syncing}>
<RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} />
</Button>
}
{(canManageCalendars || canDeleteCalendars) &&
<Button
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>
<TableActionGroup
className="calendar-list-actions"
actions={[
source && canSyncCalendars && {
id: "sync",
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),
disabled: saving || syncing
},
(canManageCalendars || canDeleteCalendars) && {
id: "edit",
label: i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name }),
icon: <Pencil size={15} />,
onClick: () => openCalendarEditor(calendar)
}
]}
/>
</div>);
})}
@@ -764,13 +759,19 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
<div className="calendar-toolbar-center">
<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"}>
<ChevronLeft size={18} />
</Button>
<AdminIconButton
label="i18n:govoplan-calendar.previous.50f94286"
icon={<ChevronLeft size={18} />}
onClick={() => moveFocus(-1)}
disabled={mode === "continuous"}
/>
<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"}>
<ChevronRight size={18} />
</Button>
<AdminIconButton
label="i18n:govoplan-calendar.next.bc981983"
icon={<ChevronRight size={18} />}
onClick={() => moveFocus(1)}
disabled={mode === "continuous"}
/>
<div className="calendar-range-label">
<CalendarDays size={18} aria-hidden="true" />
<strong>{heading}</strong>
@@ -779,9 +780,11 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
</div>
<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()}>
<RefreshCw size={18} />
</Button>
<AdminIconButton
label="i18n:govoplan-calendar.refresh.56e3badc"
icon={<RefreshCw size={18} />}
onClick={() => void loadEvents()}
/>
{canWrite &&
<Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}>
<Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7
@@ -1343,7 +1346,6 @@ function CalendarCollectionDialog({
const [displayName, setDisplayName] = useState(source?.display_name ?? "");
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
const [username, setUsername] = useState(source?.username ?? "");
const [credentialRef, setCredentialRef] = useState(source?.credential_ref ?? "");
const [password, setPassword] = useState("");
const [bearerToken, setBearerToken] = useState("");
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
@@ -1361,8 +1363,8 @@ function CalendarCollectionDialog({
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
const needsSourceSecret = sourceMode !== "local" && (
effectiveAuthType === "basic" && !source?.has_credential && !credentialRef.trim() && !password.trim() ||
effectiveAuthType === "bearer" && !source?.has_credential && !credentialRef.trim() && !bearerToken.trim());
effectiveAuthType === "basic" && !source?.has_credential && !password.trim() ||
effectiveAuthType === "bearer" && !source?.has_credential && !bearerToken.trim());
const sourceDetailsInvalid = sourceMode !== "local" && (
!isExistingSyncSource && !canEditSource ||
@@ -1385,7 +1387,6 @@ function CalendarCollectionDialog({
displayName,
authType,
username,
credentialRef,
password,
bearerToken,
syncEnabled,
@@ -1413,7 +1414,6 @@ function CalendarCollectionDialog({
display_name: displayName,
auth_type: effectiveAuthType,
username,
credential_ref: credentialRef,
password,
bearer_token: bearerToken,
sync_enabled: syncEnabled,
@@ -1494,8 +1494,7 @@ function CalendarCollectionDialog({
url,
source_id: source?.id ?? null,
auth_type: authType,
username: authType === "basic" ? username.trim() || null : null,
credential_ref: credentialRef.trim() || null
username: authType === "basic" ? username.trim() || null : null
};
if (authType === "basic" && password.trim()) payload.password = password;
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} />
</label>
<label>
<span>{source?.has_credential || credentialRef.trim() ? "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" />
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
<PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
</label>
</>
}
{effectiveAuthType === "bearer" &&
<label>
<span>{source?.has_credential || credentialRef.trim() ? "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" />
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
<PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
</label>
}
<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>
<input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} />
</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 className="calendar-sync-settings">
<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_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.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>
{source.last_error && <p className="calendar-form-error">{source.last_error}</p>}
<div className="calendar-sync-actions">
@@ -1704,7 +1699,7 @@ function CalendarCollectionDialog({
</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>
}
</form>
@@ -1812,10 +1807,7 @@ function CalendarCollectionDeleteDialog({
</select>
</label>
{calendar.is_default &&
<label className="calendar-checkbox-row">
<input type="checkbox" checked={makeTargetDefault} onChange={(item) => setMakeTargetDefault(item.target.checked)} />
<span>i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b</span>
</label>
<ToggleSwitch label="i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b" checked={makeTargetDefault} onChange={setMakeTargetDefault} />
}
<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,
auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type,
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_interval_seconds: Math.max(60, payload.sync_interval_seconds),
sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound",
@@ -2436,8 +2427,7 @@ function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload):
const result: CalendarSyncSourceUpdatePayload = {
collection_url: payload.collection_url.trim(),
auth_type: payload.auth_type,
username: payload.auth_type === "basic" ? payload.username.trim() || null : null,
credential_ref: payload.credential_ref.trim() || null
username: payload.auth_type === "basic" ? payload.username.trim() || null : null
};
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;

View File

@@ -39,7 +39,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"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_mode.5a06de37": "End mode",
"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.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.",
@@ -228,7 +227,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"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_mode.5a06de37": "End mode",
"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.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.",

View File

@@ -89,21 +89,6 @@
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 {
flex: 0 1 520px;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -250,40 +235,7 @@
}
.calendar-list-actions {
display: flex;
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;
width: auto;
}
.calendar-sync-spin {