From e5b6c225e9107b75a8a99b13fb8930afa39064b6 Mon Sep 17 00:00:00 2001 From: Miichelle Date: Fri, 27 Feb 2026 15:24:18 +0100 Subject: [PATCH] New federation features --- server/config/database.js | 23 ++++++++++ server/config/mailer.js | 42 ++++++++++++++++++ server/routes/federation.js | 61 ++++++++++++++++++++++++- src/components/FederatedRoomCard.jsx | 66 ++++++++++++++++++++++++++++ src/i18n/de.json | 14 +++++- src/i18n/en.json | 14 +++++- src/pages/Dashboard.jsx | 29 ++++++++++++ src/pages/FederationInbox.jsx | 7 +-- 8 files changed, 246 insertions(+), 10 deletions(-) create mode 100644 src/components/FederatedRoomCard.jsx diff --git a/server/config/database.js b/server/config/database.js index a326b8f..777eba0 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -193,6 +193,17 @@ export async function initDatabase() { ); CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id); CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id); + + CREATE TABLE IF NOT EXISTS federated_rooms ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invite_id TEXT UNIQUE NOT NULL, + room_name TEXT NOT NULL, + from_user TEXT NOT NULL, + join_url TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_fed_rooms_user_id ON federated_rooms(user_id); `); } else { await db.exec(` @@ -270,6 +281,18 @@ export async function initDatabase() { CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id); CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id); + + CREATE TABLE IF NOT EXISTS federated_rooms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + invite_id TEXT UNIQUE NOT NULL, + room_name TEXT NOT NULL, + from_user TEXT NOT NULL, + join_url TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_fed_rooms_user_id ON federated_rooms(user_id); `); } diff --git a/server/config/mailer.js b/server/config/mailer.js index 02bb9c6..7cba35e 100644 --- a/server/config/mailer.js +++ b/server/config/mailer.js @@ -68,3 +68,45 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl text: `Hey ${name},\n\nPlease verify your email: ${verifyUrl}\n\nThis link is valid for 24 hours.\n\n– ${appName}`, }); } + +/** + * Send a federation meeting invitation email. + * @param {string} to – recipient email + * @param {string} name – recipient display name + * @param {string} fromUser – sender federated address (user@domain) + * @param {string} roomName – name of the invited room + * @param {string} message – optional personal message + * @param {string} inboxUrl – URL to the federation inbox + * @param {string} appName – branding app name (default "Redlight") + */ +export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight') { + if (!transporter) return; // silently skip if SMTP not configured + + const from = process.env.SMTP_FROM || process.env.SMTP_USER; + + await transporter.sendMail({ + from: `"${appName}" <${from}>`, + to, + subject: `${appName} – Meeting invitation from ${fromUser}`, + html: ` +
+

Hey ${name} 👋

+

You have received a meeting invitation from ${fromUser}.

+
+

Room:

+

${roomName}

+ ${message ? `

"${message}"

` : ''} +
+

+ + View Invitation + +

+
+

Open the link above to accept or decline the invitation.

+
+ `, + text: `Hey ${name},\n\nYou have received a meeting invitation from ${fromUser}.\nRoom: ${roomName}${message ? `\nMessage: "${message}"` : ''}\n\nView invitation: ${inboxUrl}\n\n– ${appName}`, + }); +} diff --git a/server/routes/federation.js b/server/routes/federation.js index 45cd975..13f2fec 100644 --- a/server/routes/federation.js +++ b/server/routes/federation.js @@ -2,6 +2,7 @@ import { Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; +import { sendFederationInviteEmail } from '../config/mailer.js'; import { getFederationDomain, isFederationEnabled, @@ -152,7 +153,7 @@ router.post('/receive', async (req, res) => { // Look up user by name (case-insensitive) const targetUser = await db.get( - 'SELECT id FROM users WHERE LOWER(name) = LOWER(?)', + 'SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)', [username] ); @@ -176,6 +177,19 @@ router.post('/receive', async (req, res) => { [invite_id, from_user, targetUser.id, room_name, message || null, join_url] ); + // Send notification email (fire-and-forget, don't fail the request if mail fails) + try { + const appUrl = process.env.APP_URL || ''; + const inboxUrl = `${appUrl}/federation/inbox`; + const appName = process.env.APP_NAME || 'Redlight'; + await sendFederationInviteEmail( + targetUser.email, targetUser.name, from_user, + room_name, message || null, inboxUrl, appName + ); + } catch (mailErr) { + console.warn('Federation invite mail failed (non-fatal):', mailErr.message); + } + res.json({ success: true }); } catch (err) { console.error('Federation receive error:', err); @@ -233,6 +247,19 @@ router.post('/invitations/:id/accept', authenticateToken, async (req, res) => { [invitation.id] ); + // Upsert into federated_rooms so the room appears in the user's dashboard + const existing = await db.get( + 'SELECT id FROM federated_rooms WHERE invite_id = ? AND user_id = ?', + [invitation.invite_id, req.user.id] + ); + if (!existing) { + await db.run( + `INSERT INTO federated_rooms (user_id, invite_id, room_name, from_user, join_url) + VALUES (?, ?, ?, ?, ?)`, + [req.user.id, invitation.invite_id, invitation.room_name, invitation.from_user, invitation.join_url] + ); + } + res.json({ success: true, join_url: invitation.join_url }); } catch (err) { console.error('Accept invitation error:', err); @@ -262,4 +289,36 @@ router.delete('/invitations/:id', authenticateToken, async (req, res) => { } }); +// ── GET /api/federation/federated-rooms — List saved federated rooms ──────── +router.get('/federated-rooms', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const rooms = await db.all( + `SELECT * FROM federated_rooms WHERE user_id = ? ORDER BY created_at DESC`, + [req.user.id] + ); + res.json({ rooms }); + } catch (err) { + console.error('List federated rooms error:', err); + res.status(500).json({ error: 'Failed to load federated rooms' }); + } +}); + +// ── DELETE /api/federation/federated-rooms/:id — Remove a federated room ──── +router.delete('/federated-rooms/:id', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const room = await db.get( + 'SELECT * FROM federated_rooms WHERE id = ? AND user_id = ?', + [req.params.id, req.user.id] + ); + if (!room) return res.status(404).json({ error: 'Room not found' }); + await db.run('DELETE FROM federated_rooms WHERE id = ?', [room.id]); + res.json({ success: true }); + } catch (err) { + console.error('Delete federated room error:', err); + res.status(500).json({ error: 'Failed to remove room' }); + } +}); + export default router; diff --git a/src/components/FederatedRoomCard.jsx b/src/components/FederatedRoomCard.jsx new file mode 100644 index 0000000..f94a814 --- /dev/null +++ b/src/components/FederatedRoomCard.jsx @@ -0,0 +1,66 @@ +import { Globe, Play, Trash2, ExternalLink } from 'lucide-react'; +import { useLanguage } from '../contexts/LanguageContext'; +import api from '../services/api'; +import toast from 'react-hot-toast'; + +export default function FederatedRoomCard({ room, onRemove }) { + const { t } = useLanguage(); + + const handleJoin = () => { + window.open(room.join_url, '_blank'); + }; + + const handleRemove = async (e) => { + e.stopPropagation(); + if (!confirm(t('federation.removeRoomConfirm'))) return; + try { + await api.delete(`/federation/federated-rooms/${room.id}`); + toast.success(t('federation.roomRemoved')); + onRemove?.(); + } catch { + toast.error(t('federation.roomRemoveFailed')); + } + }; + + return ( +
+
+
+
+ +

+ {room.room_name} +

+ + {t('federation.federated')} + +
+

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

+
+
+ + {/* Read-only notice */} +

{t('federation.readOnlyNotice')}

+ + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/src/i18n/de.json b/src/i18n/de.json index 2fdb172..5160bd9 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -123,7 +123,9 @@ "roomDeleteFailed": "Raum konnte nicht gelöscht werden", "roomDeleteConfirm": "Raum \"{name}\" wirklich löschen?", "loadFailed": "Räume konnten nicht geladen werden", - "sharedWithMe": "Mit mir geteilt" + "sharedWithMe": "Mit mir geteilt", + "federatedRooms": "Räume von anderen Instanzen", + "federatedRoomsSubtitle": "Angenommene Meeting-Einladungen von anderen Redlight-Instanzen. Einstellungen können hier nicht geändert werden." }, "room": { "backToDashboard": "Zurück zum Dashboard", @@ -332,6 +334,14 @@ "statusDeclined": "Abgelehnt", "openLink": "Meeting öffnen", "loadFailed": "Einladungen konnten nicht geladen werden", - "inviteRemote": "Remote einladen" + "inviteRemote": "Remote einladen", + "federated": "Fremd-Instanz", + "readOnlyNotice": "Dieser Raum gehört einer anderen Instanz. Einstellungen können nicht geändert werden.", + "joinMeeting": "Meeting beitreten", + "removeRoom": "Raum entfernen", + "removeRoomConfirm": "Raum wirklich entfernen?", + "roomRemoved": "Raum entfernt", + "roomRemoveFailed": "Raum konnte nicht entfernt werden", + "acceptedSaved": "Einladung angenommen – Raum wurde in deinem Dashboard gespeichert!" } } \ No newline at end of file diff --git a/src/i18n/en.json b/src/i18n/en.json index b3e5c63..9a7e773 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -123,7 +123,9 @@ "roomDeleteFailed": "Room could not be deleted", "roomDeleteConfirm": "Really delete room \"{name}\"?", "loadFailed": "Rooms could not be loaded", - "sharedWithMe": "Shared with me" + "sharedWithMe": "Shared with me", + "federatedRooms": "Rooms from other instances", + "federatedRoomsSubtitle": "Accepted meeting invitations from other Redlight instances. Settings cannot be changed here." }, "room": { "backToDashboard": "Back to Dashboard", @@ -332,6 +334,14 @@ "statusDeclined": "Declined", "openLink": "Open meeting", "loadFailed": "Could not load invitations", - "inviteRemote": "Invite remote" + "inviteRemote": "Invite remote", + "federated": "Federated", + "readOnlyNotice": "This room belongs to another instance. Settings cannot be changed.", + "joinMeeting": "Join meeting", + "removeRoom": "Remove room", + "removeRoomConfirm": "Really remove this room?", + "roomRemoved": "Room removed", + "roomRemoveFailed": "Could not remove room", + "acceptedSaved": "Invitation accepted – room saved to your dashboard!" } } \ No newline at end of file diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 9430abf..6f57c78 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -3,12 +3,14 @@ import { Plus, Video, Loader2, LayoutGrid, List } from 'lucide-react'; import api from '../services/api'; import { useLanguage } from '../contexts/LanguageContext'; import RoomCard from '../components/RoomCard'; +import FederatedRoomCard from '../components/FederatedRoomCard'; import Modal from '../components/Modal'; import toast from 'react-hot-toast'; export default function Dashboard() { const { t } = useLanguage(); const [rooms, setRooms] = useState([]); + const [federatedRooms, setFederatedRooms] = useState([]); const [loading, setLoading] = useState(true); const [showCreate, setShowCreate] = useState(false); const [viewMode, setViewMode] = useState('grid'); @@ -33,8 +35,18 @@ export default function Dashboard() { } }; + const fetchFederatedRooms = async () => { + try { + const res = await api.get('/federation/federated-rooms'); + setFederatedRooms(res.data.rooms || []); + } catch { + // Federation may not be enabled + } + }; + useEffect(() => { fetchRooms(); + fetchFederatedRooms(); }, []); const handleCreate = async (e) => { @@ -160,6 +172,23 @@ export default function Dashboard() { )} + + {/* Federated rooms (from other instances) */} + {federatedRooms.length > 0 && ( +
+

{t('dashboard.federatedRooms')}

+

{t('dashboard.federatedRoomsSubtitle')}

+
+ {federatedRooms.map(room => ( + + ))} +
+
+ )} )} diff --git a/src/pages/FederationInbox.jsx b/src/pages/FederationInbox.jsx index 6db0352..92f0cb6 100644 --- a/src/pages/FederationInbox.jsx +++ b/src/pages/FederationInbox.jsx @@ -26,11 +26,8 @@ export default function FederationInbox() { const handleAccept = async (id) => { try { - const res = await api.post(`/federation/invitations/${id}/accept`); - if (res.data.join_url) { - window.open(res.data.join_url, '_blank'); - } - toast.success(t('federation.accepted')); + await api.post(`/federation/invitations/${id}/accept`); + toast.success(t('federation.acceptedSaved')); fetchInvitations(); } catch { toast.error(t('federation.acceptFailed'));