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 */}
+
+
+
+ {t('federation.joinMeeting')}
+
+
+
+
+
+
+ );
+}
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'));