diff --git a/BACKLOG.md b/BACKLOG.md
index d654b56..24a0587 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -55,7 +55,7 @@ SPEC: „Drag & Drop zwischen Tagen/Slots". Die Wochenansicht zeigt Mahlzeit-Kar
### BL-04 — Kalender-Sync: Settings-UI vollständig verdrahten
-**Status:** Offen
+**Status:** Erledigt (v0.3.0)
**Aufwand:** M (2–3 Tage)
Die Sync-Services `server/services/google-calendar.js` und `server/services/apple-calendar.js` sind implementiert (~300 Zeilen je). Das Settings-UI in `public/pages/settings.js` zeigt die Verbindungs-Buttons. Unklar ob der komplette OAuth-Flow (Redirect → Callback → Token-Speicherung → Auto-Sync-Intervall) end-to-end getestet und fehlerfrei ist.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5008e0..460cd89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Budget: recurring entries auto-generate instances for each viewed month; instances deleted by the user are skipped permanently via `budget_recurrence_skipped` table; generated instances are marked with ↩ in the transaction list
- Budget: month-over-month comparison in summary cards — each card (Einnahmen, Ausgaben, Saldo) shows a trend line (▲/▼ + delta amount vs. previous month); previous month summary is fetched in parallel with current month
- Meals: drag & drop between slots and days using Pointer Events (touch + mouse); ghost element follows pointer; drop on occupied slot swaps meals; reduced-motion: no ghost animation, interaction still works
+- Settings: Apple CalDAV credentials form (URL, Apple-ID, app-specific password) with live connection test; admin can connect and disconnect via UI without restarting the server; DB-stored credentials take precedence over .env vars; auto-sync runs every 15 min (configurable via SYNC_INTERVAL_MINUTES)
## [0.2.1] - 2026-03-30
diff --git a/public/pages/settings.js b/public/pages/settings.js
index d603fae..29c5f4d 100644
--- a/public/pages/settings.js
+++ b/public/pages/settings.js
@@ -147,17 +147,38 @@ export async function render(container, { user }) {
Apple Calendar (iCloud)
- ${appleStatus.configured
- ? `Konfiguriert${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}`
- : 'Nicht konfiguriert (APPLE_CALDAV_URL, APPLE_USERNAME, APPLE_APP_SPECIFIC_PASSWORD in .env setzen)'}
+ ${appleStatus.connected
+ ? `Verbunden${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}`
+ : appleStatus.configured
+ ? `Konfiguriert (via .env)${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}`
+ : 'Nicht verbunden'}
${appleStatus.configured ? `
Jetzt synchronisieren
+ ${appleStatus.connected && user?.role === 'admin' ? `Verbindung trennen ` : ''}
- ` : ''}
+ ` : user?.role === 'admin' ? `
+
+ ` : 'Nur Admin kann Apple Calendar verbinden. '}
@@ -318,6 +339,49 @@ function bindEvents(container, user) {
});
}
+ // Apple Disconnect (Admin)
+ const appleDisconnectBtn = container.querySelector('#apple-disconnect-btn');
+ if (appleDisconnectBtn) {
+ appleDisconnectBtn.addEventListener('click', async () => {
+ if (!confirm('Apple Calendar-Verbindung trennen?')) return;
+ try {
+ await api.delete('/calendar/apple/disconnect');
+ window.oikos?.showToast('Apple Calendar getrennt.', 'default');
+ window.oikos?.navigate('/settings');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ });
+ }
+
+ // Apple Connect-Formular (Admin)
+ const appleConnectForm = container.querySelector('#apple-connect-form');
+ if (appleConnectForm) {
+ appleConnectForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const errorEl = container.querySelector('#apple-connect-error');
+ errorEl.hidden = true;
+
+ const url = container.querySelector('#apple-caldav-url').value.trim();
+ const username = container.querySelector('#apple-username').value.trim();
+ const password = container.querySelector('#apple-password').value;
+ const btn = container.querySelector('#apple-connect-btn');
+
+ btn.disabled = true;
+ btn.textContent = 'Verbinde…';
+ try {
+ await api.post('/calendar/apple/connect', { url, username, password });
+ window.oikos?.showToast('Apple Calendar verbunden.', 'success');
+ window.oikos?.navigate('/settings');
+ } catch (err) {
+ showError(errorEl, err.message);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Verbinden & testen';
+ }
+ });
+ }
+
// Mitglied hinzufügen (Admin)
const addMemberBtn = container.querySelector('#add-member-btn');
if (addMemberBtn) {
diff --git a/public/styles/settings.css b/public/styles/settings.css
index 385d3f0..ebfada6 100644
--- a/public/styles/settings.css
+++ b/public/styles/settings.css
@@ -139,6 +139,12 @@
gap: var(--space-3);
}
+.settings-form--compact {
+ margin-top: var(--space-3);
+ padding-top: var(--space-3);
+ border-top: 1px solid var(--color-border);
+}
+
.settings-form-actions {
display: flex;
gap: var(--space-2);
diff --git a/server/routes/calendar.js b/server/routes/calendar.js
index 47e2de8..197d60b 100644
--- a/server/routes/calendar.js
+++ b/server/routes/calendar.js
@@ -297,6 +297,52 @@ router.post('/apple/sync', async (req, res) => {
}
});
+/**
+ * POST /api/v1/calendar/apple/connect
+ * Apple-CalDAV-Credentials speichern und Verbindung testen.
+ * Body: { url, username, password }
+ * Response: { ok: true, calendarCount: number }
+ */
+router.post('/apple/connect', requireAdmin, async (req, res) => {
+ const { url, username, password } = req.body;
+ if (!url || typeof url !== 'string' || !url.startsWith('http')) {
+ return res.status(400).json({ error: 'url muss eine gültige HTTP(S)-URL sein.', code: 400 });
+ }
+ if (!username || typeof username !== 'string' || username.length > 254) {
+ return res.status(400).json({ error: 'username fehlt oder ungültig.', code: 400 });
+ }
+ if (!password || typeof password !== 'string' || password.length < 1) {
+ return res.status(400).json({ error: 'password fehlt.', code: 400 });
+ }
+
+ try {
+ // Zuerst temporär setzen, damit testConnection() sie findet
+ appleCalendar.saveCredentials(url.trim(), username.trim(), password);
+ const result = await appleCalendar.testConnection();
+ res.json({ ok: true, calendarCount: result.calendarCount });
+ } catch (err) {
+ // Bei Fehler: gespeicherte Credentials wieder löschen
+ appleCalendar.clearCredentials();
+ console.error('[calendar/apple/connect]', err);
+ res.status(400).json({ error: err.message.replace('[Apple] ', ''), code: 400 });
+ }
+});
+
+/**
+ * DELETE /api/v1/calendar/apple/disconnect
+ * Apple-CalDAV-Credentials löschen.
+ * Response: 204
+ */
+router.delete('/apple/disconnect', requireAdmin, (req, res) => {
+ try {
+ appleCalendar.clearCredentials();
+ res.status(204).end();
+ } catch (err) {
+ console.error('[calendar/apple/disconnect]', err);
+ res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ }
+});
+
// --------------------------------------------------------
// GET /api/v1/calendar/:id
// Einzelnen Termin abrufen.
diff --git a/server/services/apple-calendar.js b/server/services/apple-calendar.js
index fe6df1e..bce76bc 100644
--- a/server/services/apple-calendar.js
+++ b/server/services/apple-calendar.js
@@ -36,18 +36,60 @@ function cfgSet(key, value) {
`).run(key, value);
}
+// --------------------------------------------------------
+// Credentials: sync_config hat Vorrang vor .env
+// --------------------------------------------------------
+
+function getCredentials() {
+ const url = cfgGet('apple_caldav_url') || process.env.APPLE_CALDAV_URL;
+ const username = cfgGet('apple_username') || process.env.APPLE_USERNAME;
+ const password = cfgGet('apple_app_password') || process.env.APPLE_APP_SPECIFIC_PASSWORD;
+ if (!url || !username || !password) return null;
+ return { url, username, password };
+}
+
+function saveCredentials(url, username, password) {
+ cfgSet('apple_caldav_url', url);
+ cfgSet('apple_username', username);
+ cfgSet('apple_app_password', password);
+}
+
+function clearCredentials() {
+ ['apple_caldav_url', 'apple_username', 'apple_app_password', 'apple_last_sync'].forEach(cfgDel);
+ console.log('[Apple] Verbindung getrennt.');
+}
+
// --------------------------------------------------------
// Verbindungsstatus
// --------------------------------------------------------
function getStatus() {
- const configured = !!(
- process.env.APPLE_CALDAV_URL &&
- process.env.APPLE_USERNAME &&
- process.env.APPLE_APP_SPECIFIC_PASSWORD
- );
- const lastSync = cfgGet('apple_last_sync');
- return { configured, lastSync };
+ const creds = getCredentials();
+ const configured = !!creds;
+ const connected = !!(cfgGet('apple_caldav_url')); // via UI gespeichert
+ const lastSync = cfgGet('apple_last_sync');
+ return { configured, connected, lastSync };
+}
+
+/**
+ * Verbindungstest: CalDAV-Client erstellen und Kalender abrufen.
+ * Wirft einen Fehler wenn die Credentials ungültig sind.
+ */
+async function testConnection() {
+ const creds = getCredentials();
+ if (!creds) throw new Error('[Apple] Keine Credentials konfiguriert.');
+
+ const { createDAVClient } = await import('tsdav');
+ const client = await createDAVClient({
+ serverUrl: creds.url,
+ credentials: { username: creds.username, password: creds.password },
+ authMethod: 'Basic',
+ defaultAccountType: 'caldav',
+ });
+
+ const calendars = await client.fetchCalendars();
+ if (!calendars.length) throw new Error('[Apple] Verbunden, aber keine Kalender gefunden.');
+ return { ok: true, calendarCount: calendars.length };
}
// --------------------------------------------------------
@@ -190,21 +232,18 @@ function escapeICS(str) {
* Outbound: lokale Termine (external_source='local', external_calendar_id IS NULL) → iCloud
*/
async function sync() {
- const caldavUrl = process.env.APPLE_CALDAV_URL;
- const username = process.env.APPLE_USERNAME;
- const password = process.env.APPLE_APP_SPECIFIC_PASSWORD;
-
- if (!caldavUrl || !username || !password) {
- throw new Error('[Apple] APPLE_CALDAV_URL, APPLE_USERNAME und APPLE_APP_SPECIFIC_PASSWORD müssen gesetzt sein.');
+ const creds = getCredentials();
+ if (!creds) {
+ throw new Error('[Apple] Keine Credentials konfiguriert (weder in DB noch in .env).');
}
// tsdav ist ESM-only — dynamischer Import aus CommonJS
const { createDAVClient } = await import('tsdav');
const client = await createDAVClient({
- serverUrl: caldavUrl,
- credentials: { username, password },
- authMethod: 'Basic',
+ serverUrl: creds.url,
+ credentials: { username: creds.username, password: creds.password },
+ authMethod: 'Basic',
defaultAccountType: 'caldav',
});
@@ -289,4 +328,4 @@ async function sync() {
console.log(`[Apple] Sync abgeschlossen — ${calObjects.length} Objekte inbound, ${localEvents.length} lokal → iCloud.`);
}
-module.exports = { sync, getStatus };
+module.exports = { sync, getStatus, saveCredentials, clearCredentials, testConnection };