From d8dcb6e628283fcd84e98f0669acd963eabae780 Mon Sep 17 00:00:00 2001 From: Michelle Date: Tue, 24 Feb 2026 19:32:57 +0100 Subject: [PATCH] Add sharing rooms --- server/config/database.js | 22 +++++ server/routes/rooms.js | 165 +++++++++++++++++++++++++++++++++--- src/components/RoomCard.jsx | 12 ++- src/i18n/de.json | 13 ++- src/i18n/en.json | 13 ++- src/pages/Dashboard.jsx | 39 +++++++-- src/pages/GuestJoin.jsx | 24 ++++-- src/pages/RoomDetail.jsx | 139 +++++++++++++++++++++++++++++- 8 files changed, 389 insertions(+), 38 deletions(-) diff --git a/server/config/database.js b/server/config/database.js index bdac28a..6ad340c 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -157,9 +157,19 @@ export async function initDatabase() { updated_at TIMESTAMP DEFAULT NOW() ); + CREATE TABLE IF NOT EXISTS room_shares ( + id SERIAL PRIMARY KEY, + room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(room_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id); CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id); + CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id); `); } else { await db.exec(` @@ -197,9 +207,21 @@ export async function initDatabase() { FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS room_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(room_id, user_id), + FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id); CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id); + CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id); `); } diff --git a/server/routes/rooms.js b/server/routes/rooms.js index a812783..ecffd65 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -22,25 +22,56 @@ function getUserAvatarURL(req, user) { return `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(user.name)}${color}`; } -// GET /api/rooms - List user's rooms +// GET /api/rooms - List user's rooms (owned + shared) router.get('/', authenticateToken, async (req, res) => { try { const db = getDb(); - const rooms = await db.all(` - SELECT r.*, u.name as owner_name + const ownRooms = await db.all(` + SELECT r.*, u.name as owner_name, 0 as shared FROM rooms r JOIN users u ON r.user_id = u.id WHERE r.user_id = ? ORDER BY r.created_at DESC `, [req.user.id]); - res.json({ rooms }); + const sharedRooms = await db.all(` + SELECT r.*, u.name as owner_name, 1 as shared + FROM rooms r + JOIN users u ON r.user_id = u.id + JOIN room_shares rs ON rs.room_id = r.id + WHERE rs.user_id = ? + ORDER BY r.created_at DESC + `, [req.user.id]); + + res.json({ rooms: [...ownRooms, ...sharedRooms] }); } catch (err) { console.error('List rooms error:', err); res.status(500).json({ error: 'Räume konnten nicht geladen werden' }); } }); +// GET /api/rooms/users/search - Search users for sharing (must be before /:uid routes) +router.get('/users/search', authenticateToken, async (req, res) => { + try { + const { q } = req.query; + if (!q || q.length < 2) { + return res.json({ users: [] }); + } + const db = getDb(); + const searchTerm = `%${q}%`; + const users = await db.all(` + SELECT id, name, email, avatar_color, avatar_image + FROM users + WHERE (name LIKE ? OR email LIKE ?) AND id != ? + LIMIT 10 + `, [searchTerm, searchTerm, req.user.id]); + res.json({ users }); + } catch (err) { + console.error('Search users error:', err); + res.status(500).json({ error: 'Benutzersuche fehlgeschlagen' }); + } +}); + // GET /api/rooms/:uid - Get room details router.get('/:uid', authenticateToken, async (req, res) => { try { @@ -56,7 +87,24 @@ router.get('/:uid', authenticateToken, async (req, res) => { return res.status(404).json({ error: 'Raum nicht gefunden' }); } - res.json({ room }); + // Check access: owner, admin, or shared + if (room.user_id !== req.user.id && req.user.role !== 'admin') { + 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: 'Keine Berechtigung' }); + } + room.shared = 1; + } + + // Get shared users + const sharedUsers = await db.all(` + SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image + FROM room_shares rs + JOIN users u ON rs.user_id = u.id + WHERE rs.room_id = ? + `, [room.id]); + + res.json({ room, sharedUsers }); } catch (err) { console.error('Get room error:', err); res.status(500).json({ error: 'Raum konnte nicht geladen werden' }); @@ -197,15 +245,100 @@ router.delete('/:uid', authenticateToken, async (req, res) => { res.status(500).json({ error: 'Raum konnte nicht gelöscht werden' }); } }); +// GET /api/rooms/:uid/shares - Get shared users for a room +router.get('/:uid/shares', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]); + if (!room) { + return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' }); + } + const shares = await db.all(` + SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image + FROM room_shares rs + JOIN users u ON rs.user_id = u.id + WHERE rs.room_id = ? + `, [room.id]); + res.json({ shares }); + } catch (err) { + console.error('Get shares error:', err); + res.status(500).json({ error: 'Fehler beim Laden der Freigaben' }); + } +}); + +// POST /api/rooms/:uid/shares - Share room with a user +router.post('/:uid/shares', authenticateToken, async (req, res) => { + try { + const { user_id } = req.body; + if (!user_id) { + return res.status(400).json({ error: 'Benutzer-ID erforderlich' }); + } + const db = getDb(); + const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]); + if (!room) { + return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' }); + } + if (user_id === req.user.id) { + return res.status(400).json({ error: 'Du kannst den Raum nicht mit dir selbst teilen' }); + } + // Check if already shared + const existing = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, user_id]); + if (existing) { + return res.status(400).json({ error: 'Raum ist bereits mit diesem Benutzer geteilt' }); + } + await db.run('INSERT INTO room_shares (room_id, user_id) VALUES (?, ?)', [room.id, user_id]); + const shares = await db.all(` + SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image + FROM room_shares rs + JOIN users u ON rs.user_id = u.id + WHERE rs.room_id = ? + `, [room.id]); + res.json({ shares }); + } catch (err) { + console.error('Share room error:', err); + res.status(500).json({ error: 'Fehler beim Teilen des Raums' }); + } +}); + +// DELETE /api/rooms/:uid/shares/:userId - Remove share +router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]); + if (!room) { + return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' }); + } + await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]); + const shares = await db.all(` + SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image + FROM room_shares rs + JOIN users u ON rs.user_id = u.id + WHERE rs.room_id = ? + `, [room.id]); + res.json({ shares }); + } catch (err) { + console.error('Remove share error:', err); + res.status(500).json({ error: 'Fehler beim Entfernen der Freigabe' }); + } +}); // POST /api/rooms/:uid/start - Start meeting router.post('/:uid/start', authenticateToken, async (req, res) => { try { const db = getDb(); - const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]); + const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); if (!room) { - return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' }); + return res.status(404).json({ error: 'Raum nicht gefunden' }); + } + + // Check access: owner or shared user + 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: 'Keine Berechtigung' }); + } } await createMeeting(room, `${req.protocol}://${req.get('host')}`); @@ -239,7 +372,10 @@ router.post('/:uid/join', authenticateToken, async (req, res) => { return res.status(400).json({ error: 'Meeting läuft nicht. Bitte warten Sie, bis der Moderator das Meeting gestartet hat.' }); } - const isModerator = room.user_id === req.user.id || room.all_join_moderator; + // Owner and shared users join as moderator + const isOwner = room.user_id === req.user.id; + const isShared = !isOwner && await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); + const isModerator = isOwner || !!isShared || room.all_join_moderator; const avatarURL = getUserAvatarURL(req, req.user); const joinUrl = await joinMeeting(room.uid, req.user.name, isModerator, avatarURL); res.json({ joinUrl }); @@ -253,10 +389,19 @@ router.post('/:uid/join', authenticateToken, async (req, res) => { router.post('/:uid/end', authenticateToken, async (req, res) => { try { const db = getDb(); - const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]); + const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); if (!room) { - return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' }); + return res.status(404).json({ error: 'Raum nicht gefunden' }); + } + + // Check access: owner or shared user + 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: 'Keine Berechtigung' }); + } } await endMeeting(room.uid); diff --git a/src/components/RoomCard.jsx b/src/components/RoomCard.jsx index c3bd3cd..1f783cc 100644 --- a/src/components/RoomCard.jsx +++ b/src/components/RoomCard.jsx @@ -1,4 +1,4 @@ -import { Users, Play, Trash2, Radio, Loader2 } from 'lucide-react'; +import { Users, Play, Trash2, Radio, Loader2, Share2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useState, useEffect } from 'react'; import api from '../services/api'; @@ -39,9 +39,15 @@ export default function RoomCard({ room, onDelete }) { {t('common.live')} )} + {room.shared ? ( + + + {t('room.shared')} + + ) : null}

- {room.uid.substring(0, 8)}... + {room.shared ? room.owner_name : `${room.uid.substring(0, 8)}...`}

@@ -93,7 +99,7 @@ export default function RoomCard({ room, onDelete }) { {starting ? : } {status.running ? t('room.join') : t('room.startMeeting')} - {onDelete && ( + {onDelete && !room.shared && ( ) : ( -
- {rooms.map(room => ( - - ))} -
+ <> + {/* Own rooms */} + {rooms.filter(r => !r.shared).length > 0 && ( +
+ {rooms.filter(r => !r.shared).map(room => ( + + ))} +
+ )} + + {/* Shared rooms */} + {rooms.filter(r => r.shared).length > 0 && ( +
+

{t('dashboard.sharedWithMe')}

+
+ {rooms.filter(r => r.shared).map(room => ( + + ))} +
+
+ )} + )} {/* Create Room Modal */} diff --git a/src/pages/GuestJoin.jsx b/src/pages/GuestJoin.jsx index 27d626a..b7e0730 100644 --- a/src/pages/GuestJoin.jsx +++ b/src/pages/GuestJoin.jsx @@ -4,15 +4,18 @@ import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio } from 'lu import api from '../services/api'; import toast from 'react-hot-toast'; import { useLanguage } from '../contexts/LanguageContext'; +import { useAuth } from '../contexts/AuthContext'; export default function GuestJoin() { const { uid } = useParams(); const { t } = useLanguage(); + const { user } = useAuth(); + const isLoggedIn = !!user; const [roomInfo, setRoomInfo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [joining, setJoining] = useState(false); - const [name, setName] = useState(''); + const [name, setName] = useState(user?.name || ''); const [accessCode, setAccessCode] = useState(''); const [moderatorCode, setModeratorCode] = useState(''); const [status, setStatus] = useState({ running: false }); @@ -162,11 +165,12 @@ export default function GuestJoin() { setName(e.target.value)} - className="input-field pl-11" + onChange={e => !isLoggedIn && setName(e.target.value)} + readOnly={isLoggedIn} + className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`} placeholder={t('room.guestNamePlaceholder')} required - autoFocus + autoFocus={!isLoggedIn} /> @@ -226,11 +230,13 @@ export default function GuestJoin() { )} -
- - {t('room.guestHasAccount')} {t('room.guestSignIn')} - -
+ {!isLoggedIn && ( +
+ + {t('room.guestHasAccount')} {t('room.guestSignIn')} + +
+ )} diff --git a/src/pages/RoomDetail.jsx b/src/pages/RoomDetail.jsx index de7c523..7bec7ce 100644 --- a/src/pages/RoomDetail.jsx +++ b/src/pages/RoomDetail.jsx @@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import { ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio, Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck, - Shield, Save, + Shield, Save, UserPlus, X, Share2, } from 'lucide-react'; import api from '../services/api'; import { useAuth } from '../contexts/AuthContext'; @@ -25,14 +25,21 @@ export default function RoomDetail() { const [activeTab, setActiveTab] = useState('overview'); const [editRoom, setEditRoom] = useState(null); const [saving, setSaving] = useState(false); + const [sharedUsers, setSharedUsers] = useState([]); + const [shareSearch, setShareSearch] = useState(''); + const [shareResults, setShareResults] = useState([]); + const [shareSearching, setShareSearching] = useState(false); const isOwner = room && user && room.user_id === user.id; + const isShared = room && !!room.shared; + const canManage = isOwner || isShared; const fetchRoom = async () => { try { const res = await api.get(`/rooms/${uid}`); setRoom(res.data.room); setEditRoom(res.data.room); + if (res.data.sharedUsers) setSharedUsers(res.data.sharedUsers); } catch { toast.error(t('room.notFound')); navigate('/dashboard'); @@ -144,6 +151,48 @@ export default function RoomDetail() { toast.success(t('room.linkCopied')); }; + // Share functions + const searchUsers = async (query) => { + setShareSearch(query); + if (query.length < 2) { + setShareResults([]); + return; + } + setShareSearching(true); + try { + const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`); + // Filter out already shared users + const sharedIds = new Set(sharedUsers.map(u => u.id)); + setShareResults(res.data.users.filter(u => !sharedIds.has(u.id))); + } catch { + setShareResults([]); + } finally { + setShareSearching(false); + } + }; + + const handleShare = async (userId) => { + try { + const res = await api.post(`/rooms/${uid}/shares`, { user_id: userId }); + setSharedUsers(res.data.shares); + setShareSearch(''); + setShareResults([]); + toast.success(t('room.shareAdded')); + } catch (err) { + toast.error(err.response?.data?.error || t('room.shareFailed')); + } + }; + + const handleUnshare = async (userId) => { + try { + const res = await api.delete(`/rooms/${uid}/shares/${userId}`); + setSharedUsers(res.data.shares); + toast.success(t('room.shareRemoved')); + } catch { + toast.error(t('room.shareFailed')); + } + }; + if (loading) { return (
@@ -203,7 +252,7 @@ export default function RoomDetail() {
- {isOwner && !status.running && ( + {canManage && !status.running && ( )} - {isOwner && status.running && ( + {canManage && status.running && (
+ {/* Share section */} +
+

+ + {t('room.shareTitle')} +

+

{t('room.shareDescription')}

+ + {/* User search */} +
+
+ + searchUsers(e.target.value)} + className="input-field pl-11" + placeholder={t('room.shareSearchPlaceholder')} + /> +
+ {shareResults.length > 0 && ( +
+ {shareResults.map(u => ( + + ))} +
+ )} +
+ + {/* Shared users list */} + {sharedUsers.length > 0 && ( +
+ {sharedUsers.map(u => ( +
+
+
+ {u.avatar_image ? ( + + ) : ( + u.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) + )} +
+
+
{u.name}
+
{u.email}
+
+
+ +
+ ))} +
+ )} +
+