feat: add room management functionality for admins with listing and deletion options
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m12s
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m12s
This commit is contained in:
@@ -362,4 +362,28 @@ router.delete('/oauth', authenticateToken, requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Room Management (admin only) ────────────────────────────────────────────
|
||||
|
||||
// GET /api/admin/rooms - List all rooms with owner info
|
||||
router.get('/rooms', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rooms = await db.all(`
|
||||
SELECT r.id, r.uid, r.name, r.user_id, r.max_participants, r.access_code,
|
||||
r.mute_on_join, r.record_meeting, r.guest_access, r.presentation_file,
|
||||
r.created_at, r.updated_at,
|
||||
COALESCE(NULLIF(u.display_name,''), u.name) as owner_name,
|
||||
u.email as owner_email,
|
||||
(SELECT COUNT(*) FROM room_shares rs WHERE rs.room_id = r.id) as share_count
|
||||
FROM rooms r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
ORDER BY r.created_at DESC
|
||||
`);
|
||||
res.json({ rooms });
|
||||
} catch (err) {
|
||||
log.admin.error(`List rooms error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Rooms could not be loaded' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -484,7 +484,20 @@
|
||||
"oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.",
|
||||
"oauthNotConfigured": "OAuth ist noch nicht konfiguriert.",
|
||||
"oauthSave": "OAuth speichern",
|
||||
"oauthRemove": "OAuth entfernen"
|
||||
"oauthRemove": "OAuth entfernen",
|
||||
"roomsTitle": "Raumverwaltung",
|
||||
"roomsDescription": "Alle Räume der Instanz einsehen, verwalten und bei Bedarf löschen.",
|
||||
"searchRooms": "Räume suchen...",
|
||||
"roomName": "Name",
|
||||
"roomOwner": "Besitzer",
|
||||
"roomShares": "Geteilt",
|
||||
"roomCreated": "Erstellt",
|
||||
"roomView": "Raum öffnen",
|
||||
"deleteRoom": "Raum löschen",
|
||||
"deleteRoomConfirm": "Raum \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"roomDeleted": "Raum gelöscht",
|
||||
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
|
||||
"noRoomsFound": "Keine Räume vorhanden"
|
||||
},
|
||||
"notifications": {
|
||||
"bell": "Benachrichtigungen",
|
||||
|
||||
@@ -484,7 +484,20 @@
|
||||
"oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.",
|
||||
"oauthNotConfigured": "OAuth is not configured yet.",
|
||||
"oauthSave": "Save OAuth",
|
||||
"oauthRemove": "Remove OAuth"
|
||||
"oauthRemove": "Remove OAuth",
|
||||
"roomsTitle": "Room Management",
|
||||
"roomsDescription": "View, manage, and delete all rooms on this instance.",
|
||||
"searchRooms": "Search rooms...",
|
||||
"roomName": "Name",
|
||||
"roomOwner": "Owner",
|
||||
"roomShares": "Shared",
|
||||
"roomCreated": "Created",
|
||||
"roomView": "View room",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteRoomConfirm": "Really delete room \"{name}\"? This cannot be undone.",
|
||||
"roomDeleted": "Room deleted",
|
||||
"roomDeleteFailed": "Room could not be deleted",
|
||||
"noRoomsFound": "No rooms found"
|
||||
},
|
||||
"notifications": {
|
||||
"bell": "Notifications",
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
|
||||
ShieldCheck, Globe, Link as LinkIcon, LogIn,
|
||||
ShieldCheck, Globe, Link as LinkIcon, LogIn, DoorOpen, Eye, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -55,6 +55,11 @@ export default function Admin() {
|
||||
const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
|
||||
const [savingOauth, setSavingOauth] = useState(false);
|
||||
|
||||
// Rooms state
|
||||
const [adminRooms, setAdminRooms] = useState([]);
|
||||
const [adminRoomsLoading, setAdminRoomsLoading] = useState(true);
|
||||
const [roomSearch, setRoomSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== 'admin') {
|
||||
navigate('/dashboard');
|
||||
@@ -63,6 +68,7 @@ export default function Admin() {
|
||||
fetchUsers();
|
||||
fetchInvites();
|
||||
fetchOauthConfig();
|
||||
fetchAdminRooms();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -101,6 +107,29 @@ export default function Admin() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAdminRooms = async () => {
|
||||
setAdminRoomsLoading(true);
|
||||
try {
|
||||
const res = await api.get('/admin/rooms');
|
||||
setAdminRooms(res.data.rooms);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setAdminRoomsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdminDeleteRoom = async (uid, name) => {
|
||||
if (!confirm(t('admin.deleteRoomConfirm', { name }))) return;
|
||||
try {
|
||||
await api.delete(`/rooms/${uid}`);
|
||||
toast.success(t('admin.roomDeleted'));
|
||||
fetchAdminRooms();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.roomDeleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId, newRole) => {
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/role`, { role: newRole });
|
||||
@@ -790,6 +819,114 @@ export default function Admin() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Room Management */}
|
||||
<div className="card p-6 mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<DoorOpen size={20} className="text-th-accent" />
|
||||
<h2 className="text-lg font-semibold text-th-text">{t('admin.roomsTitle')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mb-5">{t('admin.roomsDescription')}</p>
|
||||
|
||||
{adminRoomsLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 size={20} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Room search */}
|
||||
<div className="relative mb-4">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={roomSearch}
|
||||
onChange={e => setRoomSearch(e.target.value)}
|
||||
className="input-field pl-9 text-sm"
|
||||
placeholder={t('admin.searchRooms')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-th-border">
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
|
||||
{t('admin.roomName')}
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden sm:table-cell">
|
||||
{t('admin.roomOwner')}
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden md:table-cell">
|
||||
{t('admin.roomShares')}
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden lg:table-cell">
|
||||
{t('admin.roomCreated')}
|
||||
</th>
|
||||
<th className="text-right text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
|
||||
{t('admin.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adminRooms
|
||||
.filter(r =>
|
||||
r.name.toLowerCase().includes(roomSearch.toLowerCase()) ||
|
||||
r.owner_name.toLowerCase().includes(roomSearch.toLowerCase()) ||
|
||||
r.uid.toLowerCase().includes(roomSearch.toLowerCase())
|
||||
)
|
||||
.map(r => (
|
||||
<tr key={r.id} className="border-b border-th-border last:border-0 hover:bg-th-hover transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-th-text">{r.name}</p>
|
||||
<p className="text-xs text-th-text-s font-mono">{r.uid}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<div>
|
||||
<p className="text-sm text-th-text">{r.owner_name}</p>
|
||||
<p className="text-xs text-th-text-s">{r.owner_email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-th-text hidden md:table-cell">
|
||||
{r.share_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-th-text-s hidden lg:table-cell">
|
||||
{new Date(r.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => navigate(`/rooms/${r.uid}`)}
|
||||
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
title={t('admin.roomView')}
|
||||
>
|
||||
<Eye size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAdminDeleteRoom(r.uid, r.name)}
|
||||
className="p-1.5 rounded-lg hover:bg-th-hover text-th-error transition-colors"
|
||||
title={t('admin.deleteRoom')}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{adminRooms.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<DoorOpen size={36} className="mx-auto text-th-text-s/40 mb-2" />
|
||||
<p className="text-th-text-s text-sm">{t('admin.noRoomsFound')}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="relative">
|
||||
|
||||
Reference in New Issue
Block a user