From ddc0c684eca87b8138bd7974a02f1a07dfabd778 Mon Sep 17 00:00:00 2001 From: Michelle Date: Tue, 3 Mar 2026 11:41:35 +0100 Subject: [PATCH] feat(caldav): implement CalDAV support with token management and calendar operations --- server/config/database.js | 30 +++ server/index.js | 3 + server/routes/caldav.js | 479 ++++++++++++++++++++++++++++++++++++++ server/routes/calendar.js | 65 ++++++ src/i18n/de.json | 24 +- src/i18n/en.json | 24 +- src/pages/Settings.jsx | 176 +++++++++++++- 7 files changed, 794 insertions(+), 7 deletions(-) create mode 100644 server/routes/caldav.js diff --git a/server/config/database.js b/server/config/database.js index 11d62d2..0222a26 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -649,6 +649,36 @@ export async function initDatabase() { `); } + // ── CalDAV tokens ──────────────────────────────────────────────────────── + if (isPostgres) { + await db.exec(` + CREATE TABLE IF NOT EXISTS caldav_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + last_used_at TIMESTAMP DEFAULT NULL + ); + CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token); + `); + } else { + await db.exec(` + CREATE TABLE IF NOT EXISTS caldav_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token); + `); + } + // ── Default admin (only on very first start) ──────────────────────────── const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'"); if (!adminAlreadySeeded) { diff --git a/server/index.js b/server/index.js index c772e62..4953474 100644 --- a/server/index.js +++ b/server/index.js @@ -14,6 +14,7 @@ import adminRoutes from './routes/admin.js'; import brandingRoutes from './routes/branding.js'; import federationRoutes, { wellKnownHandler } from './routes/federation.js'; import calendarRoutes from './routes/calendar.js'; +import caldavRoutes from './routes/caldav.js'; import notificationRoutes from './routes/notifications.js'; import { startFederationSync } from './jobs/federationSync.js'; @@ -57,6 +58,8 @@ async function start() { app.use('/api/federation', federationRoutes); app.use('/api/calendar', calendarRoutes); app.use('/api/notifications', notificationRoutes); + // CalDAV — mounted outside /api so calendar clients use a clean path + app.use('/caldav', caldavRoutes); // Mount calendar federation receive also under /api/federation for remote instances app.use('/api/federation', calendarRoutes); app.get('/.well-known/redlight', wellKnownHandler); diff --git a/server/routes/caldav.js b/server/routes/caldav.js new file mode 100644 index 0000000..dbc9bb9 --- /dev/null +++ b/server/routes/caldav.js @@ -0,0 +1,479 @@ +/** + * CalDAV server for Redlight + * + * Supports PROPFIND, REPORT, GET, PUT, DELETE, OPTIONS — enough for + * Thunderbird/Lightning, Apple Calendar, GNOME Calendar and DAVx⁵ (Android). + * + * Authentication: HTTP Basic Auth → email:caldav_token + * Token management: POST/GET/DELETE /api/calendar/caldav-tokens + * + * Mounted at: /caldav + */ + +import { Router, text } from 'express'; +import crypto from 'crypto'; +import { getDb } from '../config/database.js'; +import { log } from '../config/logger.js'; + +const router = Router(); + +// ── Body parsing for XML and iCalendar payloads ──────────────────────────── +router.use(text({ type: ['application/xml', 'text/xml', 'text/calendar', 'application/octet-stream'] })); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function xmlHeader() { + return ''; +} + +function escapeXml(str) { + return String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function escapeICS(str) { + return String(str || '') + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n'); +} + +function foldICSLine(line) { + // RFC 5545: fold lines longer than 75 octets + const bytes = Buffer.from(line, 'utf8'); + if (bytes.length <= 75) return line; + const chunks = []; + let offset = 0; + let first = true; + while (offset < bytes.length) { + const chunk = first ? bytes.slice(0, 75) : bytes.slice(offset, offset + 74); + chunks.push((first ? '' : ' ') + chunk.toString('utf8')); + offset += first ? 75 : 74; + first = false; + } + return chunks.join('\r\n'); +} + +function toICSDate(dateStr) { + const d = new Date(dateStr); + return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); +} + +function parseICSDate(str) { + if (!str) return null; + // Strip TZID= prefix if present + const raw = str.includes(':') ? str.split(':').pop() : str; + if (raw.length === 8) { + // All-day: YYYYMMDD + return new Date(`${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}T00:00:00Z`); + } + return new Date( + `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}T` + + `${raw.slice(9, 11)}:${raw.slice(11, 13)}:${raw.slice(13, 15)}` + + (raw.endsWith('Z') ? 'Z' : 'Z'), + ); +} + +function getICSProp(ics, key) { + const re = new RegExp(`^${key}(?:;[^:]*)?:(.+)$`, 'im'); + const m = ics.match(re); + if (!m) return null; + // Unfold: join continuation lines + let v = m[1]; + const unfoldRe = /\r?\n[ \t](.+)/g; + v = v.replace(unfoldRe, '$1'); + return v.trim(); +} + +function eventToICS(event) { + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Redlight//CalDAV//EN', + 'CALSCALE:GREGORIAN', + 'BEGIN:VEVENT', + `UID:${event.uid}`, + `SUMMARY:${escapeICS(event.title)}`, + `DTSTART:${toICSDate(event.start_time)}`, + `DTEND:${toICSDate(event.end_time)}`, + `DTSTAMP:${toICSDate(event.updated_at || event.created_at)}`, + `LAST-MODIFIED:${toICSDate(event.updated_at || event.created_at)}`, + ]; + if (event.description) { + lines.push(`DESCRIPTION:${escapeICS(event.description)}`); + } + if (event.room_uid) { + lines.push(`X-REDLIGHT-ROOM-UID:${event.room_uid}`); + } + if (event.federated_join_url) { + lines.push(`X-REDLIGHT-JOIN-URL:${escapeICS(event.federated_join_url)}`); + } + lines.push('END:VEVENT', 'END:VCALENDAR'); + return lines.map(foldICSLine).join('\r\n'); +} + +function parseICSBody(body) { + const uid = getICSProp(body, 'UID'); + const summary = (getICSProp(body, 'SUMMARY') || '') + .replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';'); + const description = (getICSProp(body, 'DESCRIPTION') || '') + .replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';'); + const dtstart = getICSProp(body, 'DTSTART'); + const dtend = getICSProp(body, 'DTEND'); + return { + uid: uid || null, + title: summary || 'Untitled', + description: description || null, + start_time: parseICSDate(dtstart), + end_time: parseICSDate(dtend), + }; +} + +// Build etag from updated_at +function etag(event) { + return `"${Buffer.from(event.updated_at || event.created_at).toString('base64')}"`; +} + +// ── CalDAV authentication middleware ─────────────────────────────────────── +async function caldavAuth(req, res, next) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Basic ')) { + res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"'); + return res.status(401).end(); + } + try { + const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); + const colonIdx = decoded.indexOf(':'); + if (colonIdx === -1) throw new Error('no colon'); + const email = decoded.slice(0, colonIdx); + const token = decoded.slice(colonIdx + 1); + + const db = getDb(); + const user = await db.get( + 'SELECT id, name, display_name, email FROM users WHERE email = ?', + [email], + ); + if (!user) { + res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"'); + return res.status(401).end(); + } + const tokenRow = await db.get( + 'SELECT * FROM caldav_tokens WHERE user_id = ? AND token = ?', + [user.id, token], + ); + if (!tokenRow) { + res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"'); + return res.status(401).end(); + } + // Update last_used_at (fire and forget) + db.run('UPDATE caldav_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [tokenRow.id]).catch(() => {}); + req.caldavUser = user; + next(); + } catch (err) { + log.server.error(`CalDAV auth error: ${err.message}`); + res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"'); + return res.status(401).end(); + } +} + +// ── Common response headers ──────────────────────────────────────────────── +function setDAVHeaders(res) { + res.set('DAV', '1, 2, calendar-access'); + res.set('Allow', 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT'); + res.set('MS-Author-Via', 'DAV'); +} + +// ── Base URL helper ──────────────────────────────────────────────────────── +function baseUrl(req) { + const proto = req.get('x-forwarded-proto') || req.protocol; + const host = req.get('x-forwarded-host') || req.get('host'); + return `${proto}://${host}`; +} + +// ── PROPFIND response builders ───────────────────────────────────────────── + +function multistatus(responses) { + return `${xmlHeader()} + +${responses.join('\n')} +`; +} + +function propResponse(href, props, status = '200 OK') { + const propXml = Object.entries(props) + .map(([k, v]) => ` <${k}>${v}`) + .join('\n'); + return ` + ${escapeXml(href)} + + +${propXml} + + HTTP/1.1 ${status} + + `; +} + +// ── OPTIONS ──────────────────────────────────────────────────────────────── +router.options('*', (req, res) => { + setDAVHeaders(res); + res.status(200).end(); +}); + +// ── PROPFIND / ───────────────────────────────────────────────────────────── +// Service discovery root: tells the client where the user principal lives. +router.all('/', caldavAuth, async (req, res) => { + if (req.method !== 'PROPFIND') { + setDAVHeaders(res); + return res.status(405).end(); + } + const base = baseUrl(req); + const principalHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/`; + setDAVHeaders(res); + res.status(207).type('application/xml; charset=utf-8').send( + multistatus([ + propResponse('/caldav/', { + 'd:current-user-principal': `${principalHref}`, + 'd:principal-URL': `${principalHref}`, + 'd:resourcetype': '', + 'd:displayname': 'Redlight CalDAV', + }), + ]), + ); +}); + +// ── PROPFIND /{username}/ ────────────────────────────────────────────────── +// User principal: tells the client where the calendar home is. +router.all('/:username/', caldavAuth, async (req, res) => { + if (req.method !== 'PROPFIND') { + setDAVHeaders(res); + return res.status(405).end(); + } + const principalHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/`; + const calendarHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/calendar/`; + setDAVHeaders(res); + res.status(207).type('application/xml; charset=utf-8').send( + multistatus([ + propResponse(principalHref, { + 'd:resourcetype': '', + 'd:displayname': escapeXml(req.caldavUser.display_name || req.caldavUser.name), + 'd:principal-URL': `${principalHref}`, + 'c:calendar-home-set': `${calendarHref}`, + 'd:current-user-principal': `${principalHref}`, + }), + ]), + ); +}); + +// ── PROPFIND + REPORT /{username}/calendar/ ──────────────────────────────── +router.all('/:username/calendar/', caldavAuth, async (req, res) => { + const db = getDb(); + const calendarHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/calendar/`; + + // PROPFIND: return calendar collection metadata + if (req.method === 'PROPFIND') { + const latestEvent = await db.get( + 'SELECT updated_at, created_at FROM calendar_events WHERE user_id = ? ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 1', + [req.caldavUser.id], + ); + const ctag = latestEvent + ? Buffer.from(String(latestEvent.updated_at || latestEvent.created_at)).toString('base64') + : '0'; + + // If Depth: 1, also return all event hrefs + const depth = req.headers.depth || '0'; + const responses = [ + propResponse(calendarHref, { + 'd:resourcetype': '', + 'd:displayname': 'Redlight Calendar', + 'c:supported-calendar-component-set': '', + 'cs:getctag': escapeXml(ctag), + 'd:sync-token': escapeXml(ctag), + }), + ]; + + if (depth === '1') { + const events = await db.all( + 'SELECT uid, updated_at, created_at FROM calendar_events WHERE user_id = ?', + [req.caldavUser.id], + ); + for (const ev of events) { + responses.push( + propResponse(`${calendarHref}${ev.uid}.ics`, { + 'd:resourcetype': '', + 'd:getcontenttype': 'text/calendar; charset=utf-8', + 'd:getetag': escapeXml(etag(ev)), + }), + ); + } + } + + setDAVHeaders(res); + return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses)); + } + + // REPORT: calendar-query or calendar-multiget + if (req.method === 'REPORT') { + const body = typeof req.body === 'string' ? req.body : ''; + + // calendar-multiget: client sends explicit hrefs + if (body.includes('calendar-multiget')) { + const hrefMatches = [...body.matchAll(/<[^:>]*:?href[^>]*>([^<]+)<\//gi)]; + const uids = hrefMatches + .map(m => m[1].trim()) + .filter(h => h.endsWith('.ics')) + .map(h => h.split('/').pop().replace('.ics', '')); + + const responses = []; + for (const uid of uids) { + const ev = await db.get( + 'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?', + [uid, req.caldavUser.id], + ); + if (!ev) continue; + const ics = eventToICS(ev); + responses.push( + propResponse(`${calendarHref}${ev.uid}.ics`, { + 'd:getetag': escapeXml(etag(ev)), + 'c:calendar-data': escapeXml(ics), + }), + ); + } + setDAVHeaders(res); + return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses)); + } + + // calendar-query: filter by time range + let sql = 'SELECT * FROM calendar_events WHERE user_id = ?'; + const params = [req.caldavUser.id]; + + const startMatch = body.match(/start="([^"]+)"/i); + const endMatch = body.match(/end="([^"]+)"/i); + if (startMatch) { + const startIso = parseICSDate(startMatch[1])?.toISOString(); + if (startIso) { sql += ' AND end_time >= ?'; params.push(startIso); } + } + if (endMatch) { + const endIso = parseICSDate(endMatch[1])?.toISOString(); + if (endIso) { sql += ' AND start_time <= ?'; params.push(endIso); } + } + sql += ' ORDER BY start_time ASC'; + + const events = await db.all(sql, params); + const responses = events.map(ev => + propResponse(`${calendarHref}${ev.uid}.ics`, { + 'd:getetag': escapeXml(etag(ev)), + 'c:calendar-data': escapeXml(eventToICS(ev)), + }), + ); + setDAVHeaders(res); + return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses)); + } + + setDAVHeaders(res); + res.status(405).end(); +}); + +// ── GET /{username}/calendar/{uid}.ics ──────────────────────────────────── +router.get('/:username/calendar/:filename', caldavAuth, async (req, res) => { + const uid = req.params.filename.replace(/\.ics$/, ''); + const db = getDb(); + const ev = await db.get( + 'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?', + [uid, req.caldavUser.id], + ); + if (!ev) return res.status(404).end(); + setDAVHeaders(res); + res.set('ETag', etag(ev)); + res.set('Content-Type', 'text/calendar; charset=utf-8'); + res.send(eventToICS(ev)); +}); + +// ── PUT /{username}/calendar/{uid}.ics — create or update ───────────────── +router.put('/:username/calendar/:filename', caldavAuth, async (req, res) => { + const uid = req.params.filename.replace(/\.ics$/, ''); + const body = typeof req.body === 'string' ? req.body : ''; + + if (!body) return res.status(400).end(); + + const parsed = parseICSBody(body); + if (!parsed.start_time || !parsed.end_time) return res.status(400).end(); + + // Normalize UID: prefer from ICS, fall back to filename + const eventUid = parsed.uid || uid; + + const db = getDb(); + const existing = await db.get( + 'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?', + [eventUid, req.caldavUser.id], + ); + + try { + if (existing) { + await db.run( + `UPDATE calendar_events SET title = ?, description = ?, start_time = ?, end_time = ?, + updated_at = CURRENT_TIMESTAMP WHERE uid = ? AND user_id = ?`, + [ + parsed.title, + parsed.description, + parsed.start_time.toISOString(), + parsed.end_time.toISOString(), + eventUid, + req.caldavUser.id, + ], + ); + const updated = await db.get('SELECT * FROM calendar_events WHERE uid = ?', [eventUid]); + setDAVHeaders(res); + res.set('ETag', etag(updated)); + return res.status(204).end(); + } else { + await db.run( + `INSERT INTO calendar_events (uid, title, description, start_time, end_time, user_id, color) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + eventUid, + parsed.title, + parsed.description, + parsed.start_time.toISOString(), + parsed.end_time.toISOString(), + req.caldavUser.id, + '#6366f1', + ], + ); + const created = await db.get('SELECT * FROM calendar_events WHERE uid = ?', [eventUid]); + setDAVHeaders(res); + res.set('ETag', etag(created)); + return res.status(201).end(); + } + } catch (err) { + log.server.error(`CalDAV PUT error: ${err.message}`); + return res.status(500).end(); + } +}); + +// ── DELETE /{username}/calendar/{uid}.ics ───────────────────────────────── +router.delete('/:username/calendar/:filename', caldavAuth, async (req, res) => { + const uid = req.params.filename.replace(/\.ics$/, ''); + const db = getDb(); + const ev = await db.get( + 'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?', + [uid, req.caldavUser.id], + ); + if (!ev) return res.status(404).end(); + await db.run('DELETE FROM calendar_events WHERE uid = ? AND user_id = ?', [uid, req.caldavUser.id]); + setDAVHeaders(res); + res.status(204).end(); +}); + +// ── Fallback ─────────────────────────────────────────────────────────────── +router.all('*', caldavAuth, (req, res) => { + setDAVHeaders(res); + res.status(405).end(); +}); + +export default router; diff --git a/server/routes/calendar.js b/server/routes/calendar.js index ae46538..f08b489 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -677,4 +677,69 @@ function generateICS(event, location, prodIdDomain) { return ics.join('\r\n'); } +// ── CalDAV token management ──────────────────────────────────────────────── + +// GET /api/calendar/caldav-tokens +router.get('/caldav-tokens', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const tokens = await db.all( + 'SELECT id, name, created_at, last_used_at FROM caldav_tokens WHERE user_id = ? ORDER BY created_at DESC', + [req.user.id], + ); + res.json({ tokens }); + } catch (err) { + log.server.error(`CalDAV list tokens error: ${err.message}`); + res.status(500).json({ error: 'Could not load tokens' }); + } +}); + +// POST /api/calendar/caldav-tokens +router.post('/caldav-tokens', authenticateToken, async (req, res) => { + try { + const { name } = req.body; + if (!name || !name.trim()) { + return res.status(400).json({ error: 'Token name is required' }); + } + const db = getDb(); + const count = await db.get( + 'SELECT COUNT(*) as c FROM caldav_tokens WHERE user_id = ?', + [req.user.id], + ); + if (count.c >= 10) { + return res.status(400).json({ error: 'Maximum of 10 tokens allowed' }); + } + const token = crypto.randomBytes(32).toString('hex'); + const result = await db.run( + 'INSERT INTO caldav_tokens (user_id, token, name) VALUES (?, ?, ?)', + [req.user.id, token, name.trim()], + ); + res.status(201).json({ + token: { id: result.lastInsertRowid, name: name.trim() }, + plainToken: token, + }); + } catch (err) { + log.server.error(`CalDAV create token error: ${err.message}`); + res.status(500).json({ error: 'Could not create token' }); + } +}); + +// DELETE /api/calendar/caldav-tokens/:id +router.delete('/caldav-tokens/:id', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const result = await db.run( + 'DELETE FROM caldav_tokens WHERE id = ? AND user_id = ?', + [req.params.id, req.user.id], + ); + if (result.changes === 0) { + return res.status(404).json({ error: 'Token not found' }); + } + res.json({ ok: true }); + } catch (err) { + log.server.error(`CalDAV delete token error: ${err.message}`); + res.status(500).json({ error: 'Could not delete token' }); + } +}); + export default router; diff --git a/src/i18n/de.json b/src/i18n/de.json index 4b2781b..54ccc0c 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -294,7 +294,29 @@ "passwordChanged": "Passwort geändert", "passwordChangeFailed": "Fehler beim Ändern", "passwordMismatch": "Passwörter stimmen nicht überein", - "selectLanguage": "Sprache auswählen" + "selectLanguage": "Sprache auswählen", + "caldav": { + "title": "CalDAV", + "subtitle": "Verbinde deine Kalender-App (z. B. Apple Kalender, Thunderbird, DAVx⁵) über das CalDAV-Protokoll. Verwende deine E-Mail-Adresse und ein App-Token als Passwort.", + "serverUrl": "Server-URL", + "username": "Benutzername (E-Mail)", + "hint": "Gib niemals dein echtes Redlight-Passwort in einer Kalender-App ein. Verwende stattdessen ein App-Token.", + "newToken": "Neues App-Token generieren", + "tokenNamePlaceholder": "z. B. \"iPhone\" oder \"Thunderbird\"", + "generate": "Generieren", + "existingTokens": "Aktive Tokens", + "noTokens": "Noch keine Tokens erstellt.", + "created": "Erstellt", + "lastUsed": "Zuletzt verwendet", + "revoke": "Widerrufen", + "revokeConfirm": "Dieses Token wirklich widerrufen? Alle Kalender-Apps, die dieses Token verwenden, verlieren den Zugriff.", + "revoked": "Token widerrufen", + "revokeFailed": "Token konnte nicht widerrufen werden", + "createFailed": "Token konnte nicht erstellt werden", + "newTokenCreated": "Token erstellt — jetzt kopieren!", + "newTokenHint": "Dieses Token wird nur einmal angezeigt. Kopiere es und trage es als Passwort in deiner Kalender-App ein.", + "dismiss": "Ich habe das Token kopiert" + } }, "themes": { "selectTheme": "Theme auswählen", diff --git a/src/i18n/en.json b/src/i18n/en.json index fd2d48d..2ffdeae 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -294,7 +294,29 @@ "passwordChanged": "Password changed", "passwordChangeFailed": "Error changing password", "passwordMismatch": "Passwords do not match", - "selectLanguage": "Select language" + "selectLanguage": "Select language", + "caldav": { + "title": "CalDAV", + "subtitle": "Connect your calendar app (e.g. Apple Calendar, Thunderbird, DAVx⁵) using the CalDAV protocol. Use your email address and an app token as password.", + "serverUrl": "Server URL", + "username": "Username (Email)", + "hint": "Never enter your real Redlight password in a calendar app. Use an app token instead.", + "newToken": "Generate new app token", + "tokenNamePlaceholder": "e.g. \"iPhone\" or \"Thunderbird\"", + "generate": "Generate", + "existingTokens": "Active tokens", + "noTokens": "No tokens created yet.", + "created": "Created", + "lastUsed": "Last used", + "revoke": "Revoke", + "revokeConfirm": "Really revoke this token? All connected calendar apps using this token will lose access.", + "revoked": "Token revoked", + "revokeFailed": "Could not revoke token", + "createFailed": "Could not create token", + "newTokenCreated": "Token created — copy it now!", + "newTokenHint": "This token will only be shown once. Copy it and enter it as the password in your calendar app.", + "dismiss": "I have copied the token" + } }, "themes": { "selectTheme": "Select theme", diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx index 2195050..93410fc 100644 --- a/src/pages/Settings.jsx +++ b/src/pages/Settings.jsx @@ -1,5 +1,5 @@ -import { useState, useRef } from 'react'; -import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X } from 'lucide-react'; +import { useState, useRef, useEffect } from 'react'; +import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { useTheme } from '../contexts/ThemeContext'; import { useLanguage } from '../contexts/LanguageContext'; @@ -38,6 +38,53 @@ export default function Settings() { const [uploadingAvatar, setUploadingAvatar] = useState(false); const fileInputRef = useRef(null); + // CalDAV token state + const [caldavTokens, setCaldavTokens] = useState([]); + const [caldavLoading, setCaldavLoading] = useState(false); + const [newTokenName, setNewTokenName] = useState(''); + const [creatingToken, setCreatingToken] = useState(false); + const [newlyCreatedToken, setNewlyCreatedToken] = useState(null); + const [tokenVisible, setTokenVisible] = useState(false); + + useEffect(() => { + if (activeSection === 'caldav') { + setCaldavLoading(true); + api.get('/calendar/caldav-tokens') + .then(r => setCaldavTokens(r.data.tokens || [])) + .catch(() => {}) + .finally(() => setCaldavLoading(false)); + } + }, [activeSection]); + + const handleCreateToken = async (e) => { + e.preventDefault(); + if (!newTokenName.trim()) return; + setCreatingToken(true); + try { + const res = await api.post('/calendar/caldav-tokens', { name: newTokenName.trim() }); + setNewlyCreatedToken(res.data.plainToken); + setTokenVisible(false); + setNewTokenName(''); + const r = await api.get('/calendar/caldav-tokens'); + setCaldavTokens(r.data.tokens || []); + } catch (err) { + toast.error(err.response?.data?.error || t('settings.caldav.createFailed')); + } finally { + setCreatingToken(false); + } + }; + + const handleRevokeToken = async (id) => { + if (!confirm(t('settings.caldav.revokeConfirm'))) return; + try { + await api.delete(`/calendar/caldav-tokens/${id}`); + setCaldavTokens(prev => prev.filter(tk => tk.id !== id)); + toast.success(t('settings.caldav.revoked')); + } catch { + toast.error(t('settings.caldav.revokeFailed')); + } + }; + const groups = getThemeGroups(); const avatarColors = [ @@ -139,6 +186,7 @@ export default function Settings() { { id: 'password', label: t('settings.password'), icon: Lock }, { id: 'language', label: t('settings.language'), icon: Globe }, { id: 'themes', label: t('settings.themes'), icon: Palette }, + { id: 'caldav', label: t('settings.caldav.title'), icon: Calendar }, ]; return ( @@ -425,8 +473,126 @@ export default function Settings() { ))} )} - - + {/* CalDAV section */} + {activeSection === 'caldav' && ( +
+ {/* Info Card */} +
+

{t('settings.caldav.title')}

+

{t('settings.caldav.subtitle')}

+
+
+

{t('settings.caldav.serverUrl')}

+
+ + {`${window.location.origin}/caldav/`} + + +
+
+
+

{t('settings.caldav.username')}

+
+ + {user?.email} + + +
+
+

{t('settings.caldav.hint')}

+
+
+ + {/* New token was just created */} + {newlyCreatedToken && ( +
+

{t('settings.caldav.newTokenCreated')}

+

{t('settings.caldav.newTokenHint')}

+
+ + {tokenVisible ? newlyCreatedToken : '•'.repeat(48)} + + + +
+ +
+ )} + + {/* Create new token */} +
+

{t('settings.caldav.newToken')}

+
+ setNewTokenName(e.target.value)} + placeholder={t('settings.caldav.tokenNamePlaceholder')} + className="input-field flex-1 text-sm" + required + /> + +
+
+ + {/* Token list */} +
+

{t('settings.caldav.existingTokens')}

+ {caldavLoading ? ( +
+ ) : caldavTokens.length === 0 ? ( +

{t('settings.caldav.noTokens')}

+ ) : ( +
+ {caldavTokens.map(tk => ( +
+
+

{tk.name}

+

+ {t('settings.caldav.created')}: {new Date(tk.created_at).toLocaleDateString()} + {tk.last_used_at && ` · ${t('settings.caldav.lastUsed')}: ${new Date(tk.last_used_at).toLocaleDateString()}`} +

+
+ +
+ ))} +
+ )} +
+
+ )} + ); -} +} \ No newline at end of file