feat: Apple CalDAV credentials form + connect/disconnect UI (BL-04)
Admin can now enter CalDAV URL, Apple-ID and app-specific password directly in Settings; credentials are tested live before saving and stored in sync_config (take precedence over .env); disconnect clears DB-stored credentials without server restart. Auto-sync interval (15 min, configurable via SYNC_INTERVAL_MINUTES) was already in place. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -55,7 +55,7 @@ SPEC: „Drag & Drop zwischen Tagen/Slots". Die Wochenansicht zeigt Mahlzeit-Kar
|
|||||||
|
|
||||||
### BL-04 — Kalender-Sync: Settings-UI vollständig verdrahten
|
### BL-04 — Kalender-Sync: Settings-UI vollständig verdrahten
|
||||||
|
|
||||||
**Status:** Offen
|
**Status:** Erledigt (v0.3.0)
|
||||||
**Aufwand:** M (2–3 Tage)
|
**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.
|
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.
|
||||||
|
|||||||
@@ -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: 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
|
- 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
|
- 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
|
## [0.2.1] - 2026-03-30
|
||||||
|
|
||||||
|
|||||||
@@ -147,17 +147,38 @@ export async function render(container, { user }) {
|
|||||||
<div class="settings-sync-info">
|
<div class="settings-sync-info">
|
||||||
<div class="settings-sync-info__name">Apple Calendar (iCloud)</div>
|
<div class="settings-sync-info__name">Apple Calendar (iCloud)</div>
|
||||||
<div class="settings-sync-info__status ${appleStatus.configured ? 'settings-sync-info__status--connected' : ''}">
|
<div class="settings-sync-info__status ${appleStatus.configured ? 'settings-sync-info__status--connected' : ''}">
|
||||||
${appleStatus.configured
|
${appleStatus.connected
|
||||||
? `Konfiguriert${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}`
|
? `Verbunden${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}`
|
||||||
: 'Nicht konfiguriert (APPLE_CALDAV_URL, APPLE_USERNAME, APPLE_APP_SPECIFIC_PASSWORD in .env setzen)'}
|
: appleStatus.configured
|
||||||
|
? `Konfiguriert (via .env)${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}`
|
||||||
|
: 'Nicht verbunden'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${appleStatus.configured ? `
|
${appleStatus.configured ? `
|
||||||
<div class="settings-sync-actions">
|
<div class="settings-sync-actions">
|
||||||
<button class="btn btn--secondary" id="apple-sync-btn">Jetzt synchronisieren</button>
|
<button class="btn btn--secondary" id="apple-sync-btn">Jetzt synchronisieren</button>
|
||||||
|
${appleStatus.connected && user?.role === 'admin' ? `<button class="btn btn--danger-outline" id="apple-disconnect-btn">Verbindung trennen</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : user?.role === 'admin' ? `
|
||||||
|
<form id="apple-connect-form" class="settings-form settings-form--compact">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="apple-caldav-url">CalDAV-Server-URL</label>
|
||||||
|
<input class="form-input" type="url" id="apple-caldav-url" placeholder="https://caldav.icloud.com" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="apple-username">Apple-ID (E-Mail)</label>
|
||||||
|
<input class="form-input" type="email" id="apple-username" autocomplete="username" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="apple-password">App-spezifisches Passwort</label>
|
||||||
|
<input class="form-input" type="password" id="apple-password" autocomplete="current-password" required />
|
||||||
|
<span class="form-hint">Passwort unter <strong>appleid.apple.com → Sicherheit</strong> erstellen.</span>
|
||||||
|
</div>
|
||||||
|
<div id="apple-connect-error" class="form-error" hidden></div>
|
||||||
|
<button type="submit" class="btn btn--primary" id="apple-connect-btn">Verbinden & testen</button>
|
||||||
|
</form>
|
||||||
|
` : '<span class="form-hint">Nur Admin kann Apple Calendar verbinden.</span>'}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -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)
|
// Mitglied hinzufügen (Admin)
|
||||||
const addMemberBtn = container.querySelector('#add-member-btn');
|
const addMemberBtn = container.querySelector('#add-member-btn');
|
||||||
if (addMemberBtn) {
|
if (addMemberBtn) {
|
||||||
|
|||||||
@@ -139,6 +139,12 @@
|
|||||||
gap: var(--space-3);
|
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 {
|
.settings-form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
|||||||
@@ -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
|
// GET /api/v1/calendar/:id
|
||||||
// Einzelnen Termin abrufen.
|
// Einzelnen Termin abrufen.
|
||||||
|
|||||||
@@ -36,18 +36,60 @@ function cfgSet(key, value) {
|
|||||||
`).run(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
|
// Verbindungsstatus
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
function getStatus() {
|
function getStatus() {
|
||||||
const configured = !!(
|
const creds = getCredentials();
|
||||||
process.env.APPLE_CALDAV_URL &&
|
const configured = !!creds;
|
||||||
process.env.APPLE_USERNAME &&
|
const connected = !!(cfgGet('apple_caldav_url')); // via UI gespeichert
|
||||||
process.env.APPLE_APP_SPECIFIC_PASSWORD
|
|
||||||
);
|
|
||||||
const lastSync = cfgGet('apple_last_sync');
|
const lastSync = cfgGet('apple_last_sync');
|
||||||
return { configured, lastSync };
|
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,20 +232,17 @@ function escapeICS(str) {
|
|||||||
* Outbound: lokale Termine (external_source='local', external_calendar_id IS NULL) → iCloud
|
* Outbound: lokale Termine (external_source='local', external_calendar_id IS NULL) → iCloud
|
||||||
*/
|
*/
|
||||||
async function sync() {
|
async function sync() {
|
||||||
const caldavUrl = process.env.APPLE_CALDAV_URL;
|
const creds = getCredentials();
|
||||||
const username = process.env.APPLE_USERNAME;
|
if (!creds) {
|
||||||
const password = process.env.APPLE_APP_SPECIFIC_PASSWORD;
|
throw new Error('[Apple] Keine Credentials konfiguriert (weder in DB noch in .env).');
|
||||||
|
|
||||||
if (!caldavUrl || !username || !password) {
|
|
||||||
throw new Error('[Apple] APPLE_CALDAV_URL, APPLE_USERNAME und APPLE_APP_SPECIFIC_PASSWORD müssen gesetzt sein.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// tsdav ist ESM-only — dynamischer Import aus CommonJS
|
// tsdav ist ESM-only — dynamischer Import aus CommonJS
|
||||||
const { createDAVClient } = await import('tsdav');
|
const { createDAVClient } = await import('tsdav');
|
||||||
|
|
||||||
const client = await createDAVClient({
|
const client = await createDAVClient({
|
||||||
serverUrl: caldavUrl,
|
serverUrl: creds.url,
|
||||||
credentials: { username, password },
|
credentials: { username: creds.username, password: creds.password },
|
||||||
authMethod: 'Basic',
|
authMethod: 'Basic',
|
||||||
defaultAccountType: 'caldav',
|
defaultAccountType: 'caldav',
|
||||||
});
|
});
|
||||||
@@ -289,4 +328,4 @@ async function sync() {
|
|||||||
console.log(`[Apple] Sync abgeschlossen — ${calObjects.length} Objekte inbound, ${localEvents.length} lokal → iCloud.`);
|
console.log(`[Apple] Sync abgeschlossen — ${calObjects.length} Objekte inbound, ${localEvents.length} lokal → iCloud.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { sync, getStatus };
|
module.exports = { sync, getStatus, saveCredentials, clearCredentials, testConnection };
|
||||||
|
|||||||
Reference in New Issue
Block a user