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

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