feat: implement federation for inter-instance meeting invitations with dedicated API, UI, and configuration.

This commit is contained in:
2026-02-27 12:53:20 +01:00
parent a35b708cbf
commit c5a6a15731
11 changed files with 812 additions and 18 deletions

View File

@@ -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>
);
}