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}${k}>`)
+ .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')}
+
+
+
+ {/* 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