14 Commits

Author SHA1 Message Date
e0ce354eda feat: improve email invitation label for clarity and add navigation on guest join
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m19s
2026-04-02 01:03:18 +02:00
1690a74c19 feat: add email invitation functionality for guests with support for multiple addresses
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m21s
2026-04-02 00:54:57 +02:00
61585d8c63 feat: add functionality to display all rooms with search and modal support in admin panel
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m17s
2026-04-01 12:05:51 +02:00
d04793148a 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
2026-04-01 11:54:10 +02:00
9bf4228d04 fix: update presentation display to show filename instead of name
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m21s
2026-04-01 11:34:48 +02:00
c058ba3bf1 fix: update presentation URL structure for token protection
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m13s
2026-04-01 09:43:32 +02:00
b3b559e164 feat: implement token-based access for presentation files and add serving endpoint
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m43s
2026-04-01 08:52:43 +02:00
0db9227c20 chore: update package-lock.json with dependency version bumps
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m18s
2026-03-31 13:11:56 +02:00
30f106a7ea fix: guest join doesn't add avatar when logged in
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m27s
2026-03-31 09:36:13 +02:00
8cbe28f915 chore: bump version to 2.1.2 and update user name handling in GuestJoin component
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m28s
Build & Push Docker Image / build (release) Successful in 4m20s
2026-03-26 09:40:41 +01:00
5472e190d9 chore: Bump version to 2.1.1
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m19s
Build & Push Docker Image / build (release) Successful in 4m12s
2026-03-25 11:34:38 +01:00
45be976de1 Don't show guestWaitingMessage when "anyone_can_start" is set
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m14s
2026-03-25 10:13:02 +01:00
6dcb1e959b feat: allow guests to start a room if anyone_can_start is enabled
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m58s
2026-03-25 09:55:47 +01:00
bb2d179871 style: Update button styling and icon size in RecordingList component for improved UI
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m29s
2026-03-24 11:28:15 +01:00
15 changed files with 767 additions and 159 deletions

38
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "redlight", "name": "redlight",
"version": "2.1.0", "version": "2.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "redlight", "name": "redlight",
"version": "2.1.0", "version": "2.1.2",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"axios": "^1.7.0", "axios": "^1.7.0",
@@ -932,9 +932,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@@ -3108,7 +3108,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "8.0.1", "version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -3238,7 +3240,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdfkit": { "node_modules/pdfkit": {
@@ -3333,7 +3337,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3846,9 +3852,9 @@
} }
}, },
"node_modules/readdir-glob/node_modules/brace-expansion": { "node_modules/readdir-glob/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -4475,7 +4481,9 @@
} }
}, },
"node_modules/tinyglobby/node_modules/picomatch": { "node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4781,9 +4789,9 @@
} }
}, },
"node_modules/vite/node_modules/picomatch": { "node_modules/vite/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

View File

@@ -1,7 +1,7 @@
{ {
"name": "redlight", "name": "redlight",
"private": true, "private": true,
"version": "2.1.0", "version": "2.1.2",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -144,6 +144,61 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
}); });
} }
/**
* Send a guest meeting invitation email with a direct join link.
* @param {string} to - recipient email
* @param {string} fromUser - sender display name
* @param {string} roomName - name of the room
* @param {string} message - optional personal message
* @param {string} joinUrl - direct guest join URL
* @param {string} appName - branding app name
* @param {string} lang - language code
*/
export async function sendGuestInviteEmail(to, fromUser, roomName, message, joinUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) {
throw new Error('SMTP not configured');
}
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeFromUser = escapeHtml(fromUser);
const safeRoomName = escapeHtml(roomName);
const safeMessage = message ? escapeHtml(message) : null;
const introHtml = t(lang, 'email.guestInvite.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: t(lang, 'email.guestInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Meeting Invitation</h2>
<p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">${t(lang, 'email.guestInvite.roomLabel')}</p>
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${safeRoomName}</p>
${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeMessage}&quot;</p>` : ''}
</div>
<p style="text-align:center;margin:28px 0;">
<a href="${joinUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
${t(lang, 'email.guestInvite.joinButton')}
</a>
</p>
<p style="font-size:13px;color:#7f849c;">
${t(lang, 'email.linkHint')}<br/>
<a href="${joinUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(joinUrl)}</a>
</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.guestInvite.footer')}</p>
</div>
`,
text: `${t(lang, 'email.guestInvite.intro', { fromUser })}\n${t(lang, 'email.guestInvite.roomLabel')} ${roomName}${message ? `\n"${message}"` : ''}\n\n${t(lang, 'email.guestInvite.joinButton')}: ${joinUrl}\n\n- ${appName}`,
});
}
/** /**
* Send a calendar event invitation email (federated). * Send a calendar event invitation email (federated).
*/ */

View File

@@ -25,6 +25,13 @@
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.", "intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
"roomLabel": "Raum:" "roomLabel": "Raum:"
}, },
"guestInvite": {
"subject": "{appName} - Einladung zu einem Meeting",
"intro": "{fromUser} hat dich zu einem Meeting eingeladen.",
"roomLabel": "Raum:",
"joinButton": "Meeting beitreten",
"footer": "Klicke auf den Button oben, um dem Meeting beizutreten."
},
"calendarInvite": { "calendarInvite": {
"subject": "{appName} - Kalendereinladung von {fromUser}", "subject": "{appName} - Kalendereinladung von {fromUser}",
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten." "intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."

View File

@@ -25,6 +25,13 @@
"intro": "You have received a meeting invitation from {fromUser}.", "intro": "You have received a meeting invitation from {fromUser}.",
"roomLabel": "Room:" "roomLabel": "Room:"
}, },
"guestInvite": {
"subject": "{appName} - You're invited to a meeting",
"intro": "{fromUser} has invited you to a meeting.",
"roomLabel": "Room:",
"joinButton": "Join Meeting",
"footer": "Click the button above to join the meeting."
},
"calendarInvite": { "calendarInvite": {
"subject": "{appName} - Calendar invitation from {fromUser}", "subject": "{appName} - Calendar invitation from {fromUser}",
"intro": "You have received a calendar invitation from {fromUser}." "intro": "You have received a calendar invitation from {fromUser}."

View File

@@ -60,9 +60,10 @@ async function start() {
await initDatabase(); await initDatabase();
initMailer(); initMailer();
// Serve uploaded files (branding only — avatars served via /api/auth/avatar/:filename, presentations require auth) // Serve uploaded files (avatars are served via /api/auth/avatar/:filename)
const uploadsPath = path.join(__dirname, '..', 'uploads'); const uploadsPath = path.join(__dirname, '..', 'uploads');
app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding'))); app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding')));
// Presentations are served via /api/rooms/presentations/:filename?token=… (HMAC-protected)
// API Routes // API Routes
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);

View File

@@ -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; export default router;

View File

@@ -9,11 +9,11 @@ import { createNotification } from '../config/notifications.js';
// M13: rate limit the unauthenticated federation receive endpoint // M13: rate limit the unauthenticated federation receive endpoint
const federationReceiveLimiter = rateLimit({ const federationReceiveLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, max: 100,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
message: { error: 'Too many federation requests. Please try again later.' }, message: { error: 'Too many federation requests. Please try again later.' },
}); });
import { import {
@@ -40,7 +40,7 @@ export function wellKnownHandler(req, res) {
federation_api: '/api/federation', federation_api: '/api/federation',
public_key: getPublicKey(), public_key: getPublicKey(),
software: 'Redlight', software: 'Redlight',
version: '2.1.0', version: '2.1.2',
}); });
} }
@@ -236,24 +236,24 @@ router.post('/receive', federationReceiveLimiter, 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 = getBaseUrl(req); const appUrl = getBaseUrl(req);
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(
targetUser.email, targetUser.name, from_user, targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName, targetUser.language || 'en' room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => { ).catch(mailErr => {
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message); log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
}); });
} }
// In-app notification // In-app notification
await createNotification( await createNotification(
targetUser.id, targetUser.id,
'federation_invite_received', 'federation_invite_received',
from_user, from_user,
room_name, room_name,
'/federation/inbox', '/federation/inbox',
); );
res.json({ success: true }); res.json({ success: true });

View File

@@ -8,6 +8,7 @@ import { getDb } from '../config/database.js';
import { authenticateToken, getBaseUrl } from '../middleware/auth.js'; import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js'; import { createNotification } from '../config/notifications.js';
import { sendGuestInviteEmail } from '../config/mailer.js';
import { import {
createMeeting, createMeeting,
joinMeeting, joinMeeting,
@@ -37,6 +38,17 @@ const __dirname = path.dirname(__filename);
const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations'); const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations');
if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true }); if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true });
const PRESENTATION_TOKEN_SECRET = process.env.BBB_SECRET || crypto.randomBytes(32).toString('hex');
const PRESENTATION_TOKEN_TTL = 60 * 60 * 1000; // 1 hour
function signPresentationUrl(roomUid, filename) {
const expires = Date.now() + PRESENTATION_TOKEN_TTL;
const token = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET)
.update(`${roomUid}/${filename}:${expires}`)
.digest('hex');
return { token, expires };
}
// M8: rate limit unauthenticated guest-join to prevent access_code brute-force // M8: rate limit unauthenticated guest-join to prevent access_code brute-force
const guestJoinLimiter = rateLimit({ const guestJoinLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
@@ -484,9 +496,11 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
const baseUrl = getBaseUrl(req); const baseUrl = getBaseUrl(req);
const loginURL = `${baseUrl}/join/${room.uid}`; const loginURL = `${baseUrl}/join/${room.uid}`;
const presentationUrl = room.presentation_file let presentationUrl = null;
? `${baseUrl}/uploads/presentations/${room.presentation_file}` if (room.presentation_file) {
: null; const { token, expires } = signPresentationUrl(room.uid, room.presentation_file);
presentationUrl = `${baseUrl}/api/rooms/presentations/${token}/${expires}/${room.uid}/${encodeURIComponent(room.presentation_file)}`;
}
const analyticsCallbackURL = room.learning_analytics const analyticsCallbackURL = room.learning_analytics
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}` ? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
: null; : null;
@@ -602,7 +616,7 @@ router.get('/:uid/public', async (req, res) => {
// POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed) // POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed)
router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => { router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
try { try {
const { name, access_code, moderator_code } = req.body; const { name, access_code, moderator_code, avatar_image, avatar_color } = req.body;
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Name is required' }); return res.status(400).json({ error: 'Name is required' });
@@ -648,7 +662,17 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
} }
const baseUrl = getBaseUrl(req); const baseUrl = getBaseUrl(req);
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`; let guestAvatarURL;
if (avatar_image) {
// Use avatar image of the logged-in user
guestAvatarURL = `${baseUrl}/api/auth/avatar/${avatar_image}`;
} else if (avatar_color) {
// Initials with user color
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}?color=${encodeURIComponent(avatar_color)}`;
} else {
// Default: initials without color
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
}
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL); const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
res.json({ joinUrl }); res.json({ joinUrl });
} catch (err) { } catch (err) {
@@ -679,6 +703,43 @@ router.get('/:uid/status', async (req, res) => {
} }
}); });
// GET /api/rooms/presentations/:token/:expires/:roomUid/:filename - Serve presentation file (token-protected for BBB)
// Token and expires are path segments so the URL ends with the original filename,
// allowing BBB to detect the file type from the extension.
router.get('/presentations/:token/:expires/:roomUid/:filename', (req, res) => {
const { token, expires, roomUid, filename } = req.params;
if (!token || !expires) {
return res.status(401).json({ error: 'Missing token' });
}
const expiresNum = Number(expires);
if (isNaN(expiresNum) || Date.now() > expiresNum) {
return res.status(403).json({ error: 'Token expired' });
}
const expected = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET)
.update(`${roomUid}/${filename}:${expires}`)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected))) {
return res.status(403).json({ error: 'Invalid token' });
}
// S8: prevent path traversal
const roomDir = path.resolve(presentationsDir, roomUid);
const filepath = path.resolve(roomDir, filename);
if (!filepath.startsWith(presentationsDir + path.sep) || !filepath.startsWith(roomDir + path.sep)) {
return res.status(400).json({ error: 'Invalid filename' });
}
if (!fs.existsSync(filepath)) {
return res.status(404).json({ error: 'File not found' });
}
res.sendFile(filepath);
});
// POST /api/rooms/:uid/presentation - Upload a presentation file for the room // POST /api/rooms/:uid/presentation - Upload a presentation file for the room
router.post('/:uid/presentation', authenticateToken, async (req, res) => { router.post('/:uid/presentation', authenticateToken, async (req, res) => {
try { try {
@@ -733,22 +794,28 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
// Preserve original filename (sent as X-Filename header) // Preserve original filename (sent as X-Filename header)
const rawName = req.headers['x-filename']; const rawName = req.headers['x-filename'];
const originalName = rawName const filename = rawName
? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200) ? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200)
: `presentation.${ext}`; : `presentation.${ext}`;
const filename = `${room.uid}_${Date.now()}.${ext}`; // Each room gets its own folder: uploads/presentations/{roomUID}/
const filepath = path.join(presentationsDir, filename); const roomDir = path.join(presentationsDir, room.uid);
if (!fs.existsSync(roomDir)) fs.mkdirSync(roomDir, { recursive: true });
const filepath = path.join(roomDir, filename);
// S8: defense-in-depth path traversal check
if (!path.resolve(filepath).startsWith(roomDir + path.sep)) {
return res.status(400).json({ error: 'Invalid filename' });
}
// Remove old presentation file if exists // Remove old presentation file if exists
if (room.presentation_file) { if (room.presentation_file) {
// S8: defense-in-depth path traversal check const oldPath = path.resolve(roomDir, room.presentation_file);
const oldPath = path.resolve(presentationsDir, room.presentation_file); if (oldPath.startsWith(roomDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
if (oldPath.startsWith(presentationsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
} }
fs.writeFileSync(filepath, buffer); fs.writeFileSync(filepath, buffer);
await db.run('UPDATE rooms SET presentation_file = ?, presentation_name = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, originalName, req.params.uid]); 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]); const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ room: updated }); res.json({ room: updated });
} catch (err) { } catch (err) {
@@ -766,11 +833,14 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
if (room.presentation_file) { if (room.presentation_file) {
// S8: defense-in-depth path traversal check // S8: defense-in-depth path traversal check
const filepath = path.resolve(presentationsDir, room.presentation_file); const roomDir = path.join(presentationsDir, room.uid);
if (filepath.startsWith(presentationsDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath); const filepath = path.resolve(roomDir, room.presentation_file);
if (filepath.startsWith(roomDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath);
// Remove empty room folder
if (fs.existsSync(roomDir) && fs.readdirSync(roomDir).length === 0) fs.rmdirSync(roomDir);
} }
await db.run('UPDATE rooms SET presentation_file = NULL, presentation_name = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]); 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]); const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ room: updated }); res.json({ room: updated });
} catch (err) { } catch (err) {
@@ -779,4 +849,76 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
} }
}); });
// ── POST /api/rooms/invite-email — Send email invitation to guest(s) ────────
router.post('/invite-email', authenticateToken, async (req, res) => {
try {
const { room_uid, emails, message } = req.body;
if (!room_uid || !emails || !emails.length) {
return res.status(400).json({ error: 'room_uid and emails are required' });
}
if (emails.length > 50) {
return res.status(400).json({ error: 'Maximum 50 email addresses allowed' });
}
// Validate all emails
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const email of emails) {
if (!emailRegex.test(email) || email.length > 254) {
return res.status(400).json({ error: `Invalid email address: ${email}` });
}
}
if (message && message.length > 2000) {
return res.status(400).json({ error: 'Message must not exceed 2000 characters' });
}
const db = getDb();
// Verify room exists and user has access
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [room_uid]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
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: 'No permission to invite from this room' });
}
}
// Build guest join URL
const baseUrl = getBaseUrl(req);
const joinUrl = room.access_code
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
: `${baseUrl}/join/${room.uid}`;
const appName = process.env.APP_NAME || 'Redlight';
const fromUser = req.user.display_name || req.user.name;
const lang = req.user.language || 'en';
// Send emails (in parallel but collect errors)
const results = await Promise.allSettled(
emails.map(email =>
sendGuestInviteEmail(email, fromUser, room.name, message || null, joinUrl, appName, lang)
)
);
const failed = results.filter(r => r.status === 'rejected');
if (failed.length === emails.length) {
return res.status(500).json({ error: 'Failed to send all email invitations' });
}
if (failed.length > 0) {
log.rooms.warn(`${failed.length}/${emails.length} email invitations failed`);
}
res.json({ success: true, sent: emails.length - failed.length, failed: failed.length });
} catch (err) {
log.rooms.error('Email invite error:', err);
res.status(500).json({ error: err.message || 'Failed to send email invitations' });
}
});
export default router; export default router;

View File

@@ -122,9 +122,9 @@ export default function RecordingList({ recordings, onRefresh }) {
href={format.url} href={format.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-th-accent/10 text-th-accent text-xs font-medium hover:bg-th-accent/20 transition-colors" className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-th-accent/10 text-th-accent text-sm font-medium hover:bg-th-accent/20 transition-colors"
> >
<Play size={12} /> <Play size={14} />
{format.type === 'presentation' ? t('recordings.presentation') : format.type} {format.type === 'presentation' ? t('recordings.presentation') : format.type}
</a> </a>
))} ))}

View File

@@ -484,7 +484,21 @@
"oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.", "oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.",
"oauthNotConfigured": "OAuth ist noch nicht konfiguriert.", "oauthNotConfigured": "OAuth ist noch nicht konfiguriert.",
"oauthSave": "OAuth speichern", "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",
"showAllRooms": "Alle {count} Räume anzeigen"
}, },
"notifications": { "notifications": {
"bell": "Benachrichtigungen", "bell": "Benachrichtigungen",
@@ -500,14 +514,19 @@
"inbox": "Einladungen", "inbox": "Einladungen",
"inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen", "inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen",
"inviteTitle": "Remote-Benutzer einladen", "inviteTitle": "Remote-Benutzer einladen",
"inviteSubtitle": "Einen Benutzer von einer anderen Redlight-Instanz zu diesem Meeting einladen.", "inviteSubtitle": "Du kannst entweder einen Benutzer von einer anderen Redlight-Instanz über seine Adresse einladen oder direkt eine E-Mail-Einladung senden. Es kann nur eine Option gleichzeitig verwendet werden.",
"addressLabel": "Benutzeradresse", "addressLabel": "Benutzeradresse",
"addressPlaceholder": "@benutzer@andere-instanz.com", "addressPlaceholder": "@benutzer@andere-instanz.com",
"addressHint": "Format: @Benutzername@Domain der Redlight-Instanz", "addressHint": "Format: @Benutzername@Domain der Redlight-Instanz",
"emailLabel": "Per E-Mail einladen",
"emailPlaceholder": "name@beispiel.de, name2@beispiel.de",
"emailHint": "Eine oder mehrere E-Mail-Adressen, durch Komma getrennt",
"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",
"sent": "Einladung gesendet!", "sent": "Einladung gesendet!",
"emailSent": "E-Mail-Einladung(en) gesendet!",
"emailSendFailed": "E-Mail-Einladung konnte nicht gesendet werden",
"sendFailed": "Einladung konnte nicht gesendet werden", "sendFailed": "Einladung konnte nicht gesendet werden",
"from": "Von", "from": "Von",
"accept": "Annehmen", "accept": "Annehmen",

View File

@@ -484,7 +484,21 @@
"oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.", "oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.",
"oauthNotConfigured": "OAuth is not configured yet.", "oauthNotConfigured": "OAuth is not configured yet.",
"oauthSave": "Save OAuth", "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",
"showAllRooms": "Show all {count} rooms"
}, },
"notifications": { "notifications": {
"bell": "Notifications", "bell": "Notifications",
@@ -500,14 +514,19 @@
"inbox": "Invitations", "inbox": "Invitations",
"inboxSubtitle": "Meeting invitations from other Redlight instances", "inboxSubtitle": "Meeting invitations from other Redlight instances",
"inviteTitle": "Invite Remote User", "inviteTitle": "Invite Remote User",
"inviteSubtitle": "Invite a user from another Redlight instance to this meeting.", "inviteSubtitle": "You can either invite a user from another Redlight instance by their address, or send an email invitation directly. Only one option can be used at a time.",
"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",
"emailLabel": "Invite by email",
"emailPlaceholder": "name@example.com, name2@example.com",
"emailHint": "Enter one or more email addresses, separated by commas",
"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",
"sent": "Invitation sent!", "sent": "Invitation sent!",
"emailSent": "Email invitation(s) sent!",
"emailSendFailed": "Could not send email invitation",
"sendFailed": "Could not send invitation", "sendFailed": "Could not send invitation",
"from": "From", "from": "From",
"accept": "Accept", "accept": "Accept",

View File

@@ -4,7 +4,7 @@ import {
Users, Shield, Search, Trash2, ChevronDown, Loader2, Users, Shield, Search, Trash2, ChevronDown, Loader2,
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User, MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check, 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'; } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
@@ -55,6 +55,14 @@ export default function Admin() {
const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true }); const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
const [savingOauth, setSavingOauth] = useState(false); const [savingOauth, setSavingOauth] = useState(false);
// Rooms state
const [adminRooms, setAdminRooms] = useState([]);
const [adminRoomsLoading, setAdminRoomsLoading] = useState(true);
const [roomSearch, setRoomSearch] = useState('');
const [roomsExpanded, setRoomsExpanded] = useState(false);
const [showAllRoomsModal, setShowAllRoomsModal] = useState(false);
const [allRoomsSearch, setAllRoomsSearch] = useState('');
useEffect(() => { useEffect(() => {
if (user?.role !== 'admin') { if (user?.role !== 'admin') {
navigate('/dashboard'); navigate('/dashboard');
@@ -63,6 +71,7 @@ export default function Admin() {
fetchUsers(); fetchUsers();
fetchInvites(); fetchInvites();
fetchOauthConfig(); fetchOauthConfig();
fetchAdminRooms();
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
@@ -101,6 +110,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) => { const handleRoleChange = async (userId, newRole) => {
try { try {
await api.put(`/admin/users/${userId}/role`, { role: newRole }); await api.put(`/admin/users/${userId}/role`, { role: newRole });
@@ -790,6 +822,122 @@ export default function Admin() {
)} )}
</div> </div>
{/* Room Management */}
<div className="card p-6 mb-8">
<button
type="button"
onClick={() => setRoomsExpanded(v => !v)}
className="flex items-center justify-between w-full text-left"
>
<div className="flex items-center gap-2">
<DoorOpen size={20} className="text-th-accent" />
<h2 className="text-lg font-semibold text-th-text">{t('admin.roomsTitle')}</h2>
<span className="text-sm text-th-text-s">({adminRooms.length})</span>
</div>
<ChevronDown size={18} className={`text-th-text-s transition-transform duration-200 ${roomsExpanded ? 'rotate-180' : ''}`} />
</button>
{roomsExpanded && (
<div className="mt-4">
<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>
) : (
<>
{adminRooms.length > 0 && (
<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.slice(0, 10).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 > 10 && (
<div className="mt-4 text-center">
<button
onClick={() => { setAllRoomsSearch(''); setShowAllRoomsModal(true); }}
className="btn-secondary text-sm"
>
{t('admin.showAllRooms', { count: adminRooms.length })}
</button>
</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>
)}
</div>
{/* Search */} {/* Search */}
<div className="card p-4 mb-6"> <div className="card p-4 mb-6">
<div className="relative"> <div className="relative">
@@ -1067,6 +1215,119 @@ export default function Admin() {
</div> </div>
</div> </div>
)} )}
{/* All rooms modal */}
{showAllRoomsModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowAllRoomsModal(false)} />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-th-border">
<div className="flex items-center gap-2">
<DoorOpen size={20} className="text-th-accent" />
<h3 className="text-lg font-semibold text-th-text">{t('admin.roomsTitle')}</h3>
<span className="text-sm text-th-text-s">({adminRooms.length})</span>
</div>
<button onClick={() => setShowAllRoomsModal(false)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors">
<XIcon size={18} />
</button>
</div>
<div className="p-4 border-b border-th-border">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={allRoomsSearch}
onChange={e => setAllRoomsSearch(e.target.value)}
className="input-field pl-9 text-sm"
placeholder={t('admin.searchRooms')}
autoFocus
/>
</div>
</div>
<div className="overflow-y-auto flex-1 p-0">
<table className="w-full">
<thead className="sticky top-0 bg-th-card z-10">
<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(allRoomsSearch.toLowerCase()) ||
r.owner_name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
r.uid.toLowerCase().includes(allRoomsSearch.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={() => { setShowAllRoomsModal(false); 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>
{adminRooms.filter(r =>
r.name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
r.owner_name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
r.uid.toLowerCase().includes(allRoomsSearch.toLowerCase())
).length === 0 && (
<div className="text-center py-12">
<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>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useParams, Link, useSearchParams } from 'react-router-dom'; import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom';
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react'; import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react';
import BrandLogo from '../components/BrandLogo'; import BrandLogo from '../components/BrandLogo';
import api from '../services/api'; import api from '../services/api';
@@ -11,6 +11,7 @@ import { useBranding } from '../contexts/BrandingContext';
export default function GuestJoin() { export default function GuestJoin() {
const { uid } = useParams(); const { uid } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { t } = useLanguage(); const { t } = useLanguage();
const { user } = useAuth(); const { user } = useAuth();
const { imprintUrl, privacyUrl } = useBranding(); const { imprintUrl, privacyUrl } = useBranding();
@@ -19,7 +20,7 @@ export default function GuestJoin() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [joining, setJoining] = useState(false); const [joining, setJoining] = useState(false);
const [name, setName] = useState(user?.name || ''); const [name, setName] = useState(user?.display_name || user?.name || '');
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || ''); const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
const [moderatorCode, setModeratorCode] = useState(''); const [moderatorCode, setModeratorCode] = useState('');
const [status, setStatus] = useState({ running: false }); const [status, setStatus] = useState({ running: false });
@@ -30,11 +31,17 @@ export default function GuestJoin() {
const joinMeeting = async () => { const joinMeeting = async () => {
setJoining(true); setJoining(true);
try { try {
const res = await api.post(`/rooms/${uid}/guest-join`, { const payload = {
name: name.trim(), name: name.trim(),
access_code: accessCode || undefined, access_code: accessCode || undefined,
moderator_code: moderatorCode || undefined, moderator_code: moderatorCode || undefined,
}); };
// If logged in, send avatar data
if (isLoggedIn && user) {
if (user.avatar_image) payload.avatar_image = user.avatar_image;
if (user.avatar_color) payload.avatar_color = user.avatar_color;
}
const res = await api.post(`/rooms/${uid}/guest-join`, payload);
if (res.data.joinUrl) { if (res.data.joinUrl) {
window.location.href = res.data.joinUrl; window.location.href = res.data.joinUrl;
} }
@@ -54,6 +61,17 @@ export default function GuestJoin() {
useEffect(() => { useEffect(() => {
const fetchRoom = async () => { const fetchRoom = async () => {
// If logged in, check if user owns or has access to this room
if (isLoggedIn) {
try {
await api.get(`/rooms/${uid}`);
navigate(`/rooms/${uid}`, { replace: true });
return;
} catch {
// User doesn't have access — continue as guest
}
}
try { try {
const res = await api.get(`/rooms/${uid}/public`); const res = await api.get(`/rooms/${uid}/public`);
setRoomInfo(res.data.room); setRoomInfo(res.data.room);
@@ -89,7 +107,7 @@ export default function GuestJoin() {
// Auto-join when meeting starts while waiting // Auto-join when meeting starts while waiting
useEffect(() => { useEffect(() => {
if (!prevRunningRef.current && status.running && waiting) { if (!prevRunningRef.current && status.running && waiting) {
new Audio('/sounds/meeting-started.mp3').play().catch(() => {}); new Audio('/sounds/meeting-started.mp3').play().catch(() => { });
toast.success(t('room.guestMeetingStartedJoining')); toast.success(t('room.guestMeetingStartedJoining'));
joinMeeting(); joinMeeting();
} }
@@ -106,7 +124,7 @@ export default function GuestJoin() {
toast.error(t('room.guestRecordingConsent')); toast.error(t('room.guestRecordingConsent'));
return; return;
} }
if (!status.running) { if (!status.running && !roomInfo?.anyone_can_start) {
setWaiting(true); setWaiting(true);
return; return;
} }
@@ -210,97 +228,97 @@ export default function GuestJoin() {
)} )}
</div> </div>
) : ( ) : (
<form onSubmit={handleJoin} className="space-y-4"> <form onSubmit={handleJoin} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={name}
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={!isLoggedIn}
/>
</div>
</div>
{roomInfo.has_access_code && (
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label> <label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
<div className="relative"> <div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> <User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input <input
type="text" type="text"
value={accessCode} value={name}
onChange={e => setAccessCode(e.target.value)} onChange={e => !isLoggedIn && setName(e.target.value)}
className="input-field pl-11" readOnly={isLoggedIn}
placeholder={t('room.guestAccessCodePlaceholder')} className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`}
placeholder={t('room.guestNamePlaceholder')}
required
autoFocus={!isLoggedIn}
/> />
</div> </div>
</div> </div>
)}
<div> {roomInfo.has_access_code && (
<label className="block text-sm font-medium text-th-text mb-1.5"> <div>
{t('room.guestModeratorCode')} <label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span> <div className="relative">
</label> <Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<div className="relative"> <input
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" /> type="text"
<input value={accessCode}
type="text" onChange={e => setAccessCode(e.target.value)}
value={moderatorCode} className="input-field pl-11"
onChange={e => setModeratorCode(e.target.value)} placeholder={t('room.guestAccessCodePlaceholder')}
className="input-field pl-11" />
placeholder={t('room.guestModeratorPlaceholder')} </div>
/>
</div>
</div>
{/* Recording consent notice */}
{roomInfo.allow_recording && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
</div> </div>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={recordingConsent}
onChange={e => setRecordingConsent(e.target.checked)}
className="w-4 h-4 rounded accent-amber-500 cursor-pointer"
/>
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
</label>
</div>
)}
<button
type="submit"
disabled={joining || (roomInfo.allow_recording && !recordingConsent)}
className="btn-primary w-full py-3"
>
{joining ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('room.guestJoinButton')}
<ArrowRight size={18} />
</>
)} )}
</button>
{!status.running && ( <div>
<p className="text-xs text-th-text-s text-center"> <label className="block text-sm font-medium text-th-text mb-1.5">
{t('room.guestWaitingMessage')} {t('room.guestModeratorCode')}
</p> <span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
)} </label>
</form> <div className="relative">
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={moderatorCode}
onChange={e => setModeratorCode(e.target.value)}
className="input-field pl-11"
placeholder={t('room.guestModeratorPlaceholder')}
/>
</div>
</div>
{/* Recording consent notice */}
{roomInfo.allow_recording && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
</div>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={recordingConsent}
onChange={e => setRecordingConsent(e.target.checked)}
className="w-4 h-4 rounded accent-amber-500 cursor-pointer"
/>
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
</label>
</div>
)}
<button
type="submit"
disabled={joining || (roomInfo.allow_recording && !recordingConsent)}
className="btn-primary w-full py-3"
>
{joining ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('room.guestJoinButton')}
<ArrowRight size={18} />
</>
)}
</button>
{!status.running && !roomInfo?.anyone_can_start && (
<p className="text-xs text-th-text-s text-center">
{t('room.guestWaitingMessage')}
</p>
)}
</form>
)} )}
{!isLoggedIn && ( {!isLoggedIn && (

View File

@@ -51,6 +51,7 @@ export default function RoomDetail() {
// Federation invite state // Federation invite state
const [showFedInvite, setShowFedInvite] = useState(false); const [showFedInvite, setShowFedInvite] = useState(false);
const [fedAddress, setFedAddress] = useState(''); const [fedAddress, setFedAddress] = useState('');
const [fedEmails, setFedEmails] = useState('');
const [fedMessage, setFedMessage] = useState(''); const [fedMessage, setFedMessage] = useState('');
const [fedSending, setFedSending] = useState(false); const [fedSending, setFedSending] = useState(false);
@@ -266,25 +267,51 @@ export default function RoomDetail() {
const handleFedInvite = async (e) => { const handleFedInvite = async (e) => {
e.preventDefault(); e.preventDefault();
// Accept @user@domain or user@domain — must have a domain part const hasAddress = fedAddress.trim().length > 0;
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress; const hasEmails = fedEmails.trim().length > 0;
if (!normalized.includes('@') || normalized.endsWith('@')) {
if (!hasAddress && !hasEmails) {
toast.error(t('federation.addressHint')); toast.error(t('federation.addressHint'));
return; return;
} }
setFedSending(true); setFedSending(true);
try { try {
await api.post('/federation/invite', { if (hasAddress) {
room_uid: uid, // Federation address mode
to: fedAddress, const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
message: fedMessage || undefined, if (!normalized.includes('@') || normalized.endsWith('@')) {
}); toast.error(t('federation.addressHint'));
toast.success(t('federation.sent')); setFedSending(false);
return;
}
await api.post('/federation/invite', {
room_uid: uid,
to: fedAddress,
message: fedMessage || undefined,
});
toast.success(t('federation.sent'));
} else {
// Email mode
const emailList = fedEmails.split(',').map(e => e.trim()).filter(Boolean);
if (emailList.length === 0) {
toast.error(t('federation.emailHint'));
setFedSending(false);
return;
}
await api.post('/rooms/invite-email', {
room_uid: uid,
emails: emailList,
message: fedMessage || undefined,
});
toast.success(t('federation.emailSent'));
}
setShowFedInvite(false); setShowFedInvite(false);
setFedAddress(''); setFedAddress('');
setFedEmails('');
setFedMessage(''); setFedMessage('');
} catch (err) { } catch (err) {
toast.error(err.response?.data?.error || t('federation.sendFailed')); toast.error(err.response?.data?.error || t(hasAddress ? 'federation.sendFailed' : 'federation.emailSendFailed'));
} finally { } finally {
setFedSending(false); setFedSending(false);
} }
@@ -717,7 +744,7 @@ export default function RoomDetail() {
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p> <p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
<p className="text-sm text-th-text font-medium truncate"> <p className="text-sm text-th-text font-medium truncate">
{room.presentation_name || `presentation.${room.presentation_file?.split('.').pop()}`} {room.presentation_file}
</p> </p>
</div> </div>
</div> </div>
@@ -857,13 +884,33 @@ export default function RoomDetail() {
<input <input
type="text" type="text"
value={fedAddress} value={fedAddress}
onChange={e => setFedAddress(e.target.value)} onChange={e => { setFedAddress(e.target.value); if (e.target.value) setFedEmails(''); }}
className="input-field" className="input-field"
placeholder={t('federation.addressPlaceholder')} placeholder={t('federation.addressPlaceholder')}
required disabled={fedEmails.trim().length > 0}
/> />
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p> <p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
</div> </div>
<div className="flex items-center gap-3 my-2">
<div className="flex-1 border-t border-th-border" />
<span className="text-xs text-th-text-s uppercase">{t('common.or')}</span>
<div className="flex-1 border-t border-th-border" />
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.emailLabel')}</label>
<input
type="text"
value={fedEmails}
onChange={e => { setFedEmails(e.target.value); if (e.target.value) setFedAddress(''); }}
className="input-field"
placeholder={t('federation.emailPlaceholder')}
disabled={fedAddress.trim().length > 0}
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.emailHint')}</p>
</div>
<div> <div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label> <label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
<textarea <textarea
@@ -878,7 +925,7 @@ export default function RoomDetail() {
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1"> <button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button type="submit" disabled={fedSending} className="btn-primary flex-1"> <button type="submit" disabled={fedSending || (!fedAddress.trim() && !fedEmails.trim())} className="btn-primary flex-1">
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />} {fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
{t('federation.send')} {t('federation.send')}
</button> </button>