Integrate calendar sources with credential envelopes
This commit is contained in:
@@ -28,6 +28,8 @@ from govoplan_calendar.backend.schemas import (
|
||||
CalendarCollectionListResponse,
|
||||
CalendarCollectionResponse,
|
||||
CalendarCollectionUpdateRequest,
|
||||
CalendarCredentialEnvelopeListResponse,
|
||||
CalendarCredentialEnvelopeResponse,
|
||||
CalendarEventCreateRequest,
|
||||
CalendarEventDeltaResponse,
|
||||
CalendarEventListResponse,
|
||||
@@ -61,6 +63,7 @@ from govoplan_calendar.backend.service import (
|
||||
CALENDAR_EVENT_RESOURCE,
|
||||
CALENDAR_MODULE_ID,
|
||||
CalendarError,
|
||||
available_calendar_credentials,
|
||||
caldav_source_response,
|
||||
caldav_sync_response,
|
||||
calendar_response,
|
||||
@@ -129,6 +132,25 @@ def _parse_payload_datetime(value) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/credentials", response_model=CalendarCredentialEnvelopeListResponse)
|
||||
def api_list_calendar_credentials(
|
||||
source_id: str | None = None,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
return CalendarCredentialEnvelopeListResponse(
|
||||
credentials=[
|
||||
CalendarCredentialEnvelopeResponse.model_validate(item)
|
||||
for item in available_calendar_credentials(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
source_id=source_id,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _event_interval_overlaps(payload: dict, *, prefix: str, start_at: datetime | None, end_at: datetime | None) -> bool:
|
||||
event_start = _parse_payload_datetime(payload.get(f"{prefix}start_at"))
|
||||
event_end = _parse_payload_datetime(payload.get(f"{prefix}end_at")) or event_start
|
||||
|
||||
@@ -136,6 +136,7 @@ class CalendarSyncSourceResponse(BaseModel):
|
||||
auth_type: str
|
||||
username: str | None = None
|
||||
credential_ref: str | None = None
|
||||
credential_envelope_id: str | None = None
|
||||
has_credential: bool = False
|
||||
sync_enabled: bool
|
||||
sync_interval_seconds: int
|
||||
@@ -165,6 +166,26 @@ class CalendarCalDavSourceListResponse(BaseModel):
|
||||
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarCredentialEnvelopeResponse(BaseModel):
|
||||
id: str
|
||||
scope_type: str
|
||||
scope_id: str | None = None
|
||||
name: str
|
||||
description: str | None = None
|
||||
credential_kind: str
|
||||
public_data: dict[str, Any] = Field(default_factory=dict)
|
||||
secret_keys: list[str] = Field(default_factory=list)
|
||||
secret_configured: bool = False
|
||||
allowed_modules: list[str] = Field(default_factory=list)
|
||||
inherit_to_lower_scopes: bool = False
|
||||
is_active: bool = True
|
||||
revision: str
|
||||
|
||||
|
||||
class CalendarCredentialEnvelopeListResponse(BaseModel):
|
||||
credentials: list[CalendarCredentialEnvelopeResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarCalDavDiscoveryRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ from govoplan_core.security.outbound_http import (
|
||||
build_outbound_http_opener,
|
||||
validate_outbound_http_url,
|
||||
)
|
||||
from govoplan_core.security.credential_envelopes import (
|
||||
CredentialAccessContext,
|
||||
CredentialEnvelopeError,
|
||||
ResolvedCredentialEnvelope,
|
||||
credential_envelope_summary,
|
||||
get_credential_envelope,
|
||||
list_credential_envelopes,
|
||||
resolve_credential_envelope,
|
||||
)
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
|
||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
||||
@@ -52,6 +61,7 @@ class CalendarError(ValueError):
|
||||
|
||||
|
||||
CALDAV_INTERNAL_CREDENTIAL_PREFIX = "calendar-sync-credential:"
|
||||
CORE_CREDENTIAL_ENVELOPE_PREFIX = "credential-envelope:"
|
||||
CALDAV_ENV_CREDENTIAL_PREFIX = "env:"
|
||||
CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS = 900
|
||||
SYNC_SOURCE_KINDS = {"caldav", "ics", "webcal", "graph", "ews"}
|
||||
@@ -66,6 +76,33 @@ CALENDAR_EVENT_RESOURCE = "calendar_event"
|
||||
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
|
||||
|
||||
|
||||
def calendar_credential_context(
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_id: str | None = None,
|
||||
) -> CredentialAccessContext:
|
||||
return CredentialAccessContext(
|
||||
tenant_id=tenant_id,
|
||||
target_scope_type="tenant",
|
||||
target_scope_id=tenant_id,
|
||||
module_id=CALENDAR_MODULE_ID,
|
||||
server_ref=f"calendar:{source_id}" if source_id else None,
|
||||
)
|
||||
|
||||
|
||||
def available_calendar_credentials(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
context = calendar_credential_context(tenant_id=tenant_id, source_id=source_id)
|
||||
return [
|
||||
credential_envelope_summary(row)
|
||||
for row in list_credential_envelopes(session, context=context)
|
||||
]
|
||||
|
||||
|
||||
def calendar_event_change_payload(event: CalendarEvent, *, prefix: str = "") -> dict[str, Any]:
|
||||
metadata = event.metadata_ or {}
|
||||
caldav = metadata.get("caldav") if isinstance(metadata, dict) else None
|
||||
@@ -829,13 +866,24 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
|
||||
auth_type = payload.auth_type or (source.auth_type if source else "none")
|
||||
username = payload.username if payload.username is not None else (source.username if source else None)
|
||||
credential_ref = payload.credential_ref if payload.credential_ref is not None else (source.credential_ref if source else None)
|
||||
if payload.credential_ref is not None and (source is None or payload.credential_ref != source.credential_ref):
|
||||
resolved_envelope = _resolve_core_calendar_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=source.id if source else None,
|
||||
credential_ref=credential_ref,
|
||||
)
|
||||
if payload.credential_ref is not None and resolved_envelope is None and (
|
||||
source is None or payload.credential_ref != source.credential_ref
|
||||
):
|
||||
raise CalendarError(
|
||||
"Caller-supplied credential references are not accepted; provide a password/token "
|
||||
"or select an existing sync source"
|
||||
"Caller-supplied credential references are accepted only for visible reusable credential envelopes"
|
||||
)
|
||||
if not username and resolved_envelope is not None:
|
||||
username = _credential_username(resolved_envelope)
|
||||
secret = caldav_secret_from_payload(auth_type=auth_type, password=payload.password, bearer_token=payload.bearer_token)
|
||||
if secret is None and source is not None and credential_ref == source.credential_ref:
|
||||
if secret is None and resolved_envelope is not None:
|
||||
secret = _credential_secret(resolved_envelope, auth_type=auth_type)
|
||||
elif secret is None and source is not None and credential_ref == source.credential_ref:
|
||||
secret = resolve_caldav_secret(session, source=source)
|
||||
|
||||
if auth_type == "basic":
|
||||
@@ -865,10 +913,14 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
|
||||
|
||||
|
||||
def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarSyncSourceCreateRequest) -> CalendarSyncSource:
|
||||
if payload.credential_ref is not None:
|
||||
if payload.credential_ref is not None and not _core_credential_id(payload.credential_ref):
|
||||
raise CalendarError(
|
||||
"Caller-supplied credential references are not accepted; provide a password or bearer token"
|
||||
"Caller-supplied credential references are accepted only for visible reusable credential envelopes"
|
||||
)
|
||||
if payload.credential_ref is not None and (
|
||||
payload.password is not None or payload.bearer_token is not None
|
||||
):
|
||||
raise CalendarError("Select a reusable credential or enter a new secret, not both")
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||
calendar = (
|
||||
session.query(CalendarCollection)
|
||||
@@ -926,6 +978,21 @@ def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None,
|
||||
)
|
||||
session.add(source)
|
||||
session.flush()
|
||||
if source.credential_ref:
|
||||
_require_visible_core_calendar_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=source.id,
|
||||
credential_ref=source.credential_ref,
|
||||
)
|
||||
if not source.username:
|
||||
resolved = _resolve_core_calendar_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=source.id,
|
||||
credential_ref=source.credential_ref,
|
||||
)
|
||||
source.username = _credential_username(resolved) if resolved is not None else None
|
||||
credential_value = caldav_secret_from_payload(auth_type=source.auth_type, password=payload.password, bearer_token=payload.bearer_token)
|
||||
if credential_value is not None:
|
||||
source.credential_ref = store_caldav_credential(session, tenant_id=tenant_id, user_id=user_id, source=source, secret=credential_value)
|
||||
@@ -1153,6 +1220,7 @@ def _update_sync_source_credential_and_schedule(
|
||||
source: CalendarSyncSource,
|
||||
payload: CalendarSyncSourceUpdateRequest,
|
||||
previous_auth_type: str,
|
||||
previous_credential_ref: str | None,
|
||||
user_id: str | None,
|
||||
api_key_id: str | None,
|
||||
) -> None:
|
||||
@@ -1162,6 +1230,16 @@ def _update_sync_source_credential_and_schedule(
|
||||
bearer_token=payload.bearer_token,
|
||||
)
|
||||
if credential_value is not None:
|
||||
if previous_credential_ref and previous_credential_ref != source.credential_ref:
|
||||
delete_caldav_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=source.id,
|
||||
credential_ref=previous_credential_ref,
|
||||
deletion_reason="credential_replaced",
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
)
|
||||
source.credential_ref = store_caldav_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -1170,6 +1248,16 @@ def _update_sync_source_credential_and_schedule(
|
||||
secret=credential_value,
|
||||
api_key_id=api_key_id,
|
||||
)
|
||||
elif "credential_ref" in payload.model_fields_set and source.credential_ref != previous_credential_ref:
|
||||
delete_caldav_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=source.id,
|
||||
credential_ref=previous_credential_ref,
|
||||
deletion_reason="credential_replaced",
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
)
|
||||
elif payload.auth_type is not None and source.auth_type != previous_auth_type:
|
||||
delete_caldav_credential(
|
||||
session,
|
||||
@@ -1205,10 +1293,18 @@ def update_sync_source(
|
||||
source_id=source.id,
|
||||
)
|
||||
if payload.credential_ref is not None and payload.credential_ref != source.credential_ref:
|
||||
raise CalendarError(
|
||||
"Caller-supplied credential references are not accepted; provide a replacement password or bearer token"
|
||||
_require_visible_core_calendar_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=source.id,
|
||||
credential_ref=payload.credential_ref,
|
||||
)
|
||||
if payload.credential_ref is not None and (
|
||||
payload.password is not None or payload.bearer_token is not None
|
||||
):
|
||||
raise CalendarError("Select a reusable credential or enter a replacement secret, not both")
|
||||
previous_auth_type = source.auth_type
|
||||
previous_credential_ref = source.credential_ref
|
||||
(
|
||||
normalized_collection_url,
|
||||
materially_reconfigured,
|
||||
@@ -1233,6 +1329,14 @@ def update_sync_source(
|
||||
payload=payload,
|
||||
normalized_collection_url=normalized_collection_url,
|
||||
)
|
||||
if payload.credential_ref is not None and payload.username is None:
|
||||
reusable = _resolve_core_calendar_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=source.id,
|
||||
credential_ref=payload.credential_ref,
|
||||
)
|
||||
source.username = _credential_username(reusable) or source.username
|
||||
_validate_sync_source_auth(source)
|
||||
_update_sync_source_credential_and_schedule(
|
||||
session,
|
||||
@@ -1240,6 +1344,7 @@ def update_sync_source(
|
||||
source=source,
|
||||
payload=payload,
|
||||
previous_auth_type=previous_auth_type,
|
||||
previous_credential_ref=previous_credential_ref,
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
)
|
||||
@@ -1694,6 +1799,14 @@ def resolve_caldav_secret(
|
||||
return bearer_token
|
||||
if not source.credential_ref:
|
||||
return None
|
||||
reusable = _resolve_core_calendar_credential(
|
||||
session,
|
||||
tenant_id=source.tenant_id,
|
||||
source_id=source.id,
|
||||
credential_ref=source.credential_ref,
|
||||
)
|
||||
if reusable is not None:
|
||||
return _credential_secret(reusable, auth_type=source.auth_type)
|
||||
credential = internal_caldav_credential(
|
||||
session,
|
||||
tenant_id=source.tenant_id,
|
||||
@@ -1713,6 +1826,75 @@ def resolve_caldav_secret(
|
||||
return decrypt_secret(credential.secret_encrypted) if credential.secret_encrypted else None
|
||||
|
||||
|
||||
def _core_credential_id(credential_ref: str | None) -> str | None:
|
||||
if not credential_ref or not credential_ref.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX):
|
||||
return None
|
||||
value = credential_ref.removeprefix(CORE_CREDENTIAL_ENVELOPE_PREFIX).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _resolve_core_calendar_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_id: str | None,
|
||||
credential_ref: str | None,
|
||||
) -> ResolvedCredentialEnvelope | None:
|
||||
credential_id = _core_credential_id(credential_ref)
|
||||
if credential_id is None:
|
||||
return None
|
||||
try:
|
||||
return resolve_credential_envelope(
|
||||
session,
|
||||
credential_id=credential_id,
|
||||
context=calendar_credential_context(tenant_id=tenant_id, source_id=source_id),
|
||||
)
|
||||
except CredentialEnvelopeError as exc:
|
||||
raise CalendarError("The selected reusable credential is unavailable to this calendar source") from exc
|
||||
|
||||
|
||||
def _require_visible_core_calendar_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_id: str,
|
||||
credential_ref: str,
|
||||
) -> None:
|
||||
credential_id = _core_credential_id(credential_ref)
|
||||
if credential_id is None:
|
||||
raise CalendarError(
|
||||
"Caller-supplied credential references are accepted only for visible reusable credential envelopes"
|
||||
)
|
||||
try:
|
||||
get_credential_envelope(
|
||||
session,
|
||||
credential_id=credential_id,
|
||||
context=calendar_credential_context(tenant_id=tenant_id, source_id=source_id),
|
||||
)
|
||||
except CredentialEnvelopeError as exc:
|
||||
raise CalendarError("The selected reusable credential is unavailable to this calendar source") from exc
|
||||
|
||||
|
||||
def _credential_username(credential: ResolvedCredentialEnvelope | None) -> str | None:
|
||||
if credential is None:
|
||||
return None
|
||||
value = credential.public_data.get("username")
|
||||
return str(value).strip() if value is not None and str(value).strip() else None
|
||||
|
||||
|
||||
def _credential_secret(credential: ResolvedCredentialEnvelope, *, auth_type: str) -> str | None:
|
||||
keys = (
|
||||
("password", "secret", "token")
|
||||
if auth_type == "basic"
|
||||
else ("access_token", "bearer_token", "token", "password", "secret")
|
||||
)
|
||||
for key in keys:
|
||||
value = credential.secret_data.get(key)
|
||||
if value is not None and str(value):
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_caldav_credential_ref(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -1757,12 +1939,13 @@ def caldav_client_for_source(
|
||||
bearer_token: str | None = None,
|
||||
) -> CalDAVClient:
|
||||
secret = resolve_caldav_secret(session, source=source, password=password, bearer_token=bearer_token)
|
||||
username = _source_credential_username(session, source)
|
||||
if source.auth_type == "basic":
|
||||
if not source.username:
|
||||
if not username:
|
||||
raise CalendarError("CalDAV basic auth requires a username")
|
||||
if not secret:
|
||||
raise CalendarError("CalDAV basic auth requires a stored or transient password")
|
||||
return CalDAVClient(collection_url=source.collection_url, username=source.username, password=secret)
|
||||
return CalDAVClient(collection_url=source.collection_url, username=username, password=secret)
|
||||
if source.auth_type == "bearer":
|
||||
if not secret:
|
||||
raise CalendarError("CalDAV bearer auth requires a stored or transient token")
|
||||
@@ -1865,14 +2048,15 @@ def source_auth_headers(
|
||||
bearer_token: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
secret = resolve_caldav_secret(session, source=source, password=password, bearer_token=bearer_token)
|
||||
username = _source_credential_username(session, source)
|
||||
if source.auth_type == "basic":
|
||||
if not source.username:
|
||||
if not username:
|
||||
raise CalendarError(f"{sync_source_label(source.source_kind)} basic auth requires a username")
|
||||
if not secret:
|
||||
raise CalendarError(f"{sync_source_label(source.source_kind)} basic auth requires a stored or transient password")
|
||||
import base64
|
||||
|
||||
token = base64.b64encode(f"{source.username}:{secret}".encode("utf-8")).decode("ascii")
|
||||
token = base64.b64encode(f"{username}:{secret}".encode("utf-8")).decode("ascii")
|
||||
return {"Authorization": f"Basic {token}"}
|
||||
if source.auth_type == "bearer":
|
||||
if not secret:
|
||||
@@ -1881,6 +2065,16 @@ def source_auth_headers(
|
||||
return {}
|
||||
|
||||
|
||||
def _source_credential_username(session: Session, source: CalendarSyncSource) -> str | None:
|
||||
reusable = _resolve_core_calendar_credential(
|
||||
session,
|
||||
tenant_id=source.tenant_id,
|
||||
source_id=source.id,
|
||||
credential_ref=source.credential_ref,
|
||||
)
|
||||
return _credential_username(reusable) or source.username
|
||||
|
||||
|
||||
def internal_caldav_credential(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -3001,7 +3195,11 @@ def mark_calendar_sync_source(calendar: CalendarCollection, source: CalendarSync
|
||||
|
||||
def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
|
||||
has_server_owned_credential = bool(
|
||||
source.credential_ref and source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
|
||||
source.credential_ref
|
||||
and (
|
||||
source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
|
||||
or source.credential_ref.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX)
|
||||
)
|
||||
)
|
||||
return {
|
||||
"id": source.id,
|
||||
@@ -3013,6 +3211,7 @@ def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
|
||||
"auth_type": source.auth_type,
|
||||
"username": source.username,
|
||||
"credential_ref": None,
|
||||
"credential_envelope_id": _core_credential_id(source.credential_ref),
|
||||
"has_credential": has_server_owned_credential,
|
||||
"sync_enabled": bool(source.sync_enabled),
|
||||
"sync_interval_seconds": int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS),
|
||||
|
||||
@@ -42,6 +42,7 @@ from govoplan_calendar.backend.service import (
|
||||
update_event,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.security.credential_envelopes import create_credential_envelope
|
||||
from govoplan_core.tenancy.scope import create_scope_tables
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
|
||||
@@ -300,6 +301,49 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
self.assertEqual(client.username, "ada")
|
||||
self.assertEqual(client.password, "secret")
|
||||
|
||||
def test_source_can_resolve_reusable_core_credential(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"),
|
||||
)
|
||||
credential = create_credential_envelope(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Shared DAV login",
|
||||
credential_kind="username_password",
|
||||
public_data={"username": "ada"},
|
||||
secret_data={"password": "secret"},
|
||||
allowed_modules=["calendar"],
|
||||
inherit_to_lower_scopes=True,
|
||||
)
|
||||
|
||||
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",
|
||||
credential_ref=f"credential-envelope:{credential.id}",
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
client = caldav_client_for_source(session, source)
|
||||
response = caldav_source_response(source)
|
||||
|
||||
self.assertEqual(client.username, "ada")
|
||||
self.assertEqual(client.password, "secret")
|
||||
self.assertEqual(response["credential_envelope_id"], credential.id)
|
||||
self.assertEqual(session.query(CalendarSyncCredential).count(), 0)
|
||||
|
||||
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"))
|
||||
|
||||
@@ -78,6 +78,7 @@ export type CalendarSyncSource = {
|
||||
display_name?: string | null;
|
||||
auth_type: CalendarCalDavAuthType;
|
||||
username?: string | null;
|
||||
credential_envelope_id?: string | null;
|
||||
has_credential: boolean;
|
||||
sync_enabled: boolean;
|
||||
sync_interval_seconds: number;
|
||||
@@ -113,6 +114,7 @@ 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;
|
||||
};
|
||||
@@ -126,6 +128,7 @@ 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;
|
||||
@@ -140,6 +143,26 @@ export type CalendarCalDavSourceCreatePayload = CalendarSyncSourceCreatePayload;
|
||||
export type CalendarCalDavSourceUpdatePayload = Partial<CalendarCalDavSourceCreatePayload>;
|
||||
export type CalendarSyncSourceUpdatePayload = Partial<CalendarSyncSourceCreatePayload>;
|
||||
|
||||
export type CalendarCredentialEnvelope = {
|
||||
id: string;
|
||||
scope_type: string;
|
||||
scope_id?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
credential_kind: string;
|
||||
public_data: Record<string, unknown>;
|
||||
secret_keys: string[];
|
||||
secret_configured: boolean;
|
||||
allowed_modules: string[];
|
||||
inherit_to_lower_scopes: boolean;
|
||||
is_active: boolean;
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export type CalendarCredentialEnvelopeListResponse = {
|
||||
credentials: CalendarCredentialEnvelope[];
|
||||
};
|
||||
|
||||
export type CalendarSyncSourceSyncPayload = {
|
||||
password?: string | null;
|
||||
bearer_token?: string | null;
|
||||
@@ -252,6 +275,13 @@ export function listCalDavSources(settings: ApiSettings, params: { calendar_id?:
|
||||
return apiFetch<CalendarCalDavSourceListResponse>(settings, `/api/v1/calendar/caldav/sources${suffix}`);
|
||||
}
|
||||
|
||||
export function listCalendarCredentials(settings: ApiSettings, sourceId?: string | null): Promise<CalendarCredentialEnvelopeListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (sourceId) search.set("source_id", sourceId);
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return apiFetch<CalendarCredentialEnvelopeListResponse>(settings, `/api/v1/calendar/credentials${suffix}`);
|
||||
}
|
||||
|
||||
export function discoverCalDavCalendars(settings: ApiSettings, payload: CalendarCalDavDiscoveryPayload): Promise<CalendarCalDavDiscoveryResponse> {
|
||||
return apiFetch<CalendarCalDavDiscoveryResponse>(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
discoverCalDavCalendars,
|
||||
listCalendarEvents,
|
||||
listCalendarEventsDelta,
|
||||
listCalendarCredentials,
|
||||
listCalendars,
|
||||
listSyncSources,
|
||||
syncSyncSource,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
type CalendarBulkMoveExternalAction,
|
||||
type CalendarCollection,
|
||||
type CalendarCollectionDeletePayload,
|
||||
type CalendarCredentialEnvelope,
|
||||
type CalendarDeleteEventAction,
|
||||
type CalendarEvent,
|
||||
type CalendarEventCreatePayload,
|
||||
@@ -87,6 +89,7 @@ type CalendarCalDavFormPayload = {
|
||||
display_name: string;
|
||||
auth_type: CalendarCalDavAuthType;
|
||||
username: string;
|
||||
credential_envelope_id: string;
|
||||
password: string;
|
||||
bearer_token: string;
|
||||
sync_enabled: boolean;
|
||||
@@ -883,6 +886,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
<CalendarCollectionDialog
|
||||
key={calendarDialog.kind === "edit" ? calendarDialog.calendar.id : "new-calendar"}
|
||||
state={calendarDialog}
|
||||
settings={settings}
|
||||
source={calendarDialog.kind === "edit" ? syncSourceByCalendarId.get(calendarDialog.calendar.id) ?? null : null}
|
||||
saving={saving}
|
||||
syncingSourceId={syncingSourceId}
|
||||
@@ -1309,6 +1313,7 @@ function CalendarEventChip({
|
||||
|
||||
function CalendarCollectionDialog({
|
||||
state,
|
||||
settings,
|
||||
source,
|
||||
saving,
|
||||
syncingSourceId,
|
||||
@@ -1335,7 +1340,7 @@ function CalendarCollectionDialog({
|
||||
|
||||
|
||||
|
||||
}: {state: CalendarCollectionDialogState;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
|
||||
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
|
||||
const calendar = state.kind === "edit" ? state.calendar : null;
|
||||
const isEdit = Boolean(calendar);
|
||||
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? source.source_kind : "local");
|
||||
@@ -1346,6 +1351,9 @@ 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 [credentialEnvelopeId, setCredentialEnvelopeId] = useState(source?.credential_envelope_id ?? "");
|
||||
const [availableCredentials, setAvailableCredentials] = useState<CalendarCredentialEnvelope[]>([]);
|
||||
const [credentialsError, setCredentialsError] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [bearerToken, setBearerToken] = useState("");
|
||||
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
|
||||
@@ -1363,8 +1371,8 @@ function CalendarCollectionDialog({
|
||||
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
|
||||
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
|
||||
const needsSourceSecret = sourceMode !== "local" && (
|
||||
effectiveAuthType === "basic" && !source?.has_credential && !password.trim() ||
|
||||
effectiveAuthType === "bearer" && !source?.has_credential && !bearerToken.trim());
|
||||
effectiveAuthType === "basic" && !source?.has_credential && !credentialEnvelopeId && !password.trim() ||
|
||||
effectiveAuthType === "bearer" && !source?.has_credential && !credentialEnvelopeId && !bearerToken.trim());
|
||||
|
||||
const sourceDetailsInvalid = sourceMode !== "local" && (
|
||||
!isExistingSyncSource && !canEditSource ||
|
||||
@@ -1387,6 +1395,7 @@ function CalendarCollectionDialog({
|
||||
displayName,
|
||||
authType,
|
||||
username,
|
||||
credentialEnvelopeId,
|
||||
password,
|
||||
bearerToken,
|
||||
syncEnabled,
|
||||
@@ -1403,6 +1412,28 @@ function CalendarCollectionDialog({
|
||||
onDiscard: onCancel
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceMode === "local" || !canManageSources) {
|
||||
setAvailableCredentials([]);
|
||||
setCredentialsError("");
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
listCalendarCredentials(settings, source?.id)
|
||||
.then((response) => {
|
||||
if (active) setAvailableCredentials(response.credentials);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (active) {
|
||||
setAvailableCredentials([]);
|
||||
setCredentialsError(errorText(err));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [canManageSources, settings.accessToken, settings.apiBaseUrl, settings.apiKey, source?.id, sourceMode]);
|
||||
|
||||
function currentPayload(): CalendarCollectionFormPayload {
|
||||
return {
|
||||
sourceMode,
|
||||
@@ -1414,6 +1445,7 @@ function CalendarCollectionDialog({
|
||||
display_name: displayName,
|
||||
auth_type: effectiveAuthType,
|
||||
username,
|
||||
credential_envelope_id: credentialEnvelopeId,
|
||||
password,
|
||||
bearer_token: bearerToken,
|
||||
sync_enabled: syncEnabled,
|
||||
@@ -1496,8 +1528,9 @@ function CalendarCollectionDialog({
|
||||
auth_type: authType,
|
||||
username: authType === "basic" ? username.trim() || null : null
|
||||
};
|
||||
if (authType === "basic" && password.trim()) payload.password = password;
|
||||
if (authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
|
||||
if (credentialEnvelopeId) payload.credential_ref = credentialEnvelopeRef(credentialEnvelopeId);
|
||||
if (!credentialEnvelopeId && authType === "basic" && password.trim()) payload.password = password;
|
||||
if (!credentialEnvelopeId && authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
|
||||
const response = await onDiscover(payload);
|
||||
setDiscoveredCalendars(response.calendars);
|
||||
if (response.calendars.length > 0) {
|
||||
@@ -1605,19 +1638,46 @@ function CalendarCollectionDialog({
|
||||
<option value="bearer">i18n:govoplan-calendar.bearer_token.ffa64bcf</option>
|
||||
</select>
|
||||
</label>
|
||||
{effectiveAuthType !== "none" &&
|
||||
<label>
|
||||
<span>Reusable credential</span>
|
||||
<select
|
||||
value={credentialEnvelopeId}
|
||||
disabled={saving || !canEditSource}
|
||||
onChange={(event) => {
|
||||
const credentialId = event.target.value;
|
||||
setCredentialEnvelopeId(credentialId);
|
||||
setPassword("");
|
||||
setBearerToken("");
|
||||
const credential = availableCredentials.find((item) => item.id === credentialId);
|
||||
const credentialUsername = credential?.public_data?.username;
|
||||
if (credentialUsername) setUsername(String(credentialUsername));
|
||||
}}>
|
||||
<option value="">{source?.has_credential && !source.credential_envelope_id ? "Keep source-specific credential" : "Enter credentials below"}</option>
|
||||
{availableCredentials.map((credential) =>
|
||||
<option key={credential.id} value={credential.id}>
|
||||
{credential.name}{credential.public_data?.username ? ` (${String(credential.public_data.username)})` : ""}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
{credentialsError && <p className="calendar-form-error calendar-dialog-wide">{credentialsError}</p>}
|
||||
{effectiveAuthType === "basic" &&
|
||||
<>
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.username.84c29015</span>
|
||||
<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 || Boolean(credentialEnvelopeId)} />
|
||||
</label>
|
||||
{!credentialEnvelopeId &&
|
||||
<label>
|
||||
<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" &&
|
||||
{effectiveAuthType === "bearer" && !credentialEnvelopeId &&
|
||||
<label>
|
||||
<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" />
|
||||
@@ -2418,8 +2478,13 @@ function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: Calend
|
||||
conflict_policy: payload.conflict_policy,
|
||||
metadata: {}
|
||||
};
|
||||
if (result.auth_type === "basic" && payload.password.trim()) result.password = payload.password;
|
||||
if (result.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token;
|
||||
if (payload.credential_envelope_id) {
|
||||
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
|
||||
} else if (result.auth_type === "basic" && payload.password.trim()) {
|
||||
result.password = payload.password;
|
||||
} else if (result.auth_type === "bearer" && payload.bearer_token.trim()) {
|
||||
result.bearer_token = payload.bearer_token;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2429,11 +2494,20 @@ function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload):
|
||||
auth_type: payload.auth_type,
|
||||
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;
|
||||
if (payload.credential_envelope_id) {
|
||||
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
|
||||
} else if (payload.auth_type === "basic" && payload.password.trim()) {
|
||||
result.password = payload.password;
|
||||
} else if (payload.auth_type === "bearer" && payload.bearer_token.trim()) {
|
||||
result.bearer_token = payload.bearer_token;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function credentialEnvelopeRef(credentialId: string): string {
|
||||
return `credential-envelope:${credentialId}`;
|
||||
}
|
||||
|
||||
function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind {
|
||||
if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal";
|
||||
return sourceMode === "local" ? "caldav" : sourceMode;
|
||||
|
||||
Reference in New Issue
Block a user