From 202eb78ae46f35a9e542dce9a285a2ba9d82ae4c Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 28 Jul 2026 19:33:04 +0200 Subject: [PATCH] Integrate calendar sources with credential envelopes --- src/govoplan_calendar/backend/router.py | 22 ++ src/govoplan_calendar/backend/schemas.py | 21 ++ src/govoplan_calendar/backend/service.py | 225 +++++++++++++++++-- tests/test_caldav.py | 44 ++++ webui/src/api/calendar.ts | 30 +++ webui/src/features/calendar/CalendarPage.tsx | 96 +++++++- 6 files changed, 414 insertions(+), 24 deletions(-) diff --git a/src/govoplan_calendar/backend/router.py b/src/govoplan_calendar/backend/router.py index 01eb8ab..7ca598e 100644 --- a/src/govoplan_calendar/backend/router.py +++ b/src/govoplan_calendar/backend/router.py @@ -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 diff --git a/src/govoplan_calendar/backend/schemas.py b/src/govoplan_calendar/backend/schemas.py index 9516846..7e51566 100644 --- a/src/govoplan_calendar/backend/schemas.py +++ b/src/govoplan_calendar/backend/schemas.py @@ -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") diff --git a/src/govoplan_calendar/backend/service.py b/src/govoplan_calendar/backend/service.py index e34061e..3ad470f 100644 --- a/src/govoplan_calendar/backend/service.py +++ b/src/govoplan_calendar/backend/service.py @@ -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), diff --git a/tests/test_caldav.py b/tests/test_caldav.py index a959a7c..f53099e 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -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")) diff --git a/webui/src/api/calendar.ts b/webui/src/api/calendar.ts index a8343d9..7d9d5b2 100644 --- a/webui/src/api/calendar.ts +++ b/webui/src/api/calendar.ts @@ -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; export type CalendarSyncSourceUpdatePayload = Partial; +export type CalendarCredentialEnvelope = { + id: string; + scope_type: string; + scope_id?: string | null; + name: string; + description?: string | null; + credential_kind: string; + public_data: Record; + 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(settings, `/api/v1/calendar/caldav/sources${suffix}`); } +export function listCalendarCredentials(settings: ApiSettings, sourceId?: string | null): Promise { + const search = new URLSearchParams(); + if (sourceId) search.set("source_id", sourceId); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return apiFetch(settings, `/api/v1/calendar/credentials${suffix}`); +} + export function discoverCalDavCalendars(settings: ApiSettings, payload: CalendarCalDavDiscoveryPayload): Promise { return apiFetch(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) }); } diff --git a/webui/src/features/calendar/CalendarPage.tsx b/webui/src/features/calendar/CalendarPage.tsx index fe0b0bb..cd0543b 100644 --- a/webui/src/features/calendar/CalendarPage.tsx +++ b/webui/src/features/calendar/CalendarPage.tsx @@ -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; void;onSave: (payload: CalendarCollectionFormPayload) => Promise;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise;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;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) { const calendar = state.kind === "edit" ? state.calendar : null; const isEdit = Boolean(calendar); const [sourceMode, setSourceMode] = useState(source ? source.source_kind : "local"); @@ -1346,6 +1351,9 @@ function CalendarCollectionDialog({ const [displayName, setDisplayName] = useState(source?.display_name ?? ""); const [authType, setAuthType] = useState(source?.auth_type ?? "basic"); const [username, setUsername] = useState(source?.username ?? ""); + const [credentialEnvelopeId, setCredentialEnvelopeId] = useState(source?.credential_envelope_id ?? ""); + const [availableCredentials, setAvailableCredentials] = useState([]); + 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({ + {effectiveAuthType !== "none" && + + } + {credentialsError &&

{credentialsError}

} {effectiveAuthType === "basic" && <> + {!credentialEnvelopeId && + } } - {effectiveAuthType === "bearer" && + {effectiveAuthType === "bearer" && !credentialEnvelopeId &&