diff --git a/server/config/bbb.js b/server/config/bbb.js index bbfee49..692b8f1 100644 --- a/server/config/bbb.js +++ b/server/config/bbb.js @@ -16,10 +16,13 @@ function buildUrl(apiCall, params = {}) { return `${BBB_URL}${apiCall}?${queryString}`; } -async function apiCall(apiCallName, params = {}) { +async function apiCall(apiCallName, params = {}, xmlBody = null) { const url = buildUrl(apiCallName, params); try { - const response = await fetch(url); + const fetchOptions = xmlBody + ? { method: 'POST', headers: { 'Content-Type': 'application/xml' }, body: xmlBody } + : {}; + const response = await fetch(url, fetchOptions); const xml = await response.text(); const result = await xml2js.parseStringPromise(xml, { explicitArray: false, @@ -39,7 +42,7 @@ function getRoomPasswords(uid) { return { moderatorPW: modPw, attendeePW: attPw }; } -export async function createMeeting(room, logoutURL, loginURL = null) { +export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null) { const { moderatorPW, attendeePW } = getRoomPasswords(room.uid); // Build welcome message with guest invite link @@ -77,7 +80,13 @@ export async function createMeeting(room, logoutURL, loginURL = null) { if (room.access_code) { params.lockSettingsLockOnJoin = 'true'; } - return apiCall('create', params); + + // Build optional presentation XML body + const xmlBody = presentationUrl + ? `` + : null; + + return apiCall('create', params, xmlBody); } export async function joinMeeting(uid, name, isModerator = false, avatarURL = null) { diff --git a/server/config/database.js b/server/config/database.js index e4469d6..b030a6b 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -157,6 +157,7 @@ export async function initDatabase() { record_meeting INTEGER DEFAULT 1, guest_access INTEGER DEFAULT 0, moderator_code TEXT, + presentation_file TEXT DEFAULT NULL, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); @@ -244,6 +245,7 @@ export async function initDatabase() { record_meeting INTEGER DEFAULT 1, guest_access INTEGER DEFAULT 0, moderator_code TEXT, + presentation_file TEXT DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE @@ -349,6 +351,9 @@ export async function initDatabase() { await db.exec("ALTER TABLE users ADD COLUMN display_name TEXT DEFAULT ''"); await db.exec("UPDATE users SET display_name = name WHERE display_name = ''"); } + if (!(await db.columnExists('rooms', 'presentation_file'))) { + await db.exec('ALTER TABLE rooms ADD COLUMN presentation_file TEXT DEFAULT NULL'); + } // ── Default admin ─────────────────────────────────────────────────────── const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com'; diff --git a/server/index.js b/server/index.js index 4f70ed0..3e70521 100644 --- a/server/index.js +++ b/server/index.js @@ -30,6 +30,10 @@ async function start() { await initDatabase(); initMailer(); + // Serve uploaded files (avatars, presentations) + const uploadsPath = path.join(__dirname, '..', 'uploads'); + app.use('/uploads', express.static(uploadsPath)); + // API Routes app.use('/api/auth', authRoutes); app.use('/api/rooms', roomRoutes); diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 7f60a4c..c2fa22b 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -14,7 +14,7 @@ export async function authenticateToken(req, res, next) { try { const decoded = jwt.verify(token, JWT_SECRET); const db = getDb(); - const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [decoded.userId]); + const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [decoded.userId]); if (!user) { return res.status(401).json({ error: 'User not found' }); } diff --git a/server/routes/auth.js b/server/routes/auth.js index 682cb6e..59e4867 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -83,7 +83,7 @@ router.post('/register', async (req, res) => { ); const token = generateToken(result.lastInsertRowid); - const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [result.lastInsertRowid]); + const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [result.lastInsertRowid]); res.status(201).json({ token, user }); } catch (err) { @@ -243,7 +243,7 @@ router.put('/profile', authenticateToken, async (req, res) => { WHERE id = ? `, [name, display_name, email?.toLowerCase(), theme, language, avatar_color, req.user.id]); - const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]); + const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]); res.json({ user: updated }); } catch (err) { console.error('Profile update error:', err); @@ -312,8 +312,7 @@ router.post('/avatar', authenticateToken, async (req, res) => { fs.writeFileSync(filepath, buffer); await db.run('UPDATE users SET avatar_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [filename, req.user.id]); - const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]); - + const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]); res.json({ user: updated }); } catch (err) { console.error('Avatar upload error:', err); @@ -331,7 +330,7 @@ router.delete('/avatar', authenticateToken, async (req, res) => { if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); } await db.run('UPDATE users SET avatar_image = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]); - const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]); + const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]); res.json({ user: updated }); } catch (err) { console.error('Avatar delete error:', err); diff --git a/server/routes/rooms.js b/server/routes/rooms.js index 37c6d27..455bc97 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -1,5 +1,8 @@ import { Router } from 'express'; import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; import { @@ -10,6 +13,11 @@ import { isMeetingRunning, } from '../config/bbb.js'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations'); +if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true }); + const router = Router(); // Build avatar URL for a user (uploaded image or generated initials) @@ -19,7 +27,7 @@ function getUserAvatarURL(req, user) { return `${baseUrl}/api/auth/avatar/${user.avatar_image}`; } const color = user.avatar_color ? `?color=${encodeURIComponent(user.avatar_color)}` : ''; - return `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(user.name)}${color}`; + return `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(user.display_name || user.name)}${color}`; } // GET /api/rooms - List user's rooms (owned + shared) @@ -343,9 +351,13 @@ router.post('/:uid/start', authenticateToken, async (req, res) => { const baseUrl = `${req.protocol}://${req.get('host')}`; const loginURL = `${baseUrl}/join/${room.uid}`; - await createMeeting(room, baseUrl, loginURL); + const presentationUrl = room.presentation_file + ? `${baseUrl}/uploads/presentations/${room.presentation_file}` + : null; + await createMeeting(room, baseUrl, loginURL, presentationUrl); const avatarURL = getUserAvatarURL(req, req.user); - const joinUrl = await joinMeeting(room.uid, req.user.name, true, avatarURL); + const displayName = req.user.display_name || req.user.name; + const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL); res.json({ joinUrl }); } catch (err) { console.error('Start meeting error:', err); @@ -379,7 +391,7 @@ router.post('/:uid/join', authenticateToken, async (req, res) => { 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); + const joinUrl = await joinMeeting(room.uid, req.user.display_name || req.user.name, isModerator, avatarURL); res.json({ joinUrl }); } catch (err) { console.error('Join meeting error:', err); @@ -523,4 +535,73 @@ router.get('/:uid/status', async (req, res) => { } }); +// POST /api/rooms/:uid/presentation - Upload a presentation file for the room +router.post('/:uid/presentation', 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: 'Room not found or no permission' }); + + const buffer = await new Promise((resolve, reject) => { + const chunks = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); + + const contentType = req.headers['content-type'] || ''; + const extMap = { + 'application/pdf': 'pdf', + 'application/vnd.ms-powerpoint': 'ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx', + 'application/vnd.oasis.opendocument.presentation': 'odp', + 'application/msword': 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', + }; + const ext = extMap[contentType]; + if (!ext) return res.status(400).json({ error: 'Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX' }); + + // Max 50MB + if (buffer.length > 50 * 1024 * 1024) return res.status(400).json({ error: 'File must not exceed 50MB' }); + + const filename = `${room.uid}_${Date.now()}.${ext}`; + const filepath = path.join(presentationsDir, filename); + + // Remove old presentation file if exists + if (room.presentation_file) { + const oldPath = path.join(presentationsDir, room.presentation_file); + if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); + } + + fs.writeFileSync(filepath, buffer); + await db.run('UPDATE rooms SET presentation_file = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, req.params.uid]); + const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); + res.json({ room: updated }); + } catch (err) { + console.error('Presentation upload error:', err); + res.status(500).json({ error: 'Presentation could not be uploaded' }); + } +}); + +// DELETE /api/rooms/:uid/presentation - Remove presentation file +router.delete('/:uid/presentation', 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: 'Room not found or no permission' }); + + if (room.presentation_file) { + const filepath = path.join(presentationsDir, room.presentation_file); + if (fs.existsSync(filepath)) fs.unlinkSync(filepath); + } + + await db.run('UPDATE rooms SET presentation_file = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]); + const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); + res.json({ room: updated }); + } catch (err) { + console.error('Presentation delete error:', err); + res.status(500).json({ error: 'Presentation could not be removed' }); + } +}); + export default router; diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 4760f74..f6861a3 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -1,10 +1,40 @@ import { Outlet } from 'react-router-dom'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import Navbar from './Navbar'; import Sidebar from './Sidebar'; +import { useAuth } from '../contexts/AuthContext'; +import { useLanguage } from '../contexts/LanguageContext'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; +import api from '../services/api'; +import toast from 'react-hot-toast'; export default function Layout() { const [sidebarOpen, setSidebarOpen] = useState(false); + const { user } = useAuth(); + const { t } = useLanguage(); + const [resendCooldown, setResendCooldown] = useState(0); + const [resending, setResending] = useState(false); + + // Countdown timer for resend cooldown + useEffect(() => { + if (resendCooldown <= 0) return; + const timer = setTimeout(() => setResendCooldown(c => c - 1), 1000); + return () => clearTimeout(timer); + }, [resendCooldown]); + + const handleResendVerification = async () => { + if (resendCooldown > 0 || resending) return; + setResending(true); + try { + await api.post('/auth/resend-verification', { email: user.email }); + toast.success(t('auth.emailVerificationResendSuccess')); + setResendCooldown(60); + } catch { + toast.error(t('auth.emailVerificationResendFailed')); + } finally { + setResending(false); + } + }; return (
@@ -14,6 +44,25 @@ export default function Layout() { {/* Main content */}
setSidebarOpen(true)} /> + + {/* Email verification banner */} + {user && user.email_verified === 0 && ( +
+ + {t('auth.emailVerificationBanner')} + +
+ )} +
@@ -29,3 +78,4 @@ export default function Layout() {
); } + diff --git a/src/i18n/de.json b/src/i18n/de.json index 90894ca..066b9bf 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -81,7 +81,12 @@ "usernameTaken": "Benutzername ist bereits vergeben", "usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (3–30 Zeichen)", "usernameRequired": "Benutzername ist erforderlich", - "displayNameRequired": "Anzeigename ist erforderlich" + "displayNameRequired": "Anzeigename ist erforderlich", + "emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.", + "emailVerificationResend": "Hier klicken um eine neue Verifizierungsmail zu erhalten", + "emailVerificationResendCooldown": "Erneut senden in {seconds}s", + "emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!", + "emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden" }, "home": { "poweredBy": "Powered by BigBlueButton", @@ -213,7 +218,16 @@ "guestRecordingNotice": "Dieses Meeting könnte aufgenommen werden, inkl. Ihrer Audio / Video.", "guestRecordingConsent": "Ich bin damit einverstanden, dass dieses Meeting aufgenommen werden kann.", "shared": "Geteilt", - "shareTitle": "Raum teilen", + "presentationTitle": "Standard-Präsentation", + "presentationDesc": "Diese Datei wird beim Start des Meetings automatisch in BBB vorgeladen.", + "presentationUpload": "Präsentation hochladen", + "presentationRemove": "Präsentation entfernen", + "presentationUploaded": "Präsentation hochgeladen", + "presentationRemoved": "Präsentation entfernt", + "presentationUploadFailed": "Präsentation konnte nicht hochgeladen werden", + "presentationRemoveFailed": "Präsentation konnte nicht entfernt werden", + "presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB", + "presentationCurrent": "Aktuell:", "shareDescription": "Teilen Sie diesen Raum mit anderen Benutzern, damit diese ihn in ihrem Dashboard sehen und beitreten k\u00f6nnen.", "shareSearchPlaceholder": "Benutzer suchen (Name oder E-Mail)...", "shareAdded": "Benutzer hinzugef\u00fcgt", diff --git a/src/i18n/en.json b/src/i18n/en.json index d3ea2ae..73366da 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -81,7 +81,12 @@ "usernameTaken": "Username is already taken", "usernameInvalid": "Username may only contain letters, numbers, _ and - (3–30 chars)", "usernameRequired": "Username is required", - "displayNameRequired": "Display name is required" + "displayNameRequired": "Display name is required", + "emailVerificationBanner": "Your email address has not been verified yet.", + "emailVerificationResend": "Click here to receive a new verification email", + "emailVerificationResendCooldown": "Resend in {seconds}s", + "emailVerificationResendSuccess": "Verification email sent!", + "emailVerificationResendFailed": "Could not send verification email" }, "home": { "poweredBy": "Powered by BigBlueButton", @@ -213,7 +218,16 @@ "guestRecordingNotice": "This meeting may be recorded, including your audio and video.", "guestRecordingConsent": "I understand that this meeting may be recorded.", "shared": "Shared", - "shareTitle": "Share room", + "presentationTitle": "Default Presentation", + "presentationDesc": "This file will be automatically pre-loaded in BBB when the meeting starts.", + "presentationUpload": "Upload presentation", + "presentationRemove": "Remove presentation", + "presentationUploaded": "Presentation uploaded", + "presentationRemoved": "Presentation removed", + "presentationUploadFailed": "Could not upload presentation", + "presentationRemoveFailed": "Could not remove presentation", + "presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB", + "presentationCurrent": "Current:" room", "shareDescription": "Share this room with other users so they can see it in their dashboard and join meetings.", "shareSearchPlaceholder": "Search users (name or email)...", "shareAdded": "User added", diff --git a/src/pages/RoomDetail.jsx b/src/pages/RoomDetail.jsx index 43a87fe..7ae7dc5 100644 --- a/src/pages/RoomDetail.jsx +++ b/src/pages/RoomDetail.jsx @@ -1,9 +1,10 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; 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, Globe, Send, + FileText, Upload, Trash2, } from 'lucide-react'; import Modal from '../components/Modal'; import api from '../services/api'; @@ -37,6 +38,11 @@ export default function RoomDetail() { const [fedMessage, setFedMessage] = useState(''); const [fedSending, setFedSending] = useState(false); + // Presentation state + const [uploadingPresentation, setUploadingPresentation] = useState(false); + const [removingPresentation, setRemovingPresentation] = useState(false); + const presentationInputRef = useRef(null); + const isOwner = room && user && room.user_id === user.id; const isShared = room && !!room.shared; const canManage = isOwner || isShared; @@ -159,6 +165,52 @@ export default function RoomDetail() { }; // Federation invite handler + const handlePresentationUpload = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + const allowedTypes = [ + 'application/pdf', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.presentation', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ]; + if (!allowedTypes.includes(file.type)) { + toast.error('Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX'); + return; + } + setUploadingPresentation(true); + try { + const arrayBuffer = await file.arrayBuffer(); + const res = await api.post(`/rooms/${uid}/presentation`, arrayBuffer, { + headers: { 'Content-Type': file.type }, + }); + setRoom(res.data.room); + setEditRoom(res.data.room); + toast.success(t('room.presentationUploaded')); + } catch (err) { + toast.error(err.response?.data?.error || t('room.presentationUploadFailed')); + } finally { + setUploadingPresentation(false); + if (presentationInputRef.current) presentationInputRef.current.value = ''; + } + }; + + const handlePresentationRemove = async () => { + setRemovingPresentation(true); + try { + const res = await api.delete(`/rooms/${uid}/presentation`); + setRoom(res.data.room); + setEditRoom(res.data.room); + toast.success(t('room.presentationRemoved')); + } catch (err) { + toast.error(err.response?.data?.error || t('room.presentationRemoveFailed')); + } finally { + setRemovingPresentation(false); + } + }; + const handleFedInvite = async (e) => { e.preventDefault(); if (!fedAddress.includes('@')) { @@ -538,6 +590,60 @@ export default function RoomDetail() {
+ {/* Presentation section */} +
+
+

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

+

{t('room.presentationDesc')}

+
+ + {room.presentation_file ? ( +
+
+ +
+

{t('room.presentationCurrent')}

+

+ presentation.{room.presentation_file.split('.').pop()} +

+
+
+ +
+ ) : ( +
{/* no presentation */}
+ )} + + + +

{t('room.presentationAllowedTypes')}

+
+ {/* Share section */}