feat: implement federation for inter-instance meeting invitations with dedicated API, UI, and configuration.
This commit is contained in:
@@ -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() {
|
||||
<Route path="/rooms/:uid" element={<RoomDetail />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/federation/inbox" element={<FederationInbox />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch all */}
|
||||
|
||||
@@ -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.icon size={18} />
|
||||
{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>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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() {
|
||||
</div>
|
||||
|
||||
<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 && (
|
||||
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
||||
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
||||
@@ -280,11 +322,10 @@ export default function RoomDetail() {
|
||||
<button
|
||||
key={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 ${
|
||||
activeTab === tab.id
|
||||
? 'border-th-accent text-th-accent'
|
||||
: 'border-transparent text-th-text-s hover:text-th-text hover:border-th-border'
|
||||
}`}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${activeTab === tab.id
|
||||
? 'border-th-accent text-th-accent'
|
||||
: 'border-transparent text-th-text-s hover:text-th-text hover:border-th-border'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
{tab.label}
|
||||
@@ -393,7 +434,7 @@ export default function RoomDetail() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editRoom.max_participants}
|
||||
@@ -403,7 +444,7 @@ export default function RoomDetail() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editRoom.access_code || ''}
|
||||
@@ -589,6 +630,46 @@ export default function RoomDetail() {
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user