Integrate calendar sources with credential envelopes
This commit is contained in:
@@ -28,6 +28,8 @@ from govoplan_calendar.backend.schemas import (
|
|||||||
CalendarCollectionListResponse,
|
CalendarCollectionListResponse,
|
||||||
CalendarCollectionResponse,
|
CalendarCollectionResponse,
|
||||||
CalendarCollectionUpdateRequest,
|
CalendarCollectionUpdateRequest,
|
||||||
|
CalendarCredentialEnvelopeListResponse,
|
||||||
|
CalendarCredentialEnvelopeResponse,
|
||||||
CalendarEventCreateRequest,
|
CalendarEventCreateRequest,
|
||||||
CalendarEventDeltaResponse,
|
CalendarEventDeltaResponse,
|
||||||
CalendarEventListResponse,
|
CalendarEventListResponse,
|
||||||
@@ -61,6 +63,7 @@ from govoplan_calendar.backend.service import (
|
|||||||
CALENDAR_EVENT_RESOURCE,
|
CALENDAR_EVENT_RESOURCE,
|
||||||
CALENDAR_MODULE_ID,
|
CALENDAR_MODULE_ID,
|
||||||
CalendarError,
|
CalendarError,
|
||||||
|
available_calendar_credentials,
|
||||||
caldav_source_response,
|
caldav_source_response,
|
||||||
caldav_sync_response,
|
caldav_sync_response,
|
||||||
calendar_response,
|
calendar_response,
|
||||||
@@ -129,6 +132,25 @@ def _parse_payload_datetime(value) -> datetime | None:
|
|||||||
return 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:
|
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_start = _parse_payload_datetime(payload.get(f"{prefix}start_at"))
|
||||||
event_end = _parse_payload_datetime(payload.get(f"{prefix}end_at")) or event_start
|
event_end = _parse_payload_datetime(payload.get(f"{prefix}end_at")) or event_start
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ class CalendarSyncSourceResponse(BaseModel):
|
|||||||
auth_type: str
|
auth_type: str
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
credential_ref: str | None = None
|
credential_ref: str | None = None
|
||||||
|
credential_envelope_id: str | None = None
|
||||||
has_credential: bool = False
|
has_credential: bool = False
|
||||||
sync_enabled: bool
|
sync_enabled: bool
|
||||||
sync_interval_seconds: int
|
sync_interval_seconds: int
|
||||||
@@ -165,6 +166,26 @@ class CalendarCalDavSourceListResponse(BaseModel):
|
|||||||
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
|
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):
|
class CalendarCalDavDiscoveryRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ from govoplan_core.security.outbound_http import (
|
|||||||
build_outbound_http_opener,
|
build_outbound_http_opener,
|
||||||
validate_outbound_http_url,
|
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_core.audit.logging import audit_event
|
||||||
|
|
||||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
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:"
|
CALDAV_INTERNAL_CREDENTIAL_PREFIX = "calendar-sync-credential:"
|
||||||
|
CORE_CREDENTIAL_ENVELOPE_PREFIX = "credential-envelope:"
|
||||||
CALDAV_ENV_CREDENTIAL_PREFIX = "env:"
|
CALDAV_ENV_CREDENTIAL_PREFIX = "env:"
|
||||||
CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS = 900
|
CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS = 900
|
||||||
SYNC_SOURCE_KINDS = {"caldav", "ics", "webcal", "graph", "ews"}
|
SYNC_SOURCE_KINDS = {"caldav", "ics", "webcal", "graph", "ews"}
|
||||||
@@ -66,6 +76,33 @@ CALENDAR_EVENT_RESOURCE = "calendar_event"
|
|||||||
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
|
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]:
|
def calendar_event_change_payload(event: CalendarEvent, *, prefix: str = "") -> dict[str, Any]:
|
||||||
metadata = event.metadata_ or {}
|
metadata = event.metadata_ or {}
|
||||||
caldav = metadata.get("caldav") if isinstance(metadata, dict) else None
|
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")
|
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)
|
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)
|
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(
|
raise CalendarError(
|
||||||
"Caller-supplied credential references are not accepted; provide a password/token "
|
"Caller-supplied credential references are accepted only for visible reusable credential envelopes"
|
||||||
"or select an existing sync source"
|
|
||||||
)
|
)
|
||||||
|
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)
|
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)
|
secret = resolve_caldav_secret(session, source=source)
|
||||||
|
|
||||||
if auth_type == "basic":
|
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:
|
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(
|
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 = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||||
calendar = (
|
calendar = (
|
||||||
session.query(CalendarCollection)
|
session.query(CalendarCollection)
|
||||||
@@ -926,6 +978,21 @@ def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None,
|
|||||||
)
|
)
|
||||||
session.add(source)
|
session.add(source)
|
||||||
session.flush()
|
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)
|
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:
|
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)
|
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,
|
source: CalendarSyncSource,
|
||||||
payload: CalendarSyncSourceUpdateRequest,
|
payload: CalendarSyncSourceUpdateRequest,
|
||||||
previous_auth_type: str,
|
previous_auth_type: str,
|
||||||
|
previous_credential_ref: str | None,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
api_key_id: str | None,
|
api_key_id: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1162,6 +1230,16 @@ def _update_sync_source_credential_and_schedule(
|
|||||||
bearer_token=payload.bearer_token,
|
bearer_token=payload.bearer_token,
|
||||||
)
|
)
|
||||||
if credential_value is not None:
|
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(
|
source.credential_ref = store_caldav_credential(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -1170,6 +1248,16 @@ def _update_sync_source_credential_and_schedule(
|
|||||||
secret=credential_value,
|
secret=credential_value,
|
||||||
api_key_id=api_key_id,
|
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:
|
elif payload.auth_type is not None and source.auth_type != previous_auth_type:
|
||||||
delete_caldav_credential(
|
delete_caldav_credential(
|
||||||
session,
|
session,
|
||||||
@@ -1205,10 +1293,18 @@ def update_sync_source(
|
|||||||
source_id=source.id,
|
source_id=source.id,
|
||||||
)
|
)
|
||||||
if payload.credential_ref is not None and payload.credential_ref != source.credential_ref:
|
if payload.credential_ref is not None and payload.credential_ref != source.credential_ref:
|
||||||
raise CalendarError(
|
_require_visible_core_calendar_credential(
|
||||||
"Caller-supplied credential references are not accepted; provide a replacement password or bearer token"
|
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_auth_type = source.auth_type
|
||||||
|
previous_credential_ref = source.credential_ref
|
||||||
(
|
(
|
||||||
normalized_collection_url,
|
normalized_collection_url,
|
||||||
materially_reconfigured,
|
materially_reconfigured,
|
||||||
@@ -1233,6 +1329,14 @@ def update_sync_source(
|
|||||||
payload=payload,
|
payload=payload,
|
||||||
normalized_collection_url=normalized_collection_url,
|
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)
|
_validate_sync_source_auth(source)
|
||||||
_update_sync_source_credential_and_schedule(
|
_update_sync_source_credential_and_schedule(
|
||||||
session,
|
session,
|
||||||
@@ -1240,6 +1344,7 @@ def update_sync_source(
|
|||||||
source=source,
|
source=source,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
previous_auth_type=previous_auth_type,
|
previous_auth_type=previous_auth_type,
|
||||||
|
previous_credential_ref=previous_credential_ref,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
api_key_id=api_key_id,
|
api_key_id=api_key_id,
|
||||||
)
|
)
|
||||||
@@ -1694,6 +1799,14 @@ def resolve_caldav_secret(
|
|||||||
return bearer_token
|
return bearer_token
|
||||||
if not source.credential_ref:
|
if not source.credential_ref:
|
||||||
return None
|
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(
|
credential = internal_caldav_credential(
|
||||||
session,
|
session,
|
||||||
tenant_id=source.tenant_id,
|
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
|
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(
|
def resolve_caldav_credential_ref(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -1757,12 +1939,13 @@ def caldav_client_for_source(
|
|||||||
bearer_token: str | None = None,
|
bearer_token: str | None = None,
|
||||||
) -> CalDAVClient:
|
) -> CalDAVClient:
|
||||||
secret = resolve_caldav_secret(session, source=source, password=password, bearer_token=bearer_token)
|
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 source.auth_type == "basic":
|
||||||
if not source.username:
|
if not username:
|
||||||
raise CalendarError("CalDAV basic auth requires a username")
|
raise CalendarError("CalDAV basic auth requires a username")
|
||||||
if not secret:
|
if not secret:
|
||||||
raise CalendarError("CalDAV basic auth requires a stored or transient password")
|
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 source.auth_type == "bearer":
|
||||||
if not secret:
|
if not secret:
|
||||||
raise CalendarError("CalDAV bearer auth requires a stored or transient token")
|
raise CalendarError("CalDAV bearer auth requires a stored or transient token")
|
||||||
@@ -1865,14 +2048,15 @@ def source_auth_headers(
|
|||||||
bearer_token: str | None = None,
|
bearer_token: str | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
secret = resolve_caldav_secret(session, source=source, password=password, bearer_token=bearer_token)
|
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 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")
|
raise CalendarError(f"{sync_source_label(source.source_kind)} basic auth requires a username")
|
||||||
if not secret:
|
if not secret:
|
||||||
raise CalendarError(f"{sync_source_label(source.source_kind)} basic auth requires a stored or transient password")
|
raise CalendarError(f"{sync_source_label(source.source_kind)} basic auth requires a stored or transient password")
|
||||||
import base64
|
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}"}
|
return {"Authorization": f"Basic {token}"}
|
||||||
if source.auth_type == "bearer":
|
if source.auth_type == "bearer":
|
||||||
if not secret:
|
if not secret:
|
||||||
@@ -1881,6 +2065,16 @@ def source_auth_headers(
|
|||||||
return {}
|
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(
|
def internal_caldav_credential(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -3001,7 +3195,11 @@ def mark_calendar_sync_source(calendar: CalendarCollection, source: CalendarSync
|
|||||||
|
|
||||||
def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
|
def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
|
||||||
has_server_owned_credential = bool(
|
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 {
|
return {
|
||||||
"id": source.id,
|
"id": source.id,
|
||||||
@@ -3013,6 +3211,7 @@ def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
|
|||||||
"auth_type": source.auth_type,
|
"auth_type": source.auth_type,
|
||||||
"username": source.username,
|
"username": source.username,
|
||||||
"credential_ref": None,
|
"credential_ref": None,
|
||||||
|
"credential_envelope_id": _core_credential_id(source.credential_ref),
|
||||||
"has_credential": has_server_owned_credential,
|
"has_credential": has_server_owned_credential,
|
||||||
"sync_enabled": bool(source.sync_enabled),
|
"sync_enabled": bool(source.sync_enabled),
|
||||||
"sync_interval_seconds": int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS),
|
"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,
|
update_event,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
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_core.tenancy.scope import create_scope_tables
|
||||||
from govoplan_tenancy.backend.db.models import Tenant
|
from govoplan_tenancy.backend.db.models import Tenant
|
||||||
|
|
||||||
@@ -300,6 +301,49 @@ class CalDAVSyncTests(unittest.TestCase):
|
|||||||
self.assertEqual(client.username, "ada")
|
self.assertEqual(client.username, "ada")
|
||||||
self.assertEqual(client.password, "secret")
|
self.assertEqual(client.password, "secret")
|
||||||
|
|
||||||
|
def test_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:
|
def test_api_source_and_discovery_reject_caller_selected_credential_references(self) -> None:
|
||||||
session = self.session()
|
session = self.session()
|
||||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export type CalendarSyncSource = {
|
|||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
auth_type: CalendarCalDavAuthType;
|
auth_type: CalendarCalDavAuthType;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
|
credential_envelope_id?: string | null;
|
||||||
has_credential: boolean;
|
has_credential: boolean;
|
||||||
sync_enabled: boolean;
|
sync_enabled: boolean;
|
||||||
sync_interval_seconds: number;
|
sync_interval_seconds: number;
|
||||||
@@ -113,6 +114,7 @@ export type CalendarCalDavDiscoveryPayload = {
|
|||||||
source_id?: string | null;
|
source_id?: string | null;
|
||||||
auth_type?: CalendarCalDavAuthType | null;
|
auth_type?: CalendarCalDavAuthType | null;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
|
credential_ref?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
bearer_token?: string | null;
|
bearer_token?: string | null;
|
||||||
};
|
};
|
||||||
@@ -126,6 +128,7 @@ export type CalendarSyncSourceCreatePayload = {
|
|||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
auth_type?: CalendarCalDavAuthType;
|
auth_type?: CalendarCalDavAuthType;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
|
credential_ref?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
bearer_token?: string | null;
|
bearer_token?: string | null;
|
||||||
sync_enabled?: boolean;
|
sync_enabled?: boolean;
|
||||||
@@ -140,6 +143,26 @@ export type CalendarCalDavSourceCreatePayload = CalendarSyncSourceCreatePayload;
|
|||||||
export type CalendarCalDavSourceUpdatePayload = Partial<CalendarCalDavSourceCreatePayload>;
|
export type CalendarCalDavSourceUpdatePayload = Partial<CalendarCalDavSourceCreatePayload>;
|
||||||
export type CalendarSyncSourceUpdatePayload = Partial<CalendarSyncSourceCreatePayload>;
|
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 = {
|
export type CalendarSyncSourceSyncPayload = {
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
bearer_token?: 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}`);
|
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> {
|
export function discoverCalDavCalendars(settings: ApiSettings, payload: CalendarCalDavDiscoveryPayload): Promise<CalendarCalDavDiscoveryResponse> {
|
||||||
return apiFetch<CalendarCalDavDiscoveryResponse>(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) });
|
return apiFetch<CalendarCalDavDiscoveryResponse>(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
discoverCalDavCalendars,
|
discoverCalDavCalendars,
|
||||||
listCalendarEvents,
|
listCalendarEvents,
|
||||||
listCalendarEventsDelta,
|
listCalendarEventsDelta,
|
||||||
|
listCalendarCredentials,
|
||||||
listCalendars,
|
listCalendars,
|
||||||
listSyncSources,
|
listSyncSources,
|
||||||
syncSyncSource,
|
syncSyncSource,
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
type CalendarBulkMoveExternalAction,
|
type CalendarBulkMoveExternalAction,
|
||||||
type CalendarCollection,
|
type CalendarCollection,
|
||||||
type CalendarCollectionDeletePayload,
|
type CalendarCollectionDeletePayload,
|
||||||
|
type CalendarCredentialEnvelope,
|
||||||
type CalendarDeleteEventAction,
|
type CalendarDeleteEventAction,
|
||||||
type CalendarEvent,
|
type CalendarEvent,
|
||||||
type CalendarEventCreatePayload,
|
type CalendarEventCreatePayload,
|
||||||
@@ -87,6 +89,7 @@ type CalendarCalDavFormPayload = {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
auth_type: CalendarCalDavAuthType;
|
auth_type: CalendarCalDavAuthType;
|
||||||
username: string;
|
username: string;
|
||||||
|
credential_envelope_id: string;
|
||||||
password: string;
|
password: string;
|
||||||
bearer_token: string;
|
bearer_token: string;
|
||||||
sync_enabled: boolean;
|
sync_enabled: boolean;
|
||||||
@@ -883,6 +886,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
<CalendarCollectionDialog
|
<CalendarCollectionDialog
|
||||||
key={calendarDialog.kind === "edit" ? calendarDialog.calendar.id : "new-calendar"}
|
key={calendarDialog.kind === "edit" ? calendarDialog.calendar.id : "new-calendar"}
|
||||||
state={calendarDialog}
|
state={calendarDialog}
|
||||||
|
settings={settings}
|
||||||
source={calendarDialog.kind === "edit" ? syncSourceByCalendarId.get(calendarDialog.calendar.id) ?? null : null}
|
source={calendarDialog.kind === "edit" ? syncSourceByCalendarId.get(calendarDialog.calendar.id) ?? null : null}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
syncingSourceId={syncingSourceId}
|
syncingSourceId={syncingSourceId}
|
||||||
@@ -1309,6 +1313,7 @@ function CalendarEventChip({
|
|||||||
|
|
||||||
function CalendarCollectionDialog({
|
function CalendarCollectionDialog({
|
||||||
state,
|
state,
|
||||||
|
settings,
|
||||||
source,
|
source,
|
||||||
saving,
|
saving,
|
||||||
syncingSourceId,
|
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 calendar = state.kind === "edit" ? state.calendar : null;
|
||||||
const isEdit = Boolean(calendar);
|
const isEdit = Boolean(calendar);
|
||||||
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? source.source_kind : "local");
|
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? source.source_kind : "local");
|
||||||
@@ -1346,6 +1351,9 @@ function CalendarCollectionDialog({
|
|||||||
const [displayName, setDisplayName] = useState(source?.display_name ?? "");
|
const [displayName, setDisplayName] = useState(source?.display_name ?? "");
|
||||||
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
|
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
|
||||||
const [username, setUsername] = useState(source?.username ?? "");
|
const [username, setUsername] = useState(source?.username ?? "");
|
||||||
|
const [credentialEnvelopeId, setCredentialEnvelopeId] = useState(source?.credential_envelope_id ?? "");
|
||||||
|
const [availableCredentials, setAvailableCredentials] = useState<CalendarCredentialEnvelope[]>([]);
|
||||||
|
const [credentialsError, setCredentialsError] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [bearerToken, setBearerToken] = useState("");
|
const [bearerToken, setBearerToken] = useState("");
|
||||||
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
|
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
|
||||||
@@ -1363,8 +1371,8 @@ function CalendarCollectionDialog({
|
|||||||
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
|
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
|
||||||
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
|
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
|
||||||
const needsSourceSecret = sourceMode !== "local" && (
|
const needsSourceSecret = sourceMode !== "local" && (
|
||||||
effectiveAuthType === "basic" && !source?.has_credential && !password.trim() ||
|
effectiveAuthType === "basic" && !source?.has_credential && !credentialEnvelopeId && !password.trim() ||
|
||||||
effectiveAuthType === "bearer" && !source?.has_credential && !bearerToken.trim());
|
effectiveAuthType === "bearer" && !source?.has_credential && !credentialEnvelopeId && !bearerToken.trim());
|
||||||
|
|
||||||
const sourceDetailsInvalid = sourceMode !== "local" && (
|
const sourceDetailsInvalid = sourceMode !== "local" && (
|
||||||
!isExistingSyncSource && !canEditSource ||
|
!isExistingSyncSource && !canEditSource ||
|
||||||
@@ -1387,6 +1395,7 @@ function CalendarCollectionDialog({
|
|||||||
displayName,
|
displayName,
|
||||||
authType,
|
authType,
|
||||||
username,
|
username,
|
||||||
|
credentialEnvelopeId,
|
||||||
password,
|
password,
|
||||||
bearerToken,
|
bearerToken,
|
||||||
syncEnabled,
|
syncEnabled,
|
||||||
@@ -1403,6 +1412,28 @@ function CalendarCollectionDialog({
|
|||||||
onDiscard: onCancel
|
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 {
|
function currentPayload(): CalendarCollectionFormPayload {
|
||||||
return {
|
return {
|
||||||
sourceMode,
|
sourceMode,
|
||||||
@@ -1414,6 +1445,7 @@ function CalendarCollectionDialog({
|
|||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
auth_type: effectiveAuthType,
|
auth_type: effectiveAuthType,
|
||||||
username,
|
username,
|
||||||
|
credential_envelope_id: credentialEnvelopeId,
|
||||||
password,
|
password,
|
||||||
bearer_token: bearerToken,
|
bearer_token: bearerToken,
|
||||||
sync_enabled: syncEnabled,
|
sync_enabled: syncEnabled,
|
||||||
@@ -1496,8 +1528,9 @@ function CalendarCollectionDialog({
|
|||||||
auth_type: authType,
|
auth_type: authType,
|
||||||
username: authType === "basic" ? username.trim() || null : null
|
username: authType === "basic" ? username.trim() || null : null
|
||||||
};
|
};
|
||||||
if (authType === "basic" && password.trim()) payload.password = password;
|
if (credentialEnvelopeId) payload.credential_ref = credentialEnvelopeRef(credentialEnvelopeId);
|
||||||
if (authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
|
if (!credentialEnvelopeId && authType === "basic" && password.trim()) payload.password = password;
|
||||||
|
if (!credentialEnvelopeId && authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
|
||||||
const response = await onDiscover(payload);
|
const response = await onDiscover(payload);
|
||||||
setDiscoveredCalendars(response.calendars);
|
setDiscoveredCalendars(response.calendars);
|
||||||
if (response.calendars.length > 0) {
|
if (response.calendars.length > 0) {
|
||||||
@@ -1605,19 +1638,46 @@ function CalendarCollectionDialog({
|
|||||||
<option value="bearer">i18n:govoplan-calendar.bearer_token.ffa64bcf</option>
|
<option value="bearer">i18n:govoplan-calendar.bearer_token.ffa64bcf</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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" &&
|
{effectiveAuthType === "basic" &&
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
<span>i18n:govoplan-calendar.username.84c29015</span>
|
<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>
|
</label>
|
||||||
|
{!credentialEnvelopeId &&
|
||||||
<label>
|
<label>
|
||||||
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
|
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
|
||||||
<PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
|
<PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
|
||||||
</label>
|
</label>
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{effectiveAuthType === "bearer" &&
|
{effectiveAuthType === "bearer" && !credentialEnvelopeId &&
|
||||||
<label>
|
<label>
|
||||||
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
|
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
|
||||||
<PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
|
<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,
|
conflict_policy: payload.conflict_policy,
|
||||||
metadata: {}
|
metadata: {}
|
||||||
};
|
};
|
||||||
if (result.auth_type === "basic" && payload.password.trim()) result.password = payload.password;
|
if (payload.credential_envelope_id) {
|
||||||
if (result.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2429,11 +2494,20 @@ function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload):
|
|||||||
auth_type: payload.auth_type,
|
auth_type: payload.auth_type,
|
||||||
username: payload.auth_type === "basic" ? payload.username.trim() || null : null
|
username: payload.auth_type === "basic" ? payload.username.trim() || null : null
|
||||||
};
|
};
|
||||||
if (payload.auth_type === "basic" && payload.password.trim()) result.password = payload.password;
|
if (payload.credential_envelope_id) {
|
||||||
if (payload.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function credentialEnvelopeRef(credentialId: string): string {
|
||||||
|
return `credential-envelope:${credentialId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind {
|
function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind {
|
||||||
if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal";
|
if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal";
|
||||||
return sourceMode === "local" ? "caldav" : sourceMode;
|
return sourceMode === "local" ? "caldav" : sourceMode;
|
||||||
|
|||||||
Reference in New Issue
Block a user