feat: implement federation for inter-instance meeting invitations with dedicated API, UI, and configuration.
This commit is contained in:
@@ -33,3 +33,9 @@ SMTP_FROM=noreply@example.com
|
|||||||
|
|
||||||
# App URL (used for verification links, auto-detected if not set)
|
# App URL (used for verification links, auto-detected if not set)
|
||||||
# APP_URL=https://your-domain.com
|
# 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-----"
|
||||||
|
|||||||
@@ -179,6 +179,20 @@ export async function initDatabase() {
|
|||||||
value TEXT,
|
value TEXT,
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
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 {
|
} else {
|
||||||
await db.exec(`
|
await db.exec(`
|
||||||
@@ -240,6 +254,22 @@ export async function initDatabase() {
|
|||||||
value TEXT,
|
value TEXT,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
158
server/config/federation.js
Normal file
158
server/config/federation.js
Normal file
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import roomRoutes from './routes/rooms.js';
|
|||||||
import recordingRoutes from './routes/recordings.js';
|
import recordingRoutes from './routes/recordings.js';
|
||||||
import adminRoutes from './routes/admin.js';
|
import adminRoutes from './routes/admin.js';
|
||||||
import brandingRoutes from './routes/branding.js';
|
import brandingRoutes from './routes/branding.js';
|
||||||
|
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -35,6 +36,8 @@ async function start() {
|
|||||||
app.use('/api/recordings', recordingRoutes);
|
app.use('/api/recordings', recordingRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
app.use('/api/branding', brandingRoutes);
|
app.use('/api/branding', brandingRoutes);
|
||||||
|
app.use('/api/federation', federationRoutes);
|
||||||
|
app.get('/.well-known/redlight', wellKnownHandler);
|
||||||
|
|
||||||
// Serve static files in production
|
// Serve static files in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
|||||||
262
server/routes/federation.js
Normal file
262
server/routes/federation.js
Normal file
@@ -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;
|
||||||
@@ -14,6 +14,7 @@ import RoomDetail from './pages/RoomDetail';
|
|||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
import Admin from './pages/Admin';
|
import Admin from './pages/Admin';
|
||||||
import GuestJoin from './pages/GuestJoin';
|
import GuestJoin from './pages/GuestJoin';
|
||||||
|
import FederationInbox from './pages/FederationInbox';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -55,6 +56,7 @@ export default function App() {
|
|||||||
<Route path="/rooms/:uid" element={<RoomDetail />} />
|
<Route path="/rooms/:uid" element={<RoomDetail />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/admin" element={<Admin />} />
|
<Route path="/admin" element={<Admin />} />
|
||||||
|
<Route path="/federation/inbox" element={<FederationInbox />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Catch all */}
|
{/* Catch all */}
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
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 BrandLogo from './BrandLogo';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import ThemeSelector from './ThemeSelector';
|
import ThemeSelector from './ThemeSelector';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
export default function Sidebar({ open, onClose }) {
|
export default function Sidebar({ open, onClose }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [themeOpen, setThemeOpen] = useState(false);
|
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 = [
|
const navItems = [
|
||||||
{ to: '/dashboard', icon: LayoutDashboard, label: t('nav.dashboard') },
|
{ 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') },
|
{ to: '/settings', icon: Settings, label: t('nav.settings') },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -21,8 +39,7 @@ export default function Sidebar({ open, onClose }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const linkClasses = ({ isActive }) =>
|
const linkClasses = ({ isActive }) =>
|
||||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive
|
||||||
isActive
|
|
||||||
? 'bg-th-accent text-th-accent-t shadow-sm'
|
? 'bg-th-accent text-th-accent-t shadow-sm'
|
||||||
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
|
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
|
||||||
}`;
|
}`;
|
||||||
@@ -60,6 +77,11 @@ export default function Sidebar({ open, onClose }) {
|
|||||||
>
|
>
|
||||||
<item.icon size={18} />
|
<item.icon size={18} />
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{item.badge > 0 && (
|
||||||
|
<span className="ml-auto px-1.5 py-0.5 rounded-full bg-th-accent text-th-accent-t text-xs font-bold">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,8 @@
|
|||||||
"admin": "Administration",
|
"admin": "Administration",
|
||||||
"appearance": "Darstellung",
|
"appearance": "Darstellung",
|
||||||
"changeTheme": "Theme ändern",
|
"changeTheme": "Theme ändern",
|
||||||
"navigation": "Navigation"
|
"navigation": "Navigation",
|
||||||
|
"federation": "Einladungen"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
@@ -302,5 +303,35 @@
|
|||||||
"appNameLabel": "App-Name",
|
"appNameLabel": "App-Name",
|
||||||
"appNameUpdated": "App-Name aktualisiert",
|
"appNameUpdated": "App-Name aktualisiert",
|
||||||
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,8 @@
|
|||||||
"admin": "Administration",
|
"admin": "Administration",
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"changeTheme": "Change theme",
|
"changeTheme": "Change theme",
|
||||||
"navigation": "Navigation"
|
"navigation": "Navigation",
|
||||||
|
"federation": "Invitations"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Sign in",
|
"login": "Sign in",
|
||||||
@@ -302,5 +303,35 @@
|
|||||||
"appNameLabel": "App name",
|
"appNameLabel": "App name",
|
||||||
"appNameUpdated": "App name updated",
|
"appNameUpdated": "App name updated",
|
||||||
"appNameUpdateFailed": "Could not update app name"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
168
src/pages/FederationInbox.jsx
Normal file
168
src/pages/FederationInbox.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = invitations.filter(i => i.status === 'pending');
|
||||||
|
const past = invitations.filter(i => i.status !== 'pending');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<Globe size={24} className="text-th-accent" />
|
||||||
|
<h1 className="text-2xl font-bold text-th-text">{t('federation.inbox')}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-th-text-s mt-1">{t('federation.inboxSubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending invitations */}
|
||||||
|
{pending.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
|
||||||
|
{t('federation.pending')} ({pending.length})
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pending.map(inv => (
|
||||||
|
<div key={inv.id} className="card p-5 border-l-4 border-l-th-accent">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Mail size={16} className="text-th-accent flex-shrink-0" />
|
||||||
|
<h3 className="text-base font-semibold text-th-text truncate">{inv.room_name}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-th-text-s">
|
||||||
|
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_user}</span>
|
||||||
|
</p>
|
||||||
|
{inv.message && (
|
||||||
|
<p className="text-sm text-th-text-s mt-2 italic">"{inv.message}"</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-th-text-s mt-1">
|
||||||
|
{new Date(inv.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAccept(inv.id)}
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
>
|
||||||
|
<Check size={16} />
|
||||||
|
{t('federation.accept')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDecline(inv.id)}
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
{t('federation.decline')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Past invitations */}
|
||||||
|
{past.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
|
||||||
|
{t('federation.previousInvites')}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{past.map(inv => (
|
||||||
|
<div key={inv.id} className="card p-4 opacity-60">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
|
||||||
|
<p className="text-xs text-th-text-s">{inv.from_user}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
||||||
|
? 'bg-th-success/15 text-th-success'
|
||||||
|
: 'bg-th-error/15 text-th-error'
|
||||||
|
}`}>
|
||||||
|
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
|
||||||
|
</span>
|
||||||
|
{inv.status === 'accepted' && (
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(inv.join_url, '_blank')}
|
||||||
|
className="btn-ghost text-xs py-1.5 px-2"
|
||||||
|
title={t('federation.openLink')}
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{invitations.length === 0 && (
|
||||||
|
<div className="card p-12 text-center">
|
||||||
|
<Inbox size={48} className="mx-auto text-th-text-s/40 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-th-text mb-2">{t('federation.noInvitations')}</h3>
|
||||||
|
<p className="text-sm text-th-text-s">{t('federation.noInvitationsSubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
|
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
|
||||||
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
|
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
|
||||||
Shield, Save, UserPlus, X, Share2,
|
Shield, Save, UserPlus, X, Share2, Globe, Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -30,6 +31,12 @@ export default function RoomDetail() {
|
|||||||
const [shareResults, setShareResults] = useState([]);
|
const [shareResults, setShareResults] = useState([]);
|
||||||
const [shareSearching, setShareSearching] = useState(false);
|
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 isOwner = room && user && room.user_id === user.id;
|
||||||
const isShared = room && !!room.shared;
|
const isShared = room && !!room.shared;
|
||||||
const canManage = isOwner || isShared;
|
const canManage = isOwner || isShared;
|
||||||
@@ -151,6 +158,31 @@ export default function RoomDetail() {
|
|||||||
toast.success(t('room.linkCopied'));
|
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
|
// Share functions
|
||||||
const searchUsers = async (query) => {
|
const searchUsers = async (query) => {
|
||||||
setShareSearch(query);
|
setShareSearch(query);
|
||||||
@@ -252,6 +284,16 @@ export default function RoomDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFedInvite(true)}
|
||||||
|
className="btn-ghost text-sm"
|
||||||
|
title={t('federation.inviteRemote')}
|
||||||
|
>
|
||||||
|
<Globe size={16} />
|
||||||
|
<span className="hidden sm:inline">{t('federation.inviteRemote')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canManage && !status.running && (
|
{canManage && !status.running && (
|
||||||
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
||||||
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
||||||
@@ -280,8 +322,7 @@ export default function RoomDetail() {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${activeTab === tab.id
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-th-accent text-th-accent'
|
? 'border-th-accent text-th-accent'
|
||||||
: 'border-transparent text-th-text-s hover:text-th-text hover:border-th-border'
|
: 'border-transparent text-th-text-s hover:text-th-text hover:border-th-border'
|
||||||
}`}
|
}`}
|
||||||
@@ -589,6 +630,46 @@ export default function RoomDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Federation Invite Modal */}
|
||||||
|
{showFedInvite && (
|
||||||
|
<Modal title={t('federation.inviteTitle')} onClose={() => setShowFedInvite(false)}>
|
||||||
|
<p className="text-sm text-th-text-s mb-4">{t('federation.inviteSubtitle')}</p>
|
||||||
|
<form onSubmit={handleFedInvite} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={fedAddress}
|
||||||
|
onChange={e => setFedAddress(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
placeholder={t('federation.addressPlaceholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
|
||||||
|
<textarea
|
||||||
|
value={fedMessage}
|
||||||
|
onChange={e => setFedMessage(e.target.value)}
|
||||||
|
className="input-field resize-none"
|
||||||
|
rows={2}
|
||||||
|
placeholder={t('federation.messagePlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pt-2 border-t border-th-border">
|
||||||
|
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
|
||||||
|
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||||
|
{t('federation.send')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user