From c5a6a1573137190e3971024a02f6ff9820e8a041 Mon Sep 17 00:00:00 2001 From: Michelle Date: Fri, 27 Feb 2026 12:53:20 +0100 Subject: [PATCH] feat: implement federation for inter-instance meeting invitations with dedicated API, UI, and configuration. --- .env.example | 6 + server/config/database.js | 30 ++++ server/config/federation.js | 158 ++++++++++++++++++++ server/index.js | 3 + server/routes/federation.js | 262 ++++++++++++++++++++++++++++++++++ src/App.jsx | 2 + src/components/Sidebar.jsx | 34 ++++- src/i18n/de.json | 35 ++++- src/i18n/en.json | 35 ++++- src/pages/FederationInbox.jsx | 168 ++++++++++++++++++++++ src/pages/RoomDetail.jsx | 97 +++++++++++-- 11 files changed, 812 insertions(+), 18 deletions(-) create mode 100644 server/config/federation.js create mode 100644 server/routes/federation.js create mode 100644 src/pages/FederationInbox.jsx diff --git a/.env.example b/.env.example index 711a195..c37735c 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,9 @@ SMTP_FROM=noreply@example.com # App URL (used for verification links, auto-detected if not set) # APP_URL=https://your-domain.com + +# Federation (inter-instance meeting invitations) +# Set both values to enable federation between Redlight instances +# FEDERATION_DOMAIN=redlight.example.com +# RSA Private Key for signing outbound invitations (automatically generated if missing on startup) +# FEDERATION_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgk...\n-----END PRIVATE KEY-----" diff --git a/server/config/database.js b/server/config/database.js index 379427e..a326b8f 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -179,6 +179,20 @@ export async function initDatabase() { value TEXT, updated_at TIMESTAMP DEFAULT NOW() ); + + CREATE TABLE IF NOT EXISTS federation_invitations ( + id SERIAL PRIMARY KEY, + invite_id TEXT UNIQUE NOT NULL, + from_user TEXT NOT NULL, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + room_name TEXT NOT NULL, + message TEXT, + join_url TEXT NOT NULL, + status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')), + created_at TIMESTAMP DEFAULT NOW() + ); + 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); `); } else { await db.exec(` @@ -240,6 +254,22 @@ export async function initDatabase() { value TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + + CREATE TABLE IF NOT EXISTS federation_invitations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invite_id TEXT UNIQUE NOT NULL, + from_user TEXT NOT NULL, + to_user_id INTEGER NOT NULL, + room_name TEXT NOT NULL, + message TEXT, + join_url TEXT NOT NULL, + 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 + ); + + 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); `); } diff --git a/server/config/federation.js b/server/config/federation.js new file mode 100644 index 0000000..b6b6cdd --- /dev/null +++ b/server/config/federation.js @@ -0,0 +1,158 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const FEDERATION_DOMAIN = process.env.FEDERATION_DOMAIN || ''; +let privateKeyPem = process.env.FEDERATION_PRIVATE_KEY || ''; +let publicKeyPem = ''; + +// Load or generate RSA keys +if (FEDERATION_DOMAIN) { + const keyPath = path.join(__dirname, 'federation_key.pem'); + + if (!privateKeyPem && fs.existsSync(keyPath)) { + privateKeyPem = fs.readFileSync(keyPath, 'utf8'); + } + + if (!privateKeyPem) { + console.log('Generating new RSA federation key pair...'); + const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + privateKeyPem = privateKey; + fs.writeFileSync(keyPath, privateKeyPem, 'utf8'); + console.log(`Saved new federation private key to ${keyPath}`); + } + + // Derive public key from the loaded private key + const currentPrivateKey = crypto.createPrivateKey(privateKeyPem); + publicKeyPem = crypto.createPublicKey(currentPrivateKey).export({ type: 'spki', format: 'pem' }); +} + +// Instance discovery cache (domain → { baseUrl, publicKey }) +const discoveryCache = new Map(); + +/** + * Get this instance's federation domain. + */ +export function getFederationDomain() { + return FEDERATION_DOMAIN; +} + +/** + * Get this instance's RSA public key (PEM format). + */ +export function getPublicKey() { + return publicKeyPem; +} + +/** + * Check if federation is configured on this instance. + */ +export function isFederationEnabled() { + return !!(FEDERATION_DOMAIN && privateKeyPem); +} + +/** + * RSA sign a JSON payload. + * @param {object} payload + * @returns {string} base64 signature + */ +export function signPayload(payload) { + if (!privateKeyPem) throw new Error("Federation private key not available"); + const data = Buffer.from(JSON.stringify(payload)); + const sign = crypto.createSign('SHA256'); + sign.update(data); + sign.end(); + return sign.sign(privateKeyPem, 'base64'); +} + +/** + * Verify an RSA signature against a JSON payload using a remote public key. + * @param {object} payload + * @param {string} signature base64 signature + * @param {string} remotePublicKeyPem + * @returns {boolean} + */ +export function verifyPayload(payload, signature, remotePublicKeyPem) { + if (!remotePublicKeyPem || !signature) return false; + try { + const data = Buffer.from(JSON.stringify(payload)); + const verify = crypto.createVerify('SHA256'); + verify.update(data); + verify.end(); + return verify.verify(remotePublicKeyPem, signature, 'base64'); + } catch (e) { + console.error('Signature verification error:', e.message); + return false; + } +} + +/** + * Discover a remote Redlight instance's federation API base URL. + * Fetches https://{domain}/.well-known/redlight and caches the result. + * @param {string} domain + * @returns {Promise<{ baseUrl: string, publicKey: string }>} + */ +export async function discoverInstance(domain) { + if (discoveryCache.has(domain)) { + return discoveryCache.get(domain); + } + + const wellKnownUrl = `https://${domain}/.well-known/redlight`; + try { + // Since we test locally, allow http fallback if the request fails (optional but good for testing) + let response; + try { + response = await fetch(wellKnownUrl); + } catch (e) { + if (e.message.includes('fetch') && wellKnownUrl.startsWith('https://localhost')) { + response = await fetch(`http://${domain}/.well-known/redlight`); + } else throw e; + } + + if (!response.ok) { + throw new Error(`Discovery failed: HTTP ${response.status}`); + } + const data = await response.json(); + + if (!data.public_key) { + throw new Error(`Remote instance at ${domain} did not provide a public key`); + } + + const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`; + // Optionally handle local testing gracefully for baseUrl + const result = { + baseUrl: baseUrl.replace('https://localhost', 'http://localhost'), + publicKey: data.public_key + }; + + discoveryCache.set(domain, result); + return result; + } catch (error) { + console.error(`Federation discovery failed for ${domain}:`, error.message); + throw new Error(`Could not discover Redlight instance at ${domain}`); + } +} + +/** + * Parse a federated address like "username@domain.com". + * @param {string} address + * @returns {{ username: string, domain: string | null }} + */ +export function parseAddress(address) { + if (!address || !address.includes('@')) { + return { username: address, domain: null }; + } + const atIndex = address.lastIndexOf('@'); + return { + username: address.substring(0, atIndex), + domain: address.substring(atIndex + 1), + }; +} diff --git a/server/index.js b/server/index.js index 6eeb2f7..4f70ed0 100644 --- a/server/index.js +++ b/server/index.js @@ -10,6 +10,7 @@ import roomRoutes from './routes/rooms.js'; import recordingRoutes from './routes/recordings.js'; import adminRoutes from './routes/admin.js'; import brandingRoutes from './routes/branding.js'; +import federationRoutes, { wellKnownHandler } from './routes/federation.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -35,6 +36,8 @@ async function start() { app.use('/api/recordings', recordingRoutes); app.use('/api/admin', adminRoutes); app.use('/api/branding', brandingRoutes); + app.use('/api/federation', federationRoutes); + app.get('/.well-known/redlight', wellKnownHandler); // Serve static files in production if (process.env.NODE_ENV === 'production') { diff --git a/server/routes/federation.js b/server/routes/federation.js new file mode 100644 index 0000000..40fa1d3 --- /dev/null +++ b/server/routes/federation.js @@ -0,0 +1,262 @@ +import { Router } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { getDb } from '../config/database.js'; +import { authenticateToken } from '../middleware/auth.js'; +import { + getFederationDomain, + isFederationEnabled, + getPublicKey, + signPayload, + verifyPayload, + discoverInstance, + parseAddress, +} from '../config/federation.js'; + +const router = Router(); + +// ── Well-known discovery endpoint ─────────────────────────────────────────── +// Mounted at /.well-known/redlight in index.js +export function wellKnownHandler(req, res) { + const domain = getFederationDomain(); + if (!domain) { + return res.status(404).json({ error: 'Federation not configured' }); + } + res.json({ + domain, + federation_api: '/api/federation', + public_key: getPublicKey(), + software: 'Redlight', + version: '1.1.0', + }); +} + +// ── POST /api/federation/invite — Send invitation to remote user ──────────── +router.post('/invite', authenticateToken, async (req, res) => { + try { + if (!isFederationEnabled()) { + return res.status(400).json({ error: 'Federation is not configured on this instance' }); + } + + const { room_uid, to, message } = req.body; + if (!room_uid || !to) { + return res.status(400).json({ error: 'room_uid and to are required' }); + } + + const { username, domain } = parseAddress(to); + if (!domain) { + return res.status(400).json({ error: 'Remote address must be in format username@domain' }); + } + + // Don't allow inviting to own instance + if (domain === getFederationDomain()) { + return res.status(400).json({ error: 'Cannot send federation invite to your own instance. Use local sharing instead.' }); + } + + const db = getDb(); + + // Verify room exists and user has access + const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [room_uid]); + if (!room) { + return res.status(404).json({ error: 'Room not found' }); + } + + const isOwner = room.user_id === req.user.id; + if (!isOwner) { + const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); + if (!share) { + return res.status(403).json({ error: 'No permission to invite from this room' }); + } + } + + // Build guest join URL for the remote user + const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const joinUrl = `${baseUrl}/join/${room.uid}`; + + // Build invitation payload + const inviteId = uuidv4(); + const payload = { + invite_id: inviteId, + from_user: `${req.user.name}@${getFederationDomain()}`, + to_user: to, + room_name: room.name, + message: message || null, + join_url: joinUrl, + timestamp: new Date().toISOString(), + }; + + // Sign and send to remote instance + const signature = signPayload(payload); + const { baseUrl: remoteApi } = await discoverInstance(domain); + + const response = await fetch(`${remoteApi}/receive`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Federation-Signature': signature, + 'X-Federation-Origin': getFederationDomain(), + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || `Remote server responded with ${response.status}`); + } + + res.json({ success: true, invite_id: inviteId }); + } catch (err) { + console.error('Federation invite error:', err); + res.status(500).json({ error: err.message || 'Failed to send federation invite' }); + } +}); + +// ── POST /api/federation/receive — Accept incoming invitation from remote ─── +router.post('/receive', async (req, res) => { + try { + if (!isFederationEnabled()) { + return res.status(400).json({ error: 'Federation is not configured on this instance' }); + } + + const signature = req.headers['x-federation-signature']; + const payload = req.body; + + if (!signature) { + return res.status(401).json({ error: 'Missing federation signature' }); + } + + if (!invite_id || !from_user || !to_user || !room_name || !join_url) { + return res.status(400).json({ error: 'Incomplete invitation payload' }); + } + + // Fetch the sender's public key dynamically + const { domain: senderDomain } = parseAddress(from_user); + if (!senderDomain) { + return res.status(400).json({ error: 'Sender address must include a domain' }); + } + + const { publicKey } = await discoverInstance(senderDomain); + if (!publicKey) { + return res.status(400).json({ error: 'Sender instance did not provide a public key' }); + } + + if (!verifyPayload(payload, signature, publicKey)) { + return res.status(403).json({ error: 'Invalid federation RSA signature' }); + } + + // Parse the target address and find local user + const { username } = parseAddress(to_user); + const db = getDb(); + + // Look up user by name (case-insensitive) + const targetUser = await db.get( + 'SELECT id FROM users WHERE LOWER(name) = LOWER(?)', + [username] + ); + + if (!targetUser) { + return res.status(404).json({ error: 'User not found on this instance' }); + } + + // Check for duplicate + const existing = await db.get( + 'SELECT id FROM federation_invitations WHERE invite_id = ?', + [invite_id] + ); + if (existing) { + return res.json({ success: true, message: 'Invitation already received' }); + } + + // Store the invitation + await db.run( + `INSERT INTO federation_invitations (invite_id, from_user, to_user_id, room_name, message, join_url) + VALUES (?, ?, ?, ?, ?, ?)`, + [invite_id, from_user, targetUser.id, room_name, message || null, join_url] + ); + + res.json({ success: true }); + } catch (err) { + console.error('Federation receive error:', err); + res.status(500).json({ error: 'Failed to process federation invitation' }); + } +}); + +// ── GET /api/federation/invitations — List invitations for current user ───── +router.get('/invitations', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const invitations = await db.all( + `SELECT * FROM federation_invitations + WHERE to_user_id = ? + ORDER BY created_at DESC`, + [req.user.id] + ); + res.json({ invitations }); + } catch (err) { + console.error('List federation invitations error:', err); + res.status(500).json({ error: 'Failed to load invitations' }); + } +}); + +// ── GET /api/federation/invitations/pending-count — Badge count ───────────── +router.get('/invitations/pending-count', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const result = 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 }); + } catch (err) { + res.json({ count: 0 }); + } +}); + +// ── POST /api/federation/invitations/:id/accept — Accept an invitation ────── +router.post('/invitations/:id/accept', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const invitation = await db.get( + 'SELECT * FROM federation_invitations WHERE id = ? AND to_user_id = ?', + [req.params.id, req.user.id] + ); + + if (!invitation) { + return res.status(404).json({ error: 'Invitation not found' }); + } + + await db.run( + "UPDATE federation_invitations SET status = 'accepted' WHERE id = ?", + [invitation.id] + ); + + res.json({ success: true, join_url: invitation.join_url }); + } catch (err) { + console.error('Accept invitation error:', err); + res.status(500).json({ error: 'Failed to accept invitation' }); + } +}); + +// ── DELETE /api/federation/invitations/:id — Decline/dismiss invitation ───── +router.delete('/invitations/:id', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const invitation = await db.get( + 'SELECT * FROM federation_invitations WHERE id = ? AND to_user_id = ?', + [req.params.id, req.user.id] + ); + + if (!invitation) { + return res.status(404).json({ error: 'Invitation not found' }); + } + + await db.run('DELETE FROM federation_invitations WHERE id = ?', [invitation.id]); + + res.json({ success: true }); + } catch (err) { + console.error('Decline invitation error:', err); + res.status(500).json({ error: 'Failed to decline invitation' }); + } +}); + +export default router; diff --git a/src/App.jsx b/src/App.jsx index 5d94aa9..92f7d15 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -14,6 +14,7 @@ import RoomDetail from './pages/RoomDetail'; import Settings from './pages/Settings'; import Admin from './pages/Admin'; import GuestJoin from './pages/GuestJoin'; +import FederationInbox from './pages/FederationInbox'; export default function App() { const { user, loading } = useAuth(); @@ -55,6 +56,7 @@ export default function App() { } /> } /> } /> + } /> {/* Catch all */} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 67b6712..9147544 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -1,18 +1,36 @@ import { NavLink } from 'react-router-dom'; -import { LayoutDashboard, Settings, Shield, X, Palette } from 'lucide-react'; +import { LayoutDashboard, Settings, Shield, X, Palette, Globe } from 'lucide-react'; import BrandLogo from './BrandLogo'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; import ThemeSelector from './ThemeSelector'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; +import api from '../services/api'; export default function Sidebar({ open, onClose }) { const { user } = useAuth(); const { t } = useLanguage(); const [themeOpen, setThemeOpen] = useState(false); + const [federationCount, setFederationCount] = useState(0); + + // Fetch pending federation invitation count + useEffect(() => { + const fetchCount = async () => { + try { + const res = await api.get('/federation/invitations/pending-count'); + setFederationCount(res.data.count || 0); + } catch { + // Ignore — federation may not be enabled + } + }; + fetchCount(); + const interval = setInterval(fetchCount, 30000); + return () => clearInterval(interval); + }, []); const navItems = [ { to: '/dashboard', icon: LayoutDashboard, label: t('nav.dashboard') }, + { to: '/federation/inbox', icon: Globe, label: t('nav.federation'), badge: federationCount }, { to: '/settings', icon: Settings, label: t('nav.settings') }, ]; @@ -21,10 +39,9 @@ export default function Sidebar({ open, onClose }) { } const linkClasses = ({ isActive }) => - `flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${ - isActive - ? 'bg-th-accent text-th-accent-t shadow-sm' - : 'text-th-text-s hover:text-th-text hover:bg-th-hover' + `flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive + ? 'bg-th-accent text-th-accent-t shadow-sm' + : 'text-th-text-s hover:text-th-text hover:bg-th-hover' }`; return ( @@ -60,6 +77,11 @@ export default function Sidebar({ open, onClose }) { > {item.label} + {item.badge > 0 && ( + + {item.badge} + + )} ))} diff --git a/src/i18n/de.json b/src/i18n/de.json index cff7f31..2fdb172 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -31,7 +31,8 @@ "admin": "Administration", "appearance": "Darstellung", "changeTheme": "Theme ändern", - "navigation": "Navigation" + "navigation": "Navigation", + "federation": "Einladungen" }, "auth": { "login": "Anmelden", @@ -302,5 +303,35 @@ "appNameLabel": "App-Name", "appNameUpdated": "App-Name aktualisiert", "appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden" + }, + "federation": { + "inbox": "Einladungen", + "inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen", + "inviteTitle": "Remote-Benutzer einladen", + "inviteSubtitle": "Einen Benutzer von einer anderen Redlight-Instanz zu diesem Meeting einladen.", + "addressLabel": "Benutzeradresse", + "addressPlaceholder": "benutzer@andere-instanz.de", + "addressHint": "Format: Benutzername@Domain der Redlight-Instanz", + "messageLabel": "Nachricht (optional)", + "messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!", + "send": "Einladung senden", + "sent": "Einladung gesendet!", + "sendFailed": "Einladung konnte nicht gesendet werden", + "from": "Von", + "accept": "Annehmen", + "decline": "Ablehnen", + "accepted": "Einladung angenommen", + "declined": "Einladung abgelehnt", + "acceptFailed": "Fehler beim Annehmen", + "declineFailed": "Fehler beim Ablehnen", + "pending": "Ausstehend", + "previousInvites": "Frühere Einladungen", + "noInvitations": "Keine Einladungen", + "noInvitationsSubtitle": "Wenn Sie von einer anderen Redlight-Instanz eingeladen werden, erscheint die Einladung hier.", + "statusAccepted": "Angenommen", + "statusDeclined": "Abgelehnt", + "openLink": "Meeting öffnen", + "loadFailed": "Einladungen konnten nicht geladen werden", + "inviteRemote": "Remote einladen" } -} +} \ No newline at end of file diff --git a/src/i18n/en.json b/src/i18n/en.json index 1ef84c4..b3e5c63 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -31,7 +31,8 @@ "admin": "Administration", "appearance": "Appearance", "changeTheme": "Change theme", - "navigation": "Navigation" + "navigation": "Navigation", + "federation": "Invitations" }, "auth": { "login": "Sign in", @@ -302,5 +303,35 @@ "appNameLabel": "App name", "appNameUpdated": "App name updated", "appNameUpdateFailed": "Could not update app name" + }, + "federation": { + "inbox": "Invitations", + "inboxSubtitle": "Meeting invitations from other Redlight instances", + "inviteTitle": "Invite Remote User", + "inviteSubtitle": "Invite a user from another Redlight instance to this meeting.", + "addressLabel": "User address", + "addressPlaceholder": "user@other-instance.com", + "addressHint": "Format: username@domain of the Redlight instance", + "messageLabel": "Message (optional)", + "messagePlaceholder": "Hi, I'd like to invite you to our meeting!", + "send": "Send invitation", + "sent": "Invitation sent!", + "sendFailed": "Could not send invitation", + "from": "From", + "accept": "Accept", + "decline": "Decline", + "accepted": "Invitation accepted", + "declined": "Invitation declined", + "acceptFailed": "Error accepting invitation", + "declineFailed": "Error declining invitation", + "pending": "Pending", + "previousInvites": "Previous Invitations", + "noInvitations": "No invitations", + "noInvitationsSubtitle": "When you receive an invitation from another Redlight instance, it will appear here.", + "statusAccepted": "Accepted", + "statusDeclined": "Declined", + "openLink": "Open meeting", + "loadFailed": "Could not load invitations", + "inviteRemote": "Invite remote" } -} +} \ No newline at end of file diff --git a/src/pages/FederationInbox.jsx b/src/pages/FederationInbox.jsx new file mode 100644 index 0000000..6db0352 --- /dev/null +++ b/src/pages/FederationInbox.jsx @@ -0,0 +1,168 @@ +import { useState, useEffect } from 'react'; +import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox } from 'lucide-react'; +import api from '../services/api'; +import { useLanguage } from '../contexts/LanguageContext'; +import toast from 'react-hot-toast'; + +export default function FederationInbox() { + const { t } = useLanguage(); + const [invitations, setInvitations] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchInvitations = async () => { + try { + const res = await api.get('/federation/invitations'); + setInvitations(res.data.invitations || []); + } catch { + toast.error(t('federation.loadFailed')); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchInvitations(); + }, []); + + 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')); + fetchInvitations(); + } catch { + toast.error(t('federation.acceptFailed')); + } + }; + + const handleDecline = async (id) => { + try { + await api.delete(`/federation/invitations/${id}`); + toast.success(t('federation.declined')); + fetchInvitations(); + } catch { + toast.error(t('federation.declineFailed')); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + const pending = invitations.filter(i => i.status === 'pending'); + const past = invitations.filter(i => i.status !== 'pending'); + + return ( +
+ {/* Header */} +
+
+ +

{t('federation.inbox')}

+
+

{t('federation.inboxSubtitle')}

+
+ + {/* Pending invitations */} + {pending.length > 0 && ( +
+

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

+
+ {pending.map(inv => ( +
+
+
+
+ +

{inv.room_name}

+
+

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

+ {inv.message && ( +

"{inv.message}"

+ )} +

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

+
+
+ + +
+
+
+ ))} +
+
+ )} + + {/* Past invitations */} + {past.length > 0 && ( +
+

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

+
+ {past.map(inv => ( +
+
+
+

{inv.room_name}

+

{inv.from_user}

+
+
+ + {inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')} + + {inv.status === 'accepted' && ( + + )} +
+
+
+ ))} +
+
+ )} + + {/* Empty state */} + {invitations.length === 0 && ( +
+ +

{t('federation.noInvitations')}

+

{t('federation.noInvitationsSubtitle')}

+
+ )} +
+ ); +} diff --git a/src/pages/RoomDetail.jsx b/src/pages/RoomDetail.jsx index 7bec7ce..58b17a5 100644 --- a/src/pages/RoomDetail.jsx +++ b/src/pages/RoomDetail.jsx @@ -3,8 +3,9 @@ import { useParams, useNavigate } from 'react-router-dom'; import { ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio, Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck, - Shield, Save, UserPlus, X, Share2, + Shield, Save, UserPlus, X, Share2, Globe, Send, } from 'lucide-react'; +import Modal from '../components/Modal'; import api from '../services/api'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; @@ -30,6 +31,12 @@ export default function RoomDetail() { const [shareResults, setShareResults] = useState([]); const [shareSearching, setShareSearching] = useState(false); + // Federation invite state + const [showFedInvite, setShowFedInvite] = useState(false); + const [fedAddress, setFedAddress] = useState(''); + const [fedMessage, setFedMessage] = useState(''); + const [fedSending, setFedSending] = useState(false); + const isOwner = room && user && room.user_id === user.id; const isShared = room && !!room.shared; const canManage = isOwner || isShared; @@ -151,6 +158,31 @@ export default function RoomDetail() { toast.success(t('room.linkCopied')); }; + // Federation invite handler + const handleFedInvite = async (e) => { + e.preventDefault(); + if (!fedAddress.includes('@')) { + toast.error(t('federation.addressHint')); + return; + } + setFedSending(true); + try { + await api.post('/federation/invite', { + room_uid: uid, + to: fedAddress, + message: fedMessage || undefined, + }); + toast.success(t('federation.sent')); + setShowFedInvite(false); + setFedAddress(''); + setFedMessage(''); + } catch (err) { + toast.error(err.response?.data?.error || t('federation.sendFailed')); + } finally { + setFedSending(false); + } + }; + // Share functions const searchUsers = async (query) => { setShareSearch(query); @@ -252,6 +284,16 @@ export default function RoomDetail() {
+ {canManage && ( + + )} {canManage && !status.running && (