New federation features
All checks were successful
Build & Push Docker Image / build (push) Successful in 5m58s
All checks were successful
Build & Push Docker Image / build (push) Successful in 5m58s
This commit is contained in:
@@ -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_to_user ON federation_invitations(to_user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_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 {
|
} else {
|
||||||
await db.exec(`
|
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_to_user ON federation_invitations(to_user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_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);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`,
|
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: `
|
||||||
|
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||||
|
<h2 style="color:#cba6f7;margin-top:0;">Hey ${name} 👋</h2>
|
||||||
|
<p>You have received a meeting invitation from <strong style="color:#cdd6f4;">${fromUser}</strong>.</p>
|
||||||
|
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
|
||||||
|
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">Room:</p>
|
||||||
|
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${roomName}</p>
|
||||||
|
${message ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${message}"</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<p style="text-align:center;margin:28px 0;">
|
||||||
|
<a href="${inboxUrl}"
|
||||||
|
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||||
|
View Invitation
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||||
|
<p style="font-size:12px;color:#585b70;">Open the link above to accept or decline the invitation.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
text: `Hey ${name},\n\nYou have received a meeting invitation from ${fromUser}.\nRoom: ${roomName}${message ? `\nMessage: "${message}"` : ''}\n\nView invitation: ${inboxUrl}\n\n– ${appName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import { authenticateToken } from '../middleware/auth.js';
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
|
import { sendFederationInviteEmail } from '../config/mailer.js';
|
||||||
import {
|
import {
|
||||||
getFederationDomain,
|
getFederationDomain,
|
||||||
isFederationEnabled,
|
isFederationEnabled,
|
||||||
@@ -152,7 +153,7 @@ router.post('/receive', async (req, res) => {
|
|||||||
|
|
||||||
// Look up user by name (case-insensitive)
|
// Look up user by name (case-insensitive)
|
||||||
const targetUser = await db.get(
|
const targetUser = await db.get(
|
||||||
'SELECT id FROM users WHERE LOWER(name) = LOWER(?)',
|
'SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)',
|
||||||
[username]
|
[username]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -176,6 +177,19 @@ router.post('/receive', async (req, res) => {
|
|||||||
[invite_id, from_user, targetUser.id, room_name, message || null, join_url]
|
[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 });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Federation receive error:', err);
|
console.error('Federation receive error:', err);
|
||||||
@@ -233,6 +247,19 @@ router.post('/invitations/:id/accept', authenticateToken, async (req, res) => {
|
|||||||
[invitation.id]
|
[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 });
|
res.json({ success: true, join_url: invitation.join_url });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Accept invitation error:', 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;
|
export default router;
|
||||||
|
|||||||
66
src/components/FederatedRoomCard.jsx
Normal file
66
src/components/FederatedRoomCard.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="card-hover group p-5">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe size={14} className="text-th-accent flex-shrink-0" />
|
||||||
|
<h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors">
|
||||||
|
{room.room_name}
|
||||||
|
</h3>
|
||||||
|
<span className="flex-shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
|
||||||
|
{t('federation.federated')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-th-text-s mt-0.5 truncate">
|
||||||
|
{t('federation.from')}: <span className="font-medium">{room.from_user}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Read-only notice */}
|
||||||
|
<p className="text-xs text-th-text-s mb-4 italic">{t('federation.readOnlyNotice')}</p>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 pt-3 border-t border-th-border">
|
||||||
|
<button
|
||||||
|
onClick={handleJoin}
|
||||||
|
className="btn-primary text-xs py-1.5 px-3 flex-1"
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
{t('federation.joinMeeting')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
|
||||||
|
title={t('federation.removeRoom')}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -123,7 +123,9 @@
|
|||||||
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
|
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
|
||||||
"roomDeleteConfirm": "Raum \"{name}\" wirklich löschen?",
|
"roomDeleteConfirm": "Raum \"{name}\" wirklich löschen?",
|
||||||
"loadFailed": "Räume konnten nicht geladen werden",
|
"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": {
|
"room": {
|
||||||
"backToDashboard": "Zurück zum Dashboard",
|
"backToDashboard": "Zurück zum Dashboard",
|
||||||
@@ -332,6 +334,14 @@
|
|||||||
"statusDeclined": "Abgelehnt",
|
"statusDeclined": "Abgelehnt",
|
||||||
"openLink": "Meeting öffnen",
|
"openLink": "Meeting öffnen",
|
||||||
"loadFailed": "Einladungen konnten nicht geladen werden",
|
"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!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,9 @@
|
|||||||
"roomDeleteFailed": "Room could not be deleted",
|
"roomDeleteFailed": "Room could not be deleted",
|
||||||
"roomDeleteConfirm": "Really delete room \"{name}\"?",
|
"roomDeleteConfirm": "Really delete room \"{name}\"?",
|
||||||
"loadFailed": "Rooms could not be loaded",
|
"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": {
|
"room": {
|
||||||
"backToDashboard": "Back to Dashboard",
|
"backToDashboard": "Back to Dashboard",
|
||||||
@@ -332,6 +334,14 @@
|
|||||||
"statusDeclined": "Declined",
|
"statusDeclined": "Declined",
|
||||||
"openLink": "Open meeting",
|
"openLink": "Open meeting",
|
||||||
"loadFailed": "Could not load invitations",
|
"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!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,14 @@ import { Plus, Video, Loader2, LayoutGrid, List } from 'lucide-react';
|
|||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import RoomCard from '../components/RoomCard';
|
import RoomCard from '../components/RoomCard';
|
||||||
|
import FederatedRoomCard from '../components/FederatedRoomCard';
|
||||||
import Modal from '../components/Modal';
|
import Modal from '../components/Modal';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [rooms, setRooms] = useState([]);
|
const [rooms, setRooms] = useState([]);
|
||||||
|
const [federatedRooms, setFederatedRooms] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState('grid');
|
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(() => {
|
useEffect(() => {
|
||||||
fetchRooms();
|
fetchRooms();
|
||||||
|
fetchFederatedRooms();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
const handleCreate = async (e) => {
|
||||||
@@ -160,6 +172,23 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Federated rooms (from other instances) */}
|
||||||
|
{federatedRooms.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-lg font-semibold text-th-text mb-1">{t('dashboard.federatedRooms')}</h2>
|
||||||
|
<p className="text-sm text-th-text-s mb-4">{t('dashboard.federatedRoomsSubtitle')}</p>
|
||||||
|
<div className={
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||||
|
: 'space-y-3'
|
||||||
|
}>
|
||||||
|
{federatedRooms.map(room => (
|
||||||
|
<FederatedRoomCard key={room.id} room={room} onRemove={fetchFederatedRooms} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,8 @@ export default function FederationInbox() {
|
|||||||
|
|
||||||
const handleAccept = async (id) => {
|
const handleAccept = async (id) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.post(`/federation/invitations/${id}/accept`);
|
await api.post(`/federation/invitations/${id}/accept`);
|
||||||
if (res.data.join_url) {
|
toast.success(t('federation.acceptedSaved'));
|
||||||
window.open(res.data.join_url, '_blank');
|
|
||||||
}
|
|
||||||
toast.success(t('federation.accepted'));
|
|
||||||
fetchInvitations();
|
fetchInvitations();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('federation.acceptFailed'));
|
toast.error(t('federation.acceptFailed'));
|
||||||
|
|||||||
Reference in New Issue
Block a user