Integrate calendar sources with credential envelopes

This commit is contained in:
2026-07-28 19:33:04 +02:00
parent bf59cd830c
commit 202eb78ae4
6 changed files with 414 additions and 24 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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):
raise CalendarError(
"Caller-supplied credential references are not accepted; provide a password/token "
"or select an existing sync source"
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 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),

View File

@@ -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"))

View File

@@ -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) });
}

View File

@@ -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;