diff --git a/server/config/database.js b/server/config/database.js index 0f2a220..f7d1430 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -514,6 +514,50 @@ export async function initDatabase() { await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL'); } + // Calendar invitations (federated calendar events that must be accepted first) + if (isPostgres) { + await db.exec(` + CREATE TABLE IF NOT EXISTS calendar_invitations ( + id SERIAL PRIMARY KEY, + event_uid TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + room_uid TEXT, + join_url TEXT, + from_user TEXT NOT NULL, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + color TEXT DEFAULT '#6366f1', + status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')), + created_at TIMESTAMP DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_inv_uid_user ON calendar_invitations(event_uid, to_user_id); + `); + } else { + await db.exec(` + CREATE TABLE IF NOT EXISTS calendar_invitations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_uid TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + room_uid TEXT, + join_url TEXT, + from_user TEXT NOT NULL, + to_user_id INTEGER NOT NULL, + color TEXT DEFAULT '#6366f1', + status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(event_uid, to_user_id) + ); + CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id); + `); + } + // ── 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/routes/calendar.js b/server/routes/calendar.js index 6a04858..2f47ea7 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -413,13 +413,13 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as const targetUser = await db.get('SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)', [username]); if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' }); - // Check duplicate - const existing = await db.get('SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?', [event_uid, targetUser.id]); - if (existing) return res.json({ success: true, message: 'Event already received' }); + // Check duplicate (already in invitations or already accepted into calendar) + const existingInv = await db.get('SELECT id FROM calendar_invitations WHERE event_uid = ? AND to_user_id = ?', [event_uid, targetUser.id]); + if (existingInv) return res.json({ success: true, message: 'Calendar invitation already received' }); - // Create event for the target user + // Store as pending invitation — user must accept before it appears in calendar await db.run(` - INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url) + INSERT INTO calendar_invitations (event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user_id, color) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ event_uid, @@ -428,10 +428,10 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as start_time, end_time, room_uid || null, + join_url || null, + from_user, targetUser.id, '#6366f1', - from_user, - join_url || null, ]); res.json({ success: true }); diff --git a/server/routes/federation.js b/server/routes/federation.js index c166dad..c26b067 100644 --- a/server/routes/federation.js +++ b/server/routes/federation.js @@ -261,12 +261,20 @@ router.get('/invitations', authenticateToken, async (req, res) => { router.get('/invitations/pending-count', authenticateToken, async (req, res) => { try { const db = getDb(); - const result = await db.get( + const roomResult = await db.get( `SELECT COUNT(*) as count FROM federation_invitations WHERE to_user_id = ? AND status = 'pending'`, [req.user.id] ); - res.json({ count: result?.count || 0 }); + let calResult = { count: 0 }; + try { + calResult = await db.get( + `SELECT COUNT(*) as count FROM calendar_invitations + WHERE to_user_id = ? AND status = 'pending'`, + [req.user.id] + ); + } catch { /* table may not exist yet */ } + res.json({ count: (roomResult?.count || 0) + (calResult?.count || 0) }); } catch (err) { res.json({ count: 0 }); } @@ -338,6 +346,94 @@ router.delete('/invitations/:id', authenticateToken, async (req, res) => { } }); +// ── GET /api/federation/calendar-invitations — List calendar invitations ───── +router.get('/calendar-invitations', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const invitations = await db.all( + `SELECT * FROM calendar_invitations + WHERE to_user_id = ? + ORDER BY created_at DESC`, + [req.user.id] + ); + res.json({ invitations }); + } catch (err) { + log.federation.error('List calendar invitations error:', err); + res.status(500).json({ error: 'Failed to load calendar invitations' }); + } +}); + +// ── POST /api/federation/calendar-invitations/:id/accept ───────────────────── +router.post('/calendar-invitations/:id/accept', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const inv = await db.get( + `SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`, + [req.params.id, req.user.id] + ); + if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' }); + if (inv.status === 'accepted') return res.status(400).json({ error: 'Already accepted' }); + + await db.run( + `UPDATE calendar_invitations SET status = 'accepted' WHERE id = ?`, + [inv.id] + ); + + // Check if event was already previously accepted (duplicate guard) + const existing = await db.get( + 'SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?', + [inv.event_uid, req.user.id] + ); + if (!existing) { + await db.run(` + INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + inv.event_uid, + inv.title, + inv.description || null, + inv.start_time, + inv.end_time, + inv.room_uid || null, + req.user.id, + inv.color || '#6366f1', + inv.from_user, + inv.join_url || null, + ]); + } + + res.json({ success: true }); + } catch (err) { + log.federation.error('Accept calendar invitation error:', err); + res.status(500).json({ error: 'Failed to accept calendar invitation' }); + } +}); + +// ── DELETE /api/federation/calendar-invitations/:id — Decline/dismiss ──────── +router.delete('/calendar-invitations/:id', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const inv = await db.get( + `SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`, + [req.params.id, req.user.id] + ); + if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' }); + + if (inv.status === 'pending') { + // mark as declined + await db.run(`UPDATE calendar_invitations SET status = 'declined' WHERE id = ?`, [inv.id]); + } else { + // accepted or declined — permanently remove from inbox + await db.run('DELETE FROM calendar_invitations WHERE id = ?', [inv.id]); + } + + res.json({ success: true }); + } catch (err) { + log.federation.error('Delete calendar invitation error:', err); + res.status(500).json({ error: 'Failed to remove calendar invitation' }); + } +}); + // ── GET /api/federation/federated-rooms — List saved federated rooms ──────── router.get('/federated-rooms', authenticateToken, async (req, res) => { try { diff --git a/src/i18n/de.json b/src/i18n/de.json index 2b7f21c..b4c2871 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -408,7 +408,11 @@ "roomDetails": "Raumdetails", "joinUrl": "Beitritts-URL", "roomDeleted": "Gelöscht", - "roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar." + "roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar.", + "calendarEvent": "Kalendereinladung", + "calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!", + "invitationRemoved": "Einladung entfernt", + "removeInvitation": "Einladung entfernen" }, "calendar": { "title": "Kalender", @@ -456,9 +460,9 @@ "shareFailed": "Event konnte nicht geteilt werden", "sendFederated": "An Remote senden", "sendFederatedTitle": "Event an Remote-Instanz senden", - "sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz.", + "sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz. Der Empfänger muss die Einladung zuerst annehmen, bevor das Event in seinem Kalender erscheint.", "send": "Senden", - "fedSent": "Event an Remote-Instanz gesendet!", + "fedSent": "Kalendereinladung gesendet! Der Empfänger muss diese zuerst annehmen.", "fedFailed": "Event konnte nicht an Remote-Instanz gesendet werden", "openRoom": "Verknüpften Raum öffnen", "organizer": "Organisator", diff --git a/src/i18n/en.json b/src/i18n/en.json index 84231a0..21017d1 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -408,7 +408,11 @@ "roomDetails": "Room Details", "joinUrl": "Join URL", "roomDeleted": "Deleted", - "roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available." + "roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available.", + "calendarEvent": "Calendar Invitation", + "calendarAccepted": "Calendar event accepted and added to your calendar!", + "invitationRemoved": "Invitation removed", + "removeInvitation": "Remove invitation" }, "calendar": { "title": "Calendar", @@ -456,9 +460,9 @@ "shareFailed": "Could not share event", "sendFederated": "Send to remote", "sendFederatedTitle": "Send Event to Remote Instance", - "sendFederatedDesc": "Send this calendar event to a user on another Redlight instance.", + "sendFederatedDesc": "Send this calendar event to a user on another Redlight instance. The recipient must accept the invitation before the event appears in their calendar.", "send": "Send", - "fedSent": "Event sent to remote instance!", + "fedSent": "Calendar invitation sent! The recipient must accept it first.", "fedFailed": "Could not send event to remote instance", "openRoom": "Open linked room", "organizer": "Organizer", diff --git a/src/pages/FederationInbox.jsx b/src/pages/FederationInbox.jsx index 92f0cb6..2c15ba0 100644 --- a/src/pages/FederationInbox.jsx +++ b/src/pages/FederationInbox.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox } from 'lucide-react'; +import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox, Calendar, Trash2 } from 'lucide-react'; import api from '../services/api'; import { useLanguage } from '../contexts/LanguageContext'; import toast from 'react-hot-toast'; @@ -7,12 +7,17 @@ import toast from 'react-hot-toast'; export default function FederationInbox() { const { t } = useLanguage(); const [invitations, setInvitations] = useState([]); + const [calendarInvitations, setCalendarInvitations] = useState([]); const [loading, setLoading] = useState(true); const fetchInvitations = async () => { try { - const res = await api.get('/federation/invitations'); - setInvitations(res.data.invitations || []); + const [roomRes, calRes] = await Promise.all([ + api.get('/federation/invitations'), + api.get('/federation/calendar-invitations').catch(() => ({ data: { invitations: [] } })), + ]); + setInvitations(roomRes.data.invitations || []); + setCalendarInvitations(calRes.data.invitations || []); } catch { toast.error(t('federation.loadFailed')); } finally { @@ -24,6 +29,7 @@ export default function FederationInbox() { fetchInvitations(); }, []); + // ── Room invitation actions ────────────────────────────────────────────── const handleAccept = async (id) => { try { await api.post(`/federation/invitations/${id}/accept`); @@ -44,6 +50,47 @@ export default function FederationInbox() { } }; + const handleDeleteInvitation = async (id) => { + try { + await api.delete(`/federation/invitations/${id}`); + toast.success(t('federation.invitationRemoved')); + fetchInvitations(); + } catch { + toast.error(t('federation.declineFailed')); + } + }; + + // ── Calendar invitation actions ────────────────────────────────────────── + const handleCalAccept = async (id) => { + try { + await api.post(`/federation/calendar-invitations/${id}/accept`); + toast.success(t('federation.calendarAccepted')); + fetchInvitations(); + } catch { + toast.error(t('federation.acceptFailed')); + } + }; + + const handleCalDecline = async (id) => { + try { + await api.delete(`/federation/calendar-invitations/${id}`); + toast.success(t('federation.declined')); + fetchInvitations(); + } catch { + toast.error(t('federation.declineFailed')); + } + }; + + const handleCalDelete = async (id) => { + try { + await api.delete(`/federation/calendar-invitations/${id}`); + toast.success(t('federation.invitationRemoved')); + fetchInvitations(); + } catch { + toast.error(t('federation.declineFailed')); + } + }; + if (loading) { return (
@@ -52,8 +99,13 @@ export default function FederationInbox() { ); } - const pending = invitations.filter(i => i.status === 'pending'); - const past = invitations.filter(i => i.status !== 'pending'); + const pendingRooms = invitations.filter(i => i.status === 'pending'); + const pastRooms = invitations.filter(i => i.status !== 'pending'); + const pendingCal = calendarInvitations.filter(i => i.status === 'pending'); + const pastCal = calendarInvitations.filter(i => i.status !== 'pending'); + + const totalPending = pendingRooms.length + pendingCal.length; + const totalPast = pastRooms.length + pastCal.length; return (
@@ -67,14 +119,15 @@ export default function FederationInbox() {
{/* Pending invitations */} - {pending.length > 0 && ( + {totalPending > 0 && (

- {t('federation.pending')} ({pending.length}) + {t('federation.pending')} ({totalPending})

- {pending.map(inv => ( -
+ {/* Pending room invitations */} + {pendingRooms.map(inv => ( +
@@ -92,17 +145,50 @@ export default function FederationInbox() {

- - +
+
+
+ ))} + + {/* Pending calendar invitations */} + {pendingCal.map(inv => ( +
+
+
+
+ + + {t('federation.calendarEvent')} + +

{inv.title}

+
+

+ {t('federation.from')}: {inv.from_user} +

+

+ {new Date(inv.start_time).toLocaleString()} – {new Date(inv.end_time).toLocaleString()} +

+ {inv.description && ( +

"{inv.description}"

+ )} +

+ {new Date(inv.created_at).toLocaleString()} +

+
+
+ + @@ -115,24 +201,28 @@ export default function FederationInbox() { )} {/* Past invitations */} - {past.length > 0 && ( + {totalPast > 0 && (

{t('federation.previousInvites')}

- {past.map(inv => ( -
+ {/* Past room invitations */} + {pastRooms.map(inv => ( +
-
-

{inv.room_name}

-

{inv.from_user}

+
+ +
+

{inv.room_name}

+

{inv.from_user}

+
-
+
+ ? 'bg-th-success/15 text-th-success' + : 'bg-th-error/15 text-th-error' + }`}> {inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')} {inv.status === 'accepted' && ( @@ -144,6 +234,52 @@ export default function FederationInbox() { )} + +
+
+
+ ))} + + {/* Past calendar invitations */} + {pastCal.map(inv => ( +
+
+
+ +
+

{inv.title}

+

{inv.from_user} · {new Date(inv.start_time).toLocaleDateString()}

+
+
+
+ + {inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')} + + {inv.status === 'accepted' && inv.join_url && ( + + )} +
@@ -153,7 +289,7 @@ export default function FederationInbox() { )} {/* Empty state */} - {invitations.length === 0 && ( + {totalPending === 0 && totalPast === 0 && (

{t('federation.noInvitations')}

@@ -163,3 +299,4 @@ export default function FederationInbox() {
); } +