Add federated room detail page and improve address parsing in invites
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m18s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m18s
This commit is contained in:
@@ -149,12 +149,15 @@ export async function discoverInstance(domain) {
|
|||||||
* @returns {{ username: string, domain: string | null }}
|
* @returns {{ username: string, domain: string | null }}
|
||||||
*/
|
*/
|
||||||
export function parseAddress(address) {
|
export function parseAddress(address) {
|
||||||
if (!address || !address.includes('@')) {
|
if (!address) return { username: address, domain: null };
|
||||||
return { username: address, domain: null };
|
// Accept both @user@domain (Mastodon-style) and user@domain
|
||||||
|
const normalized = address.startsWith('@') ? address.slice(1) : address;
|
||||||
|
if (!normalized.includes('@')) {
|
||||||
|
return { username: normalized, domain: null };
|
||||||
}
|
}
|
||||||
const atIndex = address.lastIndexOf('@');
|
const atIndex = normalized.lastIndexOf('@');
|
||||||
return {
|
return {
|
||||||
username: address.substring(0, atIndex),
|
username: normalized.substring(0, atIndex),
|
||||||
domain: address.substring(atIndex + 1),
|
domain: normalized.substring(atIndex + 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ router.post('/invite', authenticateToken, async (req, res) => {
|
|||||||
const inviteId = uuidv4();
|
const inviteId = uuidv4();
|
||||||
const payload = {
|
const payload = {
|
||||||
invite_id: inviteId,
|
invite_id: inviteId,
|
||||||
from_user: `${req.user.name}@${getFederationDomain()}`,
|
from_user: `@${req.user.name}@${getFederationDomain()}`,
|
||||||
to_user: to,
|
to_user: to,
|
||||||
room_name: room.name,
|
room_name: room.name,
|
||||||
room_uid: room.uid,
|
room_uid: room.uid,
|
||||||
@@ -195,7 +195,7 @@ router.post('/receive', async (req, res) => {
|
|||||||
|
|
||||||
// Send notification email (truly fire-and-forget – never blocks the response)
|
// Send notification email (truly fire-and-forget – never blocks the response)
|
||||||
if (targetUser.email) {
|
if (targetUser.email) {
|
||||||
const appUrl = process.env.APP_URL || '';
|
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||||
const appName = process.env.APP_NAME || 'Redlight';
|
const appName = process.env.APP_NAME || 'Redlight';
|
||||||
sendFederationInviteEmail(
|
sendFederationInviteEmail(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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';
|
import FederationInbox from './pages/FederationInbox';
|
||||||
|
import FederatedRoomDetail from './pages/FederatedRoomDetail';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -57,6 +58,7 @@ export default function App() {
|
|||||||
<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 path="/federation/inbox" element={<FederationInbox />} />
|
||||||
|
<Route path="/federation/rooms/:id" element={<FederatedRoomDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Catch all */}
|
{/* Catch all */}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Globe, Trash2, ExternalLink, Hash, Users, Video, VideoOff } from 'lucide-react';
|
import { Globe, Trash2, ExternalLink, Hash, Users, Video, VideoOff } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function FederatedRoomCard({ room, onRemove }) {
|
export default function FederatedRoomCard({ room, onRemove }) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleJoin = () => {
|
const handleJoin = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
window.open(room.join_url, '_blank');
|
window.open(room.join_url, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,7 +28,7 @@ export default function FederatedRoomCard({ room, onRemove }) {
|
|||||||
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
|
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-hover group p-5">
|
<div className="card-hover group p-5 cursor-pointer" onClick={() => navigate(`/federation/rooms/${room.id}`)}>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export default function Navbar({ onMenuClick }) {
|
|||||||
<div className="absolute right-0 mt-2 w-56 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
|
<div className="absolute right-0 mt-2 w-56 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-th-border">
|
<div className="px-4 py-3 border-b border-th-border">
|
||||||
<p className="text-sm font-medium text-th-text">{user?.display_name || user?.name}</p>
|
<p className="text-sm font-medium text-th-text">{user?.display_name || user?.name}</p>
|
||||||
<p className="text-xs text-th-text-s">{user?.email}</p>
|
<p className="text-xs text-th-text-s">@{user?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export default function Sidebar({ open, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-th-text truncate">{user?.display_name || user?.name}</p>
|
<p className="text-sm font-medium text-th-text truncate">{user?.display_name || user?.name}</p>
|
||||||
<p className="text-xs text-th-text-s truncate">{user?.email}</p>
|
<p className="text-xs text-th-text-s truncate">@{user?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -341,8 +341,8 @@
|
|||||||
"inviteTitle": "Remote-Benutzer einladen",
|
"inviteTitle": "Remote-Benutzer einladen",
|
||||||
"inviteSubtitle": "Einen Benutzer von einer anderen Redlight-Instanz zu diesem Meeting einladen.",
|
"inviteSubtitle": "Einen Benutzer von einer anderen Redlight-Instanz zu diesem Meeting einladen.",
|
||||||
"addressLabel": "Benutzeradresse",
|
"addressLabel": "Benutzeradresse",
|
||||||
"addressPlaceholder": "benutzer@andere-instanz.de",
|
"addressPlaceholder": "@benutzer@andere-instanz.com",
|
||||||
"addressHint": "Format: Benutzername@Domain der Redlight-Instanz",
|
"addressHint": "Format: @Benutzername@Domain der Redlight-Instanz",
|
||||||
"messageLabel": "Nachricht (optional)",
|
"messageLabel": "Nachricht (optional)",
|
||||||
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
|
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
|
||||||
"send": "Einladung senden",
|
"send": "Einladung senden",
|
||||||
@@ -376,6 +376,13 @@
|
|||||||
"maxParticipants": "Max. Teilnehmer",
|
"maxParticipants": "Max. Teilnehmer",
|
||||||
"recordingOn": "Aufnahme aktiviert",
|
"recordingOn": "Aufnahme aktiviert",
|
||||||
"recordingOff": "Aufnahme deaktiviert",
|
"recordingOff": "Aufnahme deaktiviert",
|
||||||
"unlimited": "Unbegrenzt"
|
"unlimited": "Unbegrenzt",
|
||||||
|
"backToDashboard": "Zurück zum Dashboard",
|
||||||
|
"participantLimit": "Teilnehmerlimit gesetzt",
|
||||||
|
"recordingLabel": "Aufnahme",
|
||||||
|
"recordingOnHint": "Meetings in diesem Raum können aufgezeichnet werden",
|
||||||
|
"recordingOffHint": "Meetings in diesem Raum werden nicht aufgezeichnet",
|
||||||
|
"roomDetails": "Raumdetails",
|
||||||
|
"joinUrl": "Beitritts-URL"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,8 +341,8 @@
|
|||||||
"inviteTitle": "Invite Remote User",
|
"inviteTitle": "Invite Remote User",
|
||||||
"inviteSubtitle": "Invite a user from another Redlight instance to this meeting.",
|
"inviteSubtitle": "Invite a user from another Redlight instance to this meeting.",
|
||||||
"addressLabel": "User address",
|
"addressLabel": "User address",
|
||||||
"addressPlaceholder": "user@other-instance.com",
|
"addressPlaceholder": "@user@other-instance.com",
|
||||||
"addressHint": "Format: username@domain of the Redlight instance",
|
"addressHint": "Format: @username@domain of the Redlight instance",
|
||||||
"messageLabel": "Message (optional)",
|
"messageLabel": "Message (optional)",
|
||||||
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
|
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
|
||||||
"send": "Send invitation",
|
"send": "Send invitation",
|
||||||
@@ -376,6 +376,13 @@
|
|||||||
"maxParticipants": "Max. participants",
|
"maxParticipants": "Max. participants",
|
||||||
"recordingOn": "Recording enabled",
|
"recordingOn": "Recording enabled",
|
||||||
"recordingOff": "Recording disabled",
|
"recordingOff": "Recording disabled",
|
||||||
"unlimited": "Unlimited"
|
"unlimited": "Unlimited",
|
||||||
|
"backToDashboard": "Back to Dashboard",
|
||||||
|
"participantLimit": "Participant limit set",
|
||||||
|
"recordingLabel": "Recording",
|
||||||
|
"recordingOnHint": "Meetings in this room may be recorded",
|
||||||
|
"recordingOffHint": "Meetings in this room will not be recorded",
|
||||||
|
"roomDetails": "Room Details",
|
||||||
|
"joinUrl": "Join URL"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Room grid/list */}
|
{/* Room grid/list */}
|
||||||
{rooms.length === 0 ? (
|
{rooms.length === 0 && federatedRooms.length === 0 ? (
|
||||||
<div className="card p-12 text-center">
|
<div className="card p-12 text-center">
|
||||||
<Video size={48} className="mx-auto text-th-text-s/40 mb-4" />
|
<Video size={48} className="mx-auto text-th-text-s/40 mb-4" />
|
||||||
<h3 className="text-lg font-semibold text-th-text mb-2">{t('dashboard.noRooms')}</h3>
|
<h3 className="text-lg font-semibold text-th-text mb-2">{t('dashboard.noRooms')}</h3>
|
||||||
|
|||||||
183
src/pages/FederatedRoomDetail.jsx
Normal file
183
src/pages/FederatedRoomDetail.jsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ArrowLeft, Globe, ExternalLink, Trash2, Hash, Users,
|
||||||
|
Video, VideoOff, Loader2, Link2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import api from '../services/api';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function FederatedRoomDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const [room, setRoom] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [removing, setRemoving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetch = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/federation/federated-rooms');
|
||||||
|
const found = (res.data.rooms || []).find(r => String(r.id) === String(id));
|
||||||
|
if (!found) {
|
||||||
|
toast.error(t('room.notFound'));
|
||||||
|
navigate('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRoom(found);
|
||||||
|
} catch {
|
||||||
|
toast.error(t('room.notFound'));
|
||||||
|
navigate('/dashboard');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetch();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleJoin = () => {
|
||||||
|
window.open(room.join_url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
if (!confirm(t('federation.removeRoomConfirm'))) return;
|
||||||
|
setRemoving(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/federation/federated-rooms/${room.id}`);
|
||||||
|
toast.success(t('federation.roomRemoved'));
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch {
|
||||||
|
toast.error(t('federation.roomRemoveFailed'));
|
||||||
|
setRemoving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room) return null;
|
||||||
|
|
||||||
|
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
{/* Back */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
className="flex items-center gap-2 text-sm text-th-text-s hover:text-th-text transition-colors mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
{t('federation.backToDashboard')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="card p-6 mb-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-th-accent/15 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<Globe size={20} className="text-th-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-xl font-bold text-th-text truncate">{room.room_name}</h1>
|
||||||
|
<span className="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-1">
|
||||||
|
{t('federation.from')}: <span className="font-medium text-th-text">{room.from_user}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleJoin}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
{t('federation.joinMeeting')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={removing}
|
||||||
|
className="btn-ghost text-th-error hover:text-th-error px-3"
|
||||||
|
title={t('federation.removeRoom')}
|
||||||
|
>
|
||||||
|
{removing ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||||
|
{/* Max participants */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-1 text-xs font-semibold text-th-text-s uppercase tracking-wider">
|
||||||
|
<Users size={13} />
|
||||||
|
{t('federation.maxParticipants')}
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-th-text">
|
||||||
|
{room.max_participants > 0 ? room.max_participants : '∞'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-th-text-s mt-0.5">
|
||||||
|
{room.max_participants > 0 ? t('federation.participantLimit') : t('federation.unlimited')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recording */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-1 text-xs font-semibold text-th-text-s uppercase tracking-wider">
|
||||||
|
{recordingOn ? <Video size={13} /> : <VideoOff size={13} />}
|
||||||
|
{t('federation.recordingLabel')}
|
||||||
|
</div>
|
||||||
|
<p className={`text-lg font-bold ${recordingOn ? 'text-amber-400' : 'text-th-text-s'}`}>
|
||||||
|
{recordingOn ? t('federation.recordingOn') : t('federation.recordingOff')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-th-text-s mt-0.5">
|
||||||
|
{recordingOn ? t('federation.recordingOnHint') : t('federation.recordingOffHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="card p-5 space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider">
|
||||||
|
{t('federation.roomDetails')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{room.meet_id && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Hash size={16} className="text-th-accent flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-th-text-s mb-0.5">{t('federation.meetingId')}</p>
|
||||||
|
<p className="text-sm font-mono text-th-text break-all">{room.meet_id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Link2 size={16} className="text-th-accent flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs text-th-text-s mb-0.5">{t('federation.joinUrl')}</p>
|
||||||
|
<p className="text-sm font-mono text-th-text break-all opacity-60 select-all">{room.join_url}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Read-only notice */}
|
||||||
|
<p className="text-xs text-th-text-s mt-4 text-center italic">
|
||||||
|
{t('federation.readOnlyNotice')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -216,7 +216,9 @@ export default function RoomDetail() {
|
|||||||
|
|
||||||
const handleFedInvite = async (e) => {
|
const handleFedInvite = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!fedAddress.includes('@')) {
|
// Accept @user@domain or user@domain — must have a domain part
|
||||||
|
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
|
||||||
|
if (!normalized.includes('@') || normalized.endsWith('@')) {
|
||||||
toast.error(t('federation.addressHint'));
|
toast.error(t('federation.addressHint'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user