Integrate calendar sources with credential envelopes
This commit is contained in:
@@ -78,6 +78,7 @@ export type CalendarSyncSource = {
|
||||
display_name?: string | null;
|
||||
auth_type: CalendarCalDavAuthType;
|
||||
username?: string | null;
|
||||
credential_envelope_id?: string | null;
|
||||
has_credential: boolean;
|
||||
sync_enabled: boolean;
|
||||
sync_interval_seconds: number;
|
||||
@@ -113,6 +114,7 @@ export type CalendarCalDavDiscoveryPayload = {
|
||||
source_id?: string | null;
|
||||
auth_type?: CalendarCalDavAuthType | null;
|
||||
username?: string | null;
|
||||
credential_ref?: string | null;
|
||||
password?: string | null;
|
||||
bearer_token?: string | null;
|
||||
};
|
||||
@@ -126,6 +128,7 @@ export type CalendarSyncSourceCreatePayload = {
|
||||
display_name?: string | null;
|
||||
auth_type?: CalendarCalDavAuthType;
|
||||
username?: string | null;
|
||||
credential_ref?: string | null;
|
||||
password?: string | null;
|
||||
bearer_token?: string | null;
|
||||
sync_enabled?: boolean;
|
||||
@@ -140,6 +143,26 @@ export type CalendarCalDavSourceCreatePayload = CalendarSyncSourceCreatePayload;
|
||||
export type CalendarCalDavSourceUpdatePayload = Partial<CalendarCalDavSourceCreatePayload>;
|
||||
export type CalendarSyncSourceUpdatePayload = Partial<CalendarSyncSourceCreatePayload>;
|
||||
|
||||
export type CalendarCredentialEnvelope = {
|
||||
id: string;
|
||||
scope_type: string;
|
||||
scope_id?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
credential_kind: string;
|
||||
public_data: Record<string, unknown>;
|
||||
secret_keys: string[];
|
||||
secret_configured: boolean;
|
||||
allowed_modules: string[];
|
||||
inherit_to_lower_scopes: boolean;
|
||||
is_active: boolean;
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export type CalendarCredentialEnvelopeListResponse = {
|
||||
credentials: CalendarCredentialEnvelope[];
|
||||
};
|
||||
|
||||
export type CalendarSyncSourceSyncPayload = {
|
||||
password?: string | null;
|
||||
bearer_token?: string | null;
|
||||
@@ -252,6 +275,13 @@ export function listCalDavSources(settings: ApiSettings, params: { calendar_id?:
|
||||
return apiFetch<CalendarCalDavSourceListResponse>(settings, `/api/v1/calendar/caldav/sources${suffix}`);
|
||||
}
|
||||
|
||||
export function listCalendarCredentials(settings: ApiSettings, sourceId?: string | null): Promise<CalendarCredentialEnvelopeListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (sourceId) search.set("source_id", sourceId);
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return apiFetch<CalendarCredentialEnvelopeListResponse>(settings, `/api/v1/calendar/credentials${suffix}`);
|
||||
}
|
||||
|
||||
export function discoverCalDavCalendars(settings: ApiSettings, payload: CalendarCalDavDiscoveryPayload): Promise<CalendarCalDavDiscoveryResponse> {
|
||||
return apiFetch<CalendarCalDavDiscoveryResponse>(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
discoverCalDavCalendars,
|
||||
listCalendarEvents,
|
||||
listCalendarEventsDelta,
|
||||
listCalendarCredentials,
|
||||
listCalendars,
|
||||
listSyncSources,
|
||||
syncSyncSource,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
type CalendarBulkMoveExternalAction,
|
||||
type CalendarCollection,
|
||||
type CalendarCollectionDeletePayload,
|
||||
type CalendarCredentialEnvelope,
|
||||
type CalendarDeleteEventAction,
|
||||
type CalendarEvent,
|
||||
type CalendarEventCreatePayload,
|
||||
@@ -87,6 +89,7 @@ type CalendarCalDavFormPayload = {
|
||||
display_name: string;
|
||||
auth_type: CalendarCalDavAuthType;
|
||||
username: string;
|
||||
credential_envelope_id: string;
|
||||
password: string;
|
||||
bearer_token: string;
|
||||
sync_enabled: boolean;
|
||||
@@ -883,6 +886,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
<CalendarCollectionDialog
|
||||
key={calendarDialog.kind === "edit" ? calendarDialog.calendar.id : "new-calendar"}
|
||||
state={calendarDialog}
|
||||
settings={settings}
|
||||
source={calendarDialog.kind === "edit" ? syncSourceByCalendarId.get(calendarDialog.calendar.id) ?? null : null}
|
||||
saving={saving}
|
||||
syncingSourceId={syncingSourceId}
|
||||
@@ -1309,6 +1313,7 @@ function CalendarEventChip({
|
||||
|
||||
function CalendarCollectionDialog({
|
||||
state,
|
||||
settings,
|
||||
source,
|
||||
saving,
|
||||
syncingSourceId,
|
||||
@@ -1335,7 +1340,7 @@ function CalendarCollectionDialog({
|
||||
|
||||
|
||||
|
||||
}: {state: CalendarCollectionDialogState;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
|
||||
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
|
||||
const calendar = state.kind === "edit" ? state.calendar : null;
|
||||
const isEdit = Boolean(calendar);
|
||||
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? source.source_kind : "local");
|
||||
@@ -1346,6 +1351,9 @@ function CalendarCollectionDialog({
|
||||
const [displayName, setDisplayName] = useState(source?.display_name ?? "");
|
||||
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
|
||||
const [username, setUsername] = useState(source?.username ?? "");
|
||||
const [credentialEnvelopeId, setCredentialEnvelopeId] = useState(source?.credential_envelope_id ?? "");
|
||||
const [availableCredentials, setAvailableCredentials] = useState<CalendarCredentialEnvelope[]>([]);
|
||||
const [credentialsError, setCredentialsError] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [bearerToken, setBearerToken] = useState("");
|
||||
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
|
||||
@@ -1363,8 +1371,8 @@ function CalendarCollectionDialog({
|
||||
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
|
||||
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
|
||||
const needsSourceSecret = sourceMode !== "local" && (
|
||||
effectiveAuthType === "basic" && !source?.has_credential && !password.trim() ||
|
||||
effectiveAuthType === "bearer" && !source?.has_credential && !bearerToken.trim());
|
||||
effectiveAuthType === "basic" && !source?.has_credential && !credentialEnvelopeId && !password.trim() ||
|
||||
effectiveAuthType === "bearer" && !source?.has_credential && !credentialEnvelopeId && !bearerToken.trim());
|
||||
|
||||
const sourceDetailsInvalid = sourceMode !== "local" && (
|
||||
!isExistingSyncSource && !canEditSource ||
|
||||
@@ -1387,6 +1395,7 @@ function CalendarCollectionDialog({
|
||||
displayName,
|
||||
authType,
|
||||
username,
|
||||
credentialEnvelopeId,
|
||||
password,
|
||||
bearerToken,
|
||||
syncEnabled,
|
||||
@@ -1403,6 +1412,28 @@ function CalendarCollectionDialog({
|
||||
onDiscard: onCancel
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceMode === "local" || !canManageSources) {
|
||||
setAvailableCredentials([]);
|
||||
setCredentialsError("");
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
listCalendarCredentials(settings, source?.id)
|
||||
.then((response) => {
|
||||
if (active) setAvailableCredentials(response.credentials);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (active) {
|
||||
setAvailableCredentials([]);
|
||||
setCredentialsError(errorText(err));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [canManageSources, settings.accessToken, settings.apiBaseUrl, settings.apiKey, source?.id, sourceMode]);
|
||||
|
||||
function currentPayload(): CalendarCollectionFormPayload {
|
||||
return {
|
||||
sourceMode,
|
||||
@@ -1414,6 +1445,7 @@ function CalendarCollectionDialog({
|
||||
display_name: displayName,
|
||||
auth_type: effectiveAuthType,
|
||||
username,
|
||||
credential_envelope_id: credentialEnvelopeId,
|
||||
password,
|
||||
bearer_token: bearerToken,
|
||||
sync_enabled: syncEnabled,
|
||||
@@ -1496,8 +1528,9 @@ function CalendarCollectionDialog({
|
||||
auth_type: authType,
|
||||
username: authType === "basic" ? username.trim() || null : null
|
||||
};
|
||||
if (authType === "basic" && password.trim()) payload.password = password;
|
||||
if (authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
|
||||
if (credentialEnvelopeId) payload.credential_ref = credentialEnvelopeRef(credentialEnvelopeId);
|
||||
if (!credentialEnvelopeId && authType === "basic" && password.trim()) payload.password = password;
|
||||
if (!credentialEnvelopeId && authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
|
||||
const response = await onDiscover(payload);
|
||||
setDiscoveredCalendars(response.calendars);
|
||||
if (response.calendars.length > 0) {
|
||||
@@ -1605,19 +1638,46 @@ function CalendarCollectionDialog({
|
||||
<option value="bearer">i18n:govoplan-calendar.bearer_token.ffa64bcf</option>
|
||||
</select>
|
||||
</label>
|
||||
{effectiveAuthType !== "none" &&
|
||||
<label>
|
||||
<span>Reusable credential</span>
|
||||
<select
|
||||
value={credentialEnvelopeId}
|
||||
disabled={saving || !canEditSource}
|
||||
onChange={(event) => {
|
||||
const credentialId = event.target.value;
|
||||
setCredentialEnvelopeId(credentialId);
|
||||
setPassword("");
|
||||
setBearerToken("");
|
||||
const credential = availableCredentials.find((item) => item.id === credentialId);
|
||||
const credentialUsername = credential?.public_data?.username;
|
||||
if (credentialUsername) setUsername(String(credentialUsername));
|
||||
}}>
|
||||
<option value="">{source?.has_credential && !source.credential_envelope_id ? "Keep source-specific credential" : "Enter credentials below"}</option>
|
||||
{availableCredentials.map((credential) =>
|
||||
<option key={credential.id} value={credential.id}>
|
||||
{credential.name}{credential.public_data?.username ? ` (${String(credential.public_data.username)})` : ""}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
{credentialsError && <p className="calendar-form-error calendar-dialog-wide">{credentialsError}</p>}
|
||||
{effectiveAuthType === "basic" &&
|
||||
<>
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.username.84c29015</span>
|
||||
<input value={username} onChange={(item) => setUsername(item.target.value)} required={sourceMode !== "local" && effectiveAuthType === "basic"} maxLength={255} disabled={saving || !canEditSource} />
|
||||
<input value={username} onChange={(item) => setUsername(item.target.value)} required={sourceMode !== "local" && effectiveAuthType === "basic"} maxLength={255} disabled={saving || !canEditSource || Boolean(credentialEnvelopeId)} />
|
||||
</label>
|
||||
{!credentialEnvelopeId &&
|
||||
<label>
|
||||
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
|
||||
<PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
|
||||
</label>
|
||||
}
|
||||
</>
|
||||
}
|
||||
{effectiveAuthType === "bearer" &&
|
||||
{effectiveAuthType === "bearer" && !credentialEnvelopeId &&
|
||||
<label>
|
||||
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
|
||||
<PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
|
||||
@@ -2418,8 +2478,13 @@ function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: Calend
|
||||
conflict_policy: payload.conflict_policy,
|
||||
metadata: {}
|
||||
};
|
||||
if (result.auth_type === "basic" && payload.password.trim()) result.password = payload.password;
|
||||
if (result.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token;
|
||||
if (payload.credential_envelope_id) {
|
||||
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
|
||||
} else if (result.auth_type === "basic" && payload.password.trim()) {
|
||||
result.password = payload.password;
|
||||
} else if (result.auth_type === "bearer" && payload.bearer_token.trim()) {
|
||||
result.bearer_token = payload.bearer_token;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2429,11 +2494,20 @@ function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload):
|
||||
auth_type: payload.auth_type,
|
||||
username: payload.auth_type === "basic" ? payload.username.trim() || null : null
|
||||
};
|
||||
if (payload.auth_type === "basic" && payload.password.trim()) result.password = payload.password;
|
||||
if (payload.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token;
|
||||
if (payload.credential_envelope_id) {
|
||||
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
|
||||
} else if (payload.auth_type === "basic" && payload.password.trim()) {
|
||||
result.password = payload.password;
|
||||
} else if (payload.auth_type === "bearer" && payload.bearer_token.trim()) {
|
||||
result.bearer_token = payload.bearer_token;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function credentialEnvelopeRef(credentialId: string): string {
|
||||
return `credential-envelope:${credentialId}`;
|
||||
}
|
||||
|
||||
function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind {
|
||||
if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal";
|
||||
return sourceMode === "local" ? "caldav" : sourceMode;
|
||||
|
||||
Reference in New Issue
Block a user