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 ? `
+ ${appleStatus.connected && user?.role === 'admin' ? `` : ''}
- ` : ''} + ` : user?.role === 'admin' ? ` +
+
+ + +
+
+ + +
+
+ + + Passwort unter appleid.apple.com → Sicherheit erstellen. +
+ + +
+ ` : '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 };