Implement Open-Xchange calendar profiles

This commit is contained in:
2026-08-02 05:59:06 +02:00
parent 66a6df4f05
commit 4e05ab2c3e
11 changed files with 476 additions and 23 deletions
@@ -42,10 +42,11 @@ import {
normalizeHexColor,
} from "./calendarViewModel";
export type CalendarSourceMode = "local" | CalendarSyncSourceKind;
export type CalendarSourceMode = "local" | CalendarSyncSourceKind | "open_xchange";
type CalendarSourceSwitchMode =
| "local"
| "caldav"
| "open_xchange"
| "ics"
| "graph"
| "ews";
@@ -75,6 +76,9 @@ export type CalendarCalDavFormPayload = {
sync_interval_seconds: number;
sync_direction: CalendarCalDavSyncDirection;
conflict_policy: CalendarCalDavConflictPolicy;
connector_profile_ref: string;
identity_mapping_ref: string;
resource_calendar_ref: string;
};
export type CalendarCollectionFormPayload = {
sourceMode: CalendarSourceMode;
@@ -115,7 +119,7 @@ export function CalendarCollectionDialog({
}: {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");
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? calendarSourceModeForSource(source) : "local");
const [name, setName] = useState(calendar?.name ?? "");
const [color, setColor] = useState(normalizeHexColor(calendar?.color) || DEFAULT_CALENDAR_COLOR);
const [davUrl, setDavUrl] = useState(source?.collection_url ?? "");
@@ -132,6 +136,9 @@ export function CalendarCollectionDialog({
const [syncIntervalMinutes, setSyncIntervalMinutes] = useState(Math.max(1, Math.round((source?.sync_interval_seconds ?? 900) / 60)));
const [syncDirection, setSyncDirection] = useState<CalendarCalDavSyncDirection>(source?.sync_direction ?? "two_way");
const [conflictPolicy, setConflictPolicy] = useState<CalendarCalDavConflictPolicy>(source?.conflict_policy ?? "etag");
const [connectorProfileRef, setConnectorProfileRef] = useState(metadataValue(source?.metadata, "connector_profile_ref"));
const [identityMappingRef, setIdentityMappingRef] = useState(metadataValue(source?.metadata, "identity_mapping_ref"));
const [resourceCalendarRef, setResourceCalendarRef] = useState(metadataValue(source?.metadata, "resource_calendar_ref"));
const [discoveredCalendars, setDiscoveredCalendars] = useState<CalendarCalDavDiscoveryCandidate[]>([]);
const [selectedDiscoveredUrl, setSelectedDiscoveredUrl] = useState(source?.collection_url ?? "");
const [discovering, setDiscovering] = useState(false);
@@ -173,7 +180,10 @@ export function CalendarCollectionDialog({
syncEnabled,
syncIntervalMinutes,
syncDirection,
conflictPolicy
conflictPolicy,
connectorProfileRef,
identityMappingRef,
resourceCalendarRef
};
const initialCollectionDraftKey = useMemo(() => calendarDraftKey(collectionDraft), []);
const collectionDirty = calendarDraftKey(collectionDraft) !== initialCollectionDraftKey;
@@ -223,7 +233,10 @@ export function CalendarCollectionDialog({
sync_enabled: syncEnabled,
sync_interval_seconds: syncIntervalMinutes * 60,
sync_direction: syncDirection,
conflict_policy: conflictPolicy
conflict_policy: conflictPolicy,
connector_profile_ref: connectorProfileRef,
identity_mapping_ref: identityMappingRef,
resource_calendar_ref: resourceCalendarRef
}
};
}
@@ -259,7 +272,8 @@ export function CalendarCollectionDialog({
setSyncDirection("inbound");
return;
}
if (next === "caldav") {
if (next === "caldav" || next === "open_xchange") {
if (next === "open_xchange") setAuthType("basic");
setSyncDirection("two_way");
}
}
@@ -355,6 +369,7 @@ export function CalendarCollectionDialog({
options={[
{ id: "local", label: "i18n:govoplan-calendar.local.dc99d54d" },
{ id: "caldav", label: "i18n:govoplan-calendar.caldav.64f9720e", disabled: !canManageSources },
{ id: "open_xchange", label: "i18n:govoplan-calendar.open_xchange.637e5b7b", disabled: !canManageSources },
{ id: "ics", label: "i18n:govoplan-calendar.ics_webcal.9c55b570", disabled: !canManageSources },
{ id: "graph", label: "i18n:govoplan-calendar.graph.9a7405eb", disabled: !canManageSources },
{ id: "ews", label: "i18n:govoplan-calendar.exchange.5b13eac7", disabled: !canManageSources }
@@ -455,8 +470,24 @@ export function CalendarCollectionDialog({
<PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
</label>
}
<div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
{sourceMode === "caldav" &&
{sourceMode === "open_xchange" &&
<>
<label>
<span>i18n:govoplan-calendar.connector_profile_reference.e7697602</span>
<input value={connectorProfileRef} onChange={(item) => setConnectorProfileRef(item.target.value)} maxLength={255} disabled={saving || !canEditSource} />
</label>
<label>
<span>i18n:govoplan-calendar.identity_group_mapping_reference.40c3da81</span>
<input value={identityMappingRef} onChange={(item) => setIdentityMappingRef(item.target.value)} maxLength={255} disabled={saving || !canEditSource} />
</label>
<label>
<span>i18n:govoplan-calendar.resource_calendar_reference.4df67054</span>
<input value={resourceCalendarRef} onChange={(item) => setResourceCalendarRef(item.target.value)} maxLength={255} disabled={saving || !canEditSource} />
</label>
</>
}
<div className={calendarSourceUsesCalDav(sourceMode) ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
{calendarSourceUsesCalDav(sourceMode) &&
<Button type="button" onClick={() => void handleDiscover()} disabled={saving || discovering || !canEditSource || !davUrl.trim() || effectiveAuthType === "basic" && !username.trim()}>
<RefreshCw size={16} /> {discovering ? "i18n:govoplan-calendar.discovering.1884f689" : "i18n:govoplan-calendar.discover.4827ea22"}
</Button>
@@ -465,7 +496,7 @@ export function CalendarCollectionDialog({
</div>
</div>
{discoveryError && <p className="calendar-form-error">{discoveryError}</p>}
{sourceMode === "caldav" && discoveredCalendars.length > 0 &&
{calendarSourceUsesCalDav(sourceMode) && discoveredCalendars.length > 0 &&
<label>
<span>i18n:govoplan-calendar.calendar.adab5090</span>
<select value={selectedDiscoveredUrl} onChange={handleDiscoveredCalendarChange} disabled={saving || !canEditSource}>
@@ -493,14 +524,14 @@ export function CalendarCollectionDialog({
</label>
<label>
<span>i18n:govoplan-calendar.direction.fd8e45ba</span>
<select value={sourceMode === "caldav" ? syncDirection : "inbound"} onChange={(item) => setSyncDirection(item.target.value as CalendarCalDavSyncDirection)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
<select value={calendarSourceUsesCalDav(sourceMode) ? syncDirection : "inbound"} onChange={(item) => setSyncDirection(item.target.value as CalendarCalDavSyncDirection)} disabled={saving || !canEditMutableSourceSettings || !calendarSourceUsesCalDav(sourceMode)}>
<option value="two_way">i18n:govoplan-calendar.two_way.ee50a3e6</option>
<option value="inbound">i18n:govoplan-calendar.inbound_only.bf4269b0</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.conflict_policy.5810e150</span>
<select value={conflictPolicy} onChange={(item) => setConflictPolicy(item.target.value as CalendarCalDavConflictPolicy)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
<select value={conflictPolicy} onChange={(item) => setConflictPolicy(item.target.value as CalendarCalDavConflictPolicy)} disabled={saving || !canEditMutableSourceSettings || !calendarSourceUsesCalDav(sourceMode)}>
<option value="etag">i18n:govoplan-calendar.require_matching_etag.0ab1ffe1</option>
<option value="overwrite">i18n:govoplan-calendar.overwrite_remote.39625e32</option>
</select>
@@ -667,9 +698,9 @@ export function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload:
username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null,
sync_enabled: payload.sync_enabled,
sync_interval_seconds: Math.max(60, payload.sync_interval_seconds),
sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound",
sync_direction: calendarSourceUsesCalDav(sourceMode) ? payload.sync_direction : "inbound",
conflict_policy: payload.conflict_policy,
metadata: {}
metadata: calendarSourceMetadata(sourceMode, payload)
};
if (payload.credential_envelope_id) {
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
@@ -681,11 +712,19 @@ export function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload:
return result;
}
export function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload): CalendarSyncSourceUpdatePayload {
export function syncSourceConnectionUpdatePayload(
sourceMode: CalendarSourceMode,
payload: CalendarCalDavFormPayload,
currentMetadata: Record<string, unknown> | null | undefined,
): CalendarSyncSourceUpdatePayload {
const result: CalendarSyncSourceUpdatePayload = {
collection_url: payload.collection_url.trim(),
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,
metadata: {
...(currentMetadata ?? {}),
...calendarSourceMetadata(sourceMode, payload)
}
};
if (payload.credential_envelope_id) {
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
@@ -703,6 +742,7 @@ function credentialEnvelopeRef(credentialId: string): string {
function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind {
if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal";
if (sourceMode === "open_xchange") return "caldav";
return sourceMode === "local" ? "caldav" : sourceMode;
}
@@ -718,6 +758,7 @@ function syncTransientPayload(authType: CalendarCalDavAuthType, password: string
function calendarSourcePaneTitle(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "i18n:govoplan-calendar.caldav_source.459ed16a";
if (sourceMode === "open_xchange") return "i18n:govoplan-calendar.open_xchange_source.4cc03862";
if (sourceMode === "ics" || sourceMode === "webcal") return "i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63";
if (sourceMode === "graph") return "i18n:govoplan-calendar.microsoft_graph_source.ec4f1383";
if (sourceMode === "ews") return "i18n:govoplan-calendar.exchange_web_services_source.53caabf3";
@@ -726,6 +767,7 @@ function calendarSourcePaneTitle(sourceMode: CalendarSourceMode): string {
function calendarSourceUrlLabel(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "i18n:govoplan-calendar.dav_url.4205e180";
if (sourceMode === "open_xchange") return "i18n:govoplan-calendar.open_xchange_dav_url.9d1adeaf";
if (sourceMode === "graph") return "i18n:govoplan-calendar.graph_calendar_url.e59607f3";
if (sourceMode === "ews") return "i18n:govoplan-calendar.ews_endpoint.a3273983";
return "i18n:govoplan-calendar.subscription_url.b8b6a1a0";
@@ -733,11 +775,41 @@ function calendarSourceUrlLabel(sourceMode: CalendarSourceMode): string {
function calendarSourceUrlPlaceholder(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "https://cloud.example.org/remote.php/dav";
if (sourceMode === "open_xchange") return "https://groupware.example.org/caldav/";
if (sourceMode === "graph") return "me/calendar or https://graph.microsoft.com/v1.0/me/calendar/events/delta";
if (sourceMode === "ews") return "https://exchange.example.org/EWS/Exchange.asmx";
return "webcal://example.org/calendar.ics or https://example.org/calendar.ics";
}
function calendarSourceUsesCalDav(sourceMode: CalendarSourceMode): boolean {
return sourceMode === "caldav" || sourceMode === "open_xchange";
}
function calendarSourceModeForSource(source: CalendarSyncSource): CalendarSourceMode {
return source.source_kind === "caldav" && metadataValue(source.metadata, "integration_profile") === "open_xchange"
? "open_xchange"
: source.source_kind;
}
function calendarSourceMetadata(
sourceMode: CalendarSourceMode,
payload: CalendarCalDavFormPayload,
): Record<string, unknown> {
if (sourceMode !== "open_xchange") return {};
return {
integration_profile: "open_xchange",
transport_kind: "caldav",
connector_profile_ref: payload.connector_profile_ref.trim() || null,
identity_mapping_ref: payload.identity_mapping_ref.trim() || null,
resource_calendar_ref: payload.resource_calendar_ref.trim() || null
};
}
function metadataValue(metadata: Record<string, unknown> | null | undefined, key: string): string {
const value = metadata?.[key];
return typeof value === "string" ? value.trim() : "";
}
function calendarDeleteActionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove.e963907d" : "i18n:govoplan-calendar.delete.f6fdbe48";
}
+12 -2
View File
@@ -327,7 +327,15 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR
});
if (source && canDeleteCalendars) {
const updatedSource = await updateSyncSource(settings, source.id, syncSourceConnectionUpdatePayload(payload.caldav));
const updatedSource = await updateSyncSource(
settings,
source.id,
syncSourceConnectionUpdatePayload(
payload.sourceMode,
payload.caldav,
source.metadata,
),
);
setSyncSources((current) => current.map((item) => item.id === source.id ? updatedSource : item));
reloadSources = true;
}
@@ -336,7 +344,9 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
const calendar = await createCalendar(settings, {
name,
color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
owner_type: payload.sourceMode === "open_xchange" && payload.caldav.resource_calendar_ref.trim() ? "resource" : "tenant",
owner_id: payload.sourceMode === "open_xchange" ? payload.caldav.resource_calendar_ref.trim() || null : null
});
let createdSource: CalendarSyncSource | null = null;
if (payload.sourceMode !== "local") {
+12
View File
@@ -38,6 +38,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirm_delete.c9f2829e": "Confirm delete",
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.connector_profile_reference.e7697602": "Connector profile reference",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
@@ -83,6 +84,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.icalendar.f388476d": "iCalendar",
"i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63": "ICS/webcal subscription",
"i18n:govoplan-calendar.ics_webcal.9c55b570": "ICS/webcal",
"i18n:govoplan-calendar.identity_group_mapping_reference.40c3da81": "Identity/group mapping reference",
"i18n:govoplan-calendar.identity.7e5a975b": "Identity",
"i18n:govoplan-calendar.inbound_only.bf4269b0": "Inbound only",
"i18n:govoplan-calendar.interval.011efcd5": "Interval",
@@ -120,6 +122,9 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",
"i18n:govoplan-calendar.not_synced.4c205136": "Not synced",
"i18n:govoplan-calendar.opaque.3e1d0194": "OPAQUE",
"i18n:govoplan-calendar.open_xchange_dav_url.9d1adeaf": "Open-Xchange DAV URL",
"i18n:govoplan-calendar.open_xchange_source.4cc03862": "Open-Xchange source",
"i18n:govoplan-calendar.open_xchange.637e5b7b": "Open-Xchange",
"i18n:govoplan-calendar.organizer_json.3add6f9f": "Organizer JSON",
"i18n:govoplan-calendar.organizer.debd1720": "Organizer",
"i18n:govoplan-calendar.overwrite_remote.39625e32": "Overwrite remote",
@@ -133,6 +138,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.recurrence_id.e0b780ba": "Recurrence ID",
"i18n:govoplan-calendar.recurrence.f7ad40f5": "Recurrence",
"i18n:govoplan-calendar.refresh.56e3badc": "Refresh",
"i18n:govoplan-calendar.resource_calendar_reference.4df67054": "Resource calendar reference",
"i18n:govoplan-calendar.related_to_json.2d4e8f59": "Related-To JSON",
"i18n:govoplan-calendar.related_to.0e7989ff": "Related-To",
"i18n:govoplan-calendar.related.917df91e": "Related",
@@ -226,6 +232,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirm_delete.c9f2829e": "Confirm delete",
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.connector_profile_reference.e7697602": "Connector-Profilreferenz",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
@@ -271,6 +278,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.icalendar.f388476d": "iCalendar",
"i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63": "ICS/webcal subscription",
"i18n:govoplan-calendar.ics_webcal.9c55b570": "ICS/webcal",
"i18n:govoplan-calendar.identity_group_mapping_reference.40c3da81": "Identitäts-/Gruppenzuordnungsreferenz",
"i18n:govoplan-calendar.identity.7e5a975b": "Identity",
"i18n:govoplan-calendar.inbound_only.bf4269b0": "Inbound only",
"i18n:govoplan-calendar.interval.011efcd5": "Interval",
@@ -308,6 +316,9 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",
"i18n:govoplan-calendar.not_synced.4c205136": "Not synced",
"i18n:govoplan-calendar.opaque.3e1d0194": "OPAQUE",
"i18n:govoplan-calendar.open_xchange_dav_url.9d1adeaf": "Open-Xchange-DAV-URL",
"i18n:govoplan-calendar.open_xchange_source.4cc03862": "Open-Xchange-Quelle",
"i18n:govoplan-calendar.open_xchange.637e5b7b": "Open-Xchange",
"i18n:govoplan-calendar.organizer_json.3add6f9f": "Organizer JSON",
"i18n:govoplan-calendar.organizer.debd1720": "Organizer",
"i18n:govoplan-calendar.overwrite_remote.39625e32": "Overwrite remote",
@@ -321,6 +332,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.recurrence_id.e0b780ba": "Recurrence ID",
"i18n:govoplan-calendar.recurrence.f7ad40f5": "Wiederholung",
"i18n:govoplan-calendar.refresh.56e3badc": "Refresh",
"i18n:govoplan-calendar.resource_calendar_reference.4df67054": "Ressourcenkalenderreferenz",
"i18n:govoplan-calendar.related_to_json.2d4e8f59": "Related-To JSON",
"i18n:govoplan-calendar.related_to.0e7989ff": "Related-To",
"i18n:govoplan-calendar.related.917df91e": "Related",