30 Commits
Author SHA1 Message Date
Michelle 24024cd1dd chore: update version to 2.1.3 in package.json and federation.js
Build & Push Docker Image / build (push) Successful in 4m18s
Build & Push Docker Image / build (release) Successful in 4m18s
2026-05-18 13:17:02 +02:00
Michelle 4028e913c4 refactor: update class names for consistency and improve styling
Build & Push Docker Image / build (push) Successful in 4m21s
- Changed `flex-shrink-0` to `shrink-0` in multiple components for better consistency.
- Updated button and checkbox classes to use `rounded-sm` for a more uniform appearance.
- Adjusted backdrop blur classes for modals to `backdrop-blur-xs` for a subtler effect.
- Removed unused Tailwind CSS configuration file.
2026-05-18 13:07:26 +02:00
Michelle aba7819f12 chore: update dependencies and versions in package-lock.json
Build & Push Docker Image / build (push) Successful in 4m5s
2026-04-25 20:34:23 +02:00
Michelle 45fdbe4883 feat: enforce maximum password length of 64 characters in user registration and password update
Build & Push Docker Image / build (push) Successful in 4m19s
2026-04-25 20:30:29 +02:00
Michelle de696d422a fix: anyone_can_start not working as Admin
Build & Push Docker Image / build (push) Successful in 4m24s
2026-04-23 09:32:55 +02:00
Michelle 995d6eabf7 feat: remove unnecessary footer text from home page
Build & Push Docker Image / build (push) Successful in 4m19s
2026-04-09 14:01:01 +02:00
Michelle 9ee4f84d84 feat: update home page features with federation, calendar, notifications, OAuth, and analytics support
Build & Push Docker Image / build (push) Successful in 4m24s
2026-04-09 13:52:33 +02:00
Michelle df1aa20e45 feat: add recordings table and implement fetch and sync functionality for recordings
Build & Push Docker Image / build (push) Successful in 4m18s
2026-04-08 00:10:25 +02:00
Michelle e0ce354eda feat: improve email invitation label for clarity and add navigation on guest join
Build & Push Docker Image / build (push) Successful in 4m19s
2026-04-02 01:03:18 +02:00
Michelle 1690a74c19 feat: add email invitation functionality for guests with support for multiple addresses
Build & Push Docker Image / build (push) Successful in 4m21s
2026-04-02 00:54:57 +02:00
Michelle 61585d8c63 feat: add functionality to display all rooms with search and modal support in admin panel
Build & Push Docker Image / build (push) Successful in 4m17s
2026-04-01 12:05:51 +02:00
Michelle d04793148a feat: add room management functionality for admins with listing and deletion options
Build & Push Docker Image / build (push) Successful in 4m12s
2026-04-01 11:54:10 +02:00
Michelle 9bf4228d04 fix: update presentation display to show filename instead of name
Build & Push Docker Image / build (push) Successful in 4m21s
2026-04-01 11:34:48 +02:00
Michelle c058ba3bf1 fix: update presentation URL structure for token protection
Build & Push Docker Image / build (push) Successful in 4m13s
2026-04-01 09:43:32 +02:00
Michelle b3b559e164 feat: implement token-based access for presentation files and add serving endpoint
Build & Push Docker Image / build (push) Successful in 4m43s
2026-04-01 08:52:43 +02:00
Michelle 0db9227c20 chore: update package-lock.json with dependency version bumps
Build & Push Docker Image / build (push) Successful in 4m18s
2026-03-31 13:11:56 +02:00
Michelle 30f106a7ea fix: guest join doesn't add avatar when logged in
Build & Push Docker Image / build (push) Successful in 4m27s
2026-03-31 09:36:13 +02:00
Michelle 8cbe28f915 chore: bump version to 2.1.2 and update user name handling in GuestJoin component
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
Michelle 5472e190d9 chore: Bump version to 2.1.1
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
Michelle 45be976de1 Don't show guestWaitingMessage when "anyone_can_start" is set
Build & Push Docker Image / build (push) Successful in 4m14s
2026-03-25 10:13:02 +01:00
Michelle 6dcb1e959b feat: allow guests to start a room if anyone_can_start is enabled
Build & Push Docker Image / build (push) Successful in 4m58s
2026-03-25 09:55:47 +01:00
Michelle bb2d179871 style: Update button styling and icon size in RecordingList component for improved UI
Build & Push Docker Image / build (push) Successful in 4m29s
2026-03-24 11:28:15 +01:00
Michelle 82b7d060ba Merge remote-tracking branch 'refs/remotes/origin/main'
Build & Push Docker Image / build (push) Successful in 4m34s
2026-03-16 13:32:45 +01:00
Michelle 0836436fe7 feat: Implement Two-Factor Authentication (2FA) for enhanced user account security. 2026-03-16 13:28:43 +01:00
Michelle 99d3b22f62 chore: update bcryptjs and better-sqlite3 dependencies; upgrade dotenv version
Build & Push Docker Image / build (release) Successful in 4m5s
Build & Push Docker Image / build (push) Successful in 3m59s
2026-03-13 23:00:38 +01:00
Michelle eed5d98ccc chore: update dependencies for Vite and React plugin
Build & Push Docker Image / build (push) Successful in 4m9s
2026-03-13 22:48:08 +01:00
Michelle 6513fdee41 fix(Dockerfile): update base image and streamline build stages
Build & Push Docker Image / build (push) Successful in 4m17s
2026-03-13 22:41:18 +01:00
Michelle cae84754e4 feat: add analytics visibility settings and export functionality
Build & Push Docker Image / build (push) Successful in 5m11s
- Added `analytics_visibility` column to `rooms` table to control who can view analytics data.
- Updated analytics routes to check visibility settings before allowing access and export of analytics data.
- Implemented export functionality for analytics in CSV, XLSX, and PDF formats.
- Enhanced `AnalyticsList` component to include export options for analytics entries.
- Updated room detail page to allow setting analytics visibility when creating or editing rooms.
- Added translations for new analytics visibility options and export messages.
2026-03-13 22:36:07 +01:00
Michelle a0a972b53a fix(NotificationContext): ensure audio playback is unlocked only for authenticated users
Build & Push Docker Image / build (push) Successful in 6m32s
2026-03-13 13:00:54 +01:00
Michelle 9b98803053 fix(NotificationContext): handle user ID for notifications fetching and prevent stale responses
Build & Push Docker Image / build (push) Successful in 6m6s
2026-03-13 12:43:20 +01:00
46 changed files with 4843 additions and 3034 deletions
+15 -12
View File
@@ -1,28 +1,31 @@
# ── Stage 1: Build frontend ──────────────────────────────────────────────────
FROM node:20-bullseye-slim AS builder
# ── Stage 1: Install dependencies ────────────────────────────────────────────
FROM node:22-trixie-slim AS deps
WORKDIR /app
# Install build tools and sqlite headers for native modules
ENV DEBIAN_FRONTEND=noninteractive
# Install build tools for native modules (better-sqlite3, pdfkit)
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 build-essential libsqlite3-dev ca-certificates \
python3 build-essential libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./
# Install all dependencies (including dev for vite build)
RUN npm ci
# ── Stage 2: Build frontend ─────────────────────────────────────────────────
FROM deps AS builder
COPY . .
RUN npm run build
# Produce production node_modules (compile native modules here for the target arch)
RUN npm ci --omit=dev && npm cache clean --force
# ── Stage 2: Production image ───────────────────────────────────────────────
# Prune dev dependencies in-place (avoids a second npm ci)
RUN npm prune --omit=dev
FROM node:20-bullseye-slim
# Allow forcing build from source (useful when prebuilt binaries are not available)
ARG BUILD_FROM_SOURCE=false
ENV npm_config_build_from_source=${BUILD_FROM_SOURCE}
# ── Stage 3: Production image ───────────────────────────────────────────────
FROM node:22-trixie-slim
WORKDIR /app
+2491 -2434
View File
File diff suppressed because it is too large Load Diff
+22 -18
View File
@@ -1,7 +1,7 @@
{
"name": "redlight",
"private": true,
"version": "2.0.0",
"version": "2.1.3",
"license": "GPL-3.0-or-later",
"type": "module",
"scripts": {
@@ -14,35 +14,39 @@
},
"dependencies": {
"axios": "^1.7.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.0.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2",
"concurrently": "^9.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"express": "^4.21.0",
"express-rate-limit": "^7.5.1",
"dotenv": "^17.3.1",
"exceljs": "^4.4.0",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"flatpickr": "^4.6.13",
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.0",
"lucide-react": "^0.460.0",
"lucide-react": "^1.16.0",
"multer": "^2.1.0",
"nodemailer": "^8.0.1",
"otpauth": "^9.5.0",
"pdfkit": "^0.18.0",
"pg": "^8.18.0",
"rate-limit-redis": "^4.3.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"qrcode": "^1.5.4",
"rate-limit-redis": "^5.0.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hot-toast": "^2.4.0",
"react-router-dom": "^6.28.0",
"uuid": "^13.0.0",
"react-router-dom": "^7.15.1",
"uuid": "^14.0.0",
"xml2js": "^0.6.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.0",
"@tailwindcss/postcss": "^4.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"vite": "^6.4.1"
"tailwindcss": "^4.3.0",
"vite": "^8.0.0"
}
}
+1 -2
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
'@tailwindcss/postcss': {},
},
};
+59 -1
View File
@@ -1,4 +1,4 @@
import bcrypt from 'bcryptjs';
import bcrypt from 'bcryptjs';
import path from 'path';
import { fileURLToPath } from 'url';
import { log } from './logger.js';
@@ -802,6 +802,64 @@ export async function initDatabase() {
`);
}
// ── TOTP 2FA columns ──────────────────────────────────────────────────────
if (!(await db.columnExists('users', 'totp_secret'))) {
await db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT DEFAULT NULL');
}
if (!(await db.columnExists('users', 'totp_enabled'))) {
await db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0');
}
// ── Analytics visibility setting ────────────────────────────────────────
if (!(await db.columnExists('rooms', 'analytics_visibility'))) {
await db.exec("ALTER TABLE rooms ADD COLUMN analytics_visibility TEXT DEFAULT 'owner'");
}
// ── Recordings cache table ────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS recordings (
id SERIAL PRIMARY KEY,
record_id TEXT UNIQUE NOT NULL,
meeting_id TEXT NOT NULL,
name TEXT,
state TEXT,
published INTEGER DEFAULT 1,
start_time TEXT,
end_time TEXT,
participants TEXT,
size TEXT,
formats TEXT NOT NULL DEFAULT '[]',
metadata TEXT DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_recordings_record_id ON recordings(record_id);
CREATE INDEX IF NOT EXISTS idx_recordings_meeting_id ON recordings(meeting_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_id TEXT UNIQUE NOT NULL,
meeting_id TEXT NOT NULL,
name TEXT,
state TEXT,
published INTEGER DEFAULT 1,
start_time TEXT,
end_time TEXT,
participants TEXT,
size TEXT,
formats TEXT NOT NULL DEFAULT '[]',
metadata TEXT DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_recordings_record_id ON recordings(record_id);
CREATE INDEX IF NOT EXISTS idx_recordings_meeting_id ON recordings(meeting_id);
`);
}
// ── Default admin (only on very first start) ────────────────────────────
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
if (!adminAlreadySeeded) {
+55
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).
*/
+7
View File
@@ -25,6 +25,13 @@
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
"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": {
"subject": "{appName} - Kalendereinladung von {fromUser}",
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
+7
View File
@@ -25,6 +25,13 @@
"intro": "You have received a meeting invitation from {fromUser}.",
"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": {
"subject": "{appName} - Calendar invitation from {fromUser}",
"intro": "You have received a calendar invitation from {fromUser}."
+3 -2
View File
@@ -60,9 +60,10 @@ async function start() {
await initDatabase();
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');
app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding')));
// Presentations are served via /api/rooms/presentations/:filename?token=… (HMAC-protected)
// API Routes
app.use('/api/auth', authRoutes);
@@ -99,7 +100,7 @@ async function start() {
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'dist')));
app.get('*', (req, res) => {
app.get('/*splat', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
});
}
+1 -1
View File
@@ -35,7 +35,7 @@ export async function authenticateToken(req, res, next) {
}
const db = getDb();
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified, oauth_provider 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, oauth_provider, totp_enabled FROM users WHERE id = ?', [decoded.userId]);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
+30
View File
@@ -38,6 +38,9 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
}
if (password.length > 64) {
return res.status(400).json({ error: 'Password must not exceed 64 characters' });
}
// M9: email format validation
if (!EMAIL_RE.test(email)) {
@@ -160,6 +163,9 @@ router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, r
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
}
if (newPassword.length > 64) {
return res.status(400).json({ error: 'Password must not exceed 64 characters' });
}
const db = getDb();
const hash = await bcrypt.hash(newPassword, 12);
@@ -362,4 +368,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;
+192 -4
View File
@@ -1,5 +1,7 @@
import { Router } from 'express';
import crypto from 'crypto';
import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import { log } from '../config/logger.js';
@@ -13,8 +15,10 @@ router.post('/callback/:uid', async (req, res) => {
const { token } = req.query;
const expectedToken = getAnalyticsToken(req.params.uid);
// Constant-time comparison to prevent timing attacks
if (!token || token.length !== expectedToken.length ||
// Constant-time comparison to prevent timing attacks.
// Reject non-string tokens (e.g. ?token=a&token=b would yield an array and
// crash Buffer.from).
if (typeof token !== 'string' || token.length !== expectedToken.length ||
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
return res.status(403).json({ error: 'Invalid token' });
}
@@ -72,14 +76,17 @@ router.post('/callback/:uid', async (req, res) => {
router.get('/room/:uid', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [req.params.uid]);
const room = await db.get('SELECT id, user_id, analytics_visibility FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
// Check access: owner, shared, or admin
// Check access: owner, shared (if visibility allows), or admin
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
if (room.analytics_visibility !== 'shared') {
return res.status(403).json({ error: 'No permission to view analytics for this room' });
}
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 view analytics for this room' });
@@ -134,4 +141,185 @@ router.delete('/:id', authenticateToken, async (req, res) => {
}
});
// Helper: extract flat attendee rows from analytics entry
function extractRows(entry) {
const data = typeof entry.data === 'string' ? JSON.parse(entry.data) : entry.data;
const attendees = data?.data?.attendees || [];
const meetingName = data?.data?.metadata?.meeting_name || entry.meeting_name || '';
const meetingDuration = data?.data?.duration || 0;
const meetingStart = data?.data?.start || '';
const meetingFinish = data?.data?.finish || '';
return attendees.map(a => ({
meetingName,
meetingStart,
meetingFinish,
meetingDuration,
name: a.name || '',
role: a.moderator ? 'Moderator' : 'Viewer',
duration: a.duration || 0,
talkTime: a.engagement?.talk_time || 0,
chats: a.engagement?.chats || 0,
talks: a.engagement?.talks || 0,
raiseHand: a.engagement?.raisehand || 0,
emojis: a.engagement?.emojis || 0,
pollVotes: a.engagement?.poll_votes || 0,
}));
}
const COLUMNS = [
{ header: 'Meeting', key: 'meetingName', width: 25 },
{ header: 'Start', key: 'meetingStart', width: 20 },
{ header: 'End', key: 'meetingFinish', width: 20 },
{ header: 'Meeting Duration (s)', key: 'meetingDuration', width: 18 },
{ header: 'Name', key: 'name', width: 25 },
{ header: 'Role', key: 'role', width: 12 },
{ header: 'Duration (s)', key: 'duration', width: 14 },
{ header: 'Talk Time (s)', key: 'talkTime', width: 14 },
{ header: 'Chats', key: 'chats', width: 8 },
{ header: 'Talks', key: 'talks', width: 8 },
{ header: 'Raise Hand', key: 'raiseHand', width: 12 },
{ header: 'Emojis', key: 'emojis', width: 8 },
{ header: 'Poll Votes', key: 'pollVotes', width: 10 },
];
// GET /api/analytics/:id/export/:format - Export a single analytics entry (csv, xlsx, pdf)
router.get('/:id/export/:format', authenticateToken, async (req, res) => {
try {
const format = req.params.format;
if (!['csv', 'xlsx', 'pdf'].includes(format)) {
return res.status(400).json({ error: 'Unsupported format. Use csv, xlsx, or pdf.' });
}
const db = getDb();
const entry = await db.get(
`SELECT la.*, r.user_id, r.analytics_visibility
FROM learning_analytics_data la
JOIN rooms r ON la.room_id = r.id
WHERE la.id = ?`,
[req.params.id]
);
if (!entry) {
return res.status(404).json({ error: 'Analytics entry not found' });
}
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
if (entry.analytics_visibility !== 'shared') {
return res.status(403).json({ error: 'No permission to export this entry' });
}
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [entry.room_id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to export this entry' });
}
}
const rows = extractRows(entry);
const safeName = (entry.meeting_name || 'analytics').replace(/[^a-zA-Z0-9_-]/g, '_');
if (format === 'csv') {
// Prefix-escape values that start with a formula trigger character so that
// Excel/LibreOffice do not evaluate them as formulas (CSV injection).
const escapeCsv = (val) => {
if (val === null || val === undefined) return '';
let s = String(val);
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
};
const header = COLUMNS.map(c => c.header).join(',');
const csvRows = rows.map(r =>
COLUMNS.map(c => escapeCsv(r[c.key])).join(',')
);
const csv = [header, ...csvRows].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.csv"`);
return res.send('\uFEFF' + csv); // BOM for Excel UTF-8
}
if (format === 'xlsx') {
// Prefix-escape strings that would otherwise be evaluated as formulas.
const sanitizeXlsx = (r) => {
const out = {};
for (const k of Object.keys(r)) {
const v = r[k];
out[k] = (typeof v === 'string' && /^[=+\-@\t\r]/.test(v)) ? "'" + v : v;
}
return out;
};
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Analytics');
sheet.columns = COLUMNS;
rows.forEach(r => sheet.addRow(sanitizeXlsx(r)));
// Style header row
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.xlsx"`);
await workbook.xlsx.write(res);
return res.end();
}
if (format === 'pdf') {
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 30 });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.pdf"`);
doc.pipe(res);
// Title
doc.fontSize(16).text(entry.meeting_name || 'Learning Analytics', { align: 'center' });
doc.moveDown(0.5);
// Table header columns for PDF (subset for readability)
const pdfCols = [
{ header: 'Name', key: 'name', width: 120 },
{ header: 'Role', key: 'role', width: 65 },
{ header: 'Duration (s)', key: 'duration', width: 75 },
{ header: 'Talk Time (s)', key: 'talkTime', width: 75 },
{ header: 'Chats', key: 'chats', width: 50 },
{ header: 'Talks', key: 'talks', width: 50 },
{ header: 'Raise Hand', key: 'raiseHand', width: 65 },
{ header: 'Emojis', key: 'emojis', width: 50 },
{ header: 'Poll Votes', key: 'pollVotes', width: 60 },
];
const startX = doc.x;
let y = doc.y;
// Header
doc.fontSize(8).font('Helvetica-Bold');
pdfCols.forEach((col, i) => {
const x = startX + pdfCols.slice(0, i).reduce((s, c) => s + c.width, 0);
doc.text(col.header, x, y, { width: col.width, align: 'left' });
});
y += 14;
doc.moveTo(startX, y).lineTo(startX + pdfCols.reduce((s, c) => s + c.width, 0), y).stroke();
y += 4;
// Rows
doc.font('Helvetica').fontSize(8);
rows.forEach(r => {
if (y > doc.page.height - 50) {
doc.addPage();
y = 30;
}
pdfCols.forEach((col, i) => {
const x = startX + pdfCols.slice(0, i).reduce((s, c) => s + c.width, 0);
doc.text(String(r[col.key]), x, y, { width: col.width, align: 'left' });
});
y += 14;
});
doc.end();
return;
}
} catch (err) {
log.server.error(`Export analytics error: ${err.message}`);
res.status(500).json({ error: 'Error exporting analytics data' });
}
});
export default router;
+211 -3
View File
@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
@@ -7,6 +7,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import * as OTPAuth from 'otpauth';
import { getDb } from '../config/database.js';
import redis from '../config/redis.js';
import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js';
@@ -42,6 +43,13 @@ const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
const MIN_PASSWORD_LENGTH = 8;
// bcrypt only uses the first 72 bytes; cap input to prevent CPU-DoS on hashing.
const MAX_PASSWORD_LENGTH = 64;
// Pre-computed bcrypt hash of a random string used as a dummy comparison
// target when the requested account does not exist. Keeps login timing
// roughly constant so we do not leak whether an email is registered.
const DUMMY_BCRYPT_HASH = bcrypt.hashSync('dummy-password-for-timing-' + Math.random(), 12);
// ── Rate Limiters ────────────────────────────────────────────────────────────
const loginLimiter = rateLimit({
@@ -99,6 +107,15 @@ const resendVerificationLimiter = rateLimit({
store: makeRedisStore('rl:resend:'),
});
const twoFaLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many 2FA attempts. Please try again later.' },
store: makeRedisStore('rl:2fa:'),
});
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
@@ -158,6 +175,9 @@ router.post('/register', registerLimiter, async (req, res) => {
if (password.length < MIN_PASSWORD_LENGTH) {
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
}
if (password.length > MAX_PASSWORD_LENGTH) {
return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` });
}
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
if (existing) {
@@ -341,10 +361,18 @@ router.post('/login', loginLimiter, async (req, res) => {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Cap password length to keep bcrypt CPU work bounded.
if (typeof password !== 'string' || password.length > MAX_PASSWORD_LENGTH) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const db = getDb();
const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
// Always run bcrypt against either the real hash or a dummy hash so login
// timing does not reveal whether the email is registered.
const passwordOk = bcrypt.compareSync(password, user?.password_hash || DUMMY_BCRYPT_HASH);
if (!user || !passwordOk) {
return res.status(401).json({ error: 'Invalid credentials' });
}
@@ -352,8 +380,14 @@ router.post('/login', loginLimiter, async (req, res) => {
return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', needsVerification: true });
}
// ── 2FA check ────────────────────────────────────────────────────────
if (user.totp_enabled) {
const tempToken = jwt.sign({ userId: user.id, purpose: '2fa' }, JWT_SECRET, { expiresIn: '5m' });
return res.json({ requires2FA: true, tempToken });
}
const token = generateToken(user.id);
const { password_hash, verification_token, verification_token_expires, verification_resend_at, ...safeUser } = user;
const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
@@ -362,6 +396,53 @@ router.post('/login', loginLimiter, async (req, res) => {
}
});
// POST /api/auth/login/2fa - Verify TOTP code and complete login
router.post('/login/2fa', twoFaLimiter, async (req, res) => {
try {
const { tempToken, code } = req.body;
if (!tempToken || !code) {
return res.status(400).json({ error: 'Token and code are required' });
}
let decoded;
try {
decoded = jwt.verify(tempToken, JWT_SECRET);
} catch {
return res.status(401).json({ error: 'Invalid or expired token. Please log in again.' });
}
if (decoded.purpose !== '2fa') {
return res.status(401).json({ error: 'Invalid token' });
}
const db = getDb();
const user = await db.get('SELECT * FROM users WHERE id = ?', [decoded.userId]);
if (!user || !user.totp_enabled || !user.totp_secret) {
return res.status(401).json({ error: 'Invalid token' });
}
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}
const token = generateToken(user.id);
const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
log.auth.error(`2FA login error: ${err.message}`);
res.status(500).json({ error: '2FA verification failed' });
}
});
// POST /api/auth/logout - revoke JWT via DragonflyDB blacklist
router.post('/logout', authenticateToken, async (req, res) => {
try {
@@ -497,6 +578,9 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
return res.status(400).json({ error: 'Invalid input' });
}
if (currentPassword.length > MAX_PASSWORD_LENGTH || newPassword.length > MAX_PASSWORD_LENGTH) {
return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` });
}
const db = getDb();
@@ -670,4 +754,128 @@ router.get('/avatar/:filename', (req, res) => {
fs.createReadStream(filepath).pipe(res);
});
// ── 2FA Management ──────────────────────────────────────────────────────────
// GET /api/auth/2fa/status
router.get('/2fa/status', authenticateToken, async (req, res) => {
const db = getDb();
const user = await db.get('SELECT totp_enabled FROM users WHERE id = ?', [req.user.id]);
res.json({ enabled: !!user?.totp_enabled });
});
// POST /api/auth/2fa/setup - Generate TOTP secret + provisioning URI
router.post('/2fa/setup', authenticateToken, twoFaLimiter, async (req, res) => {
try {
const db = getDb();
const user = await db.get('SELECT totp_enabled FROM users WHERE id = ?', [req.user.id]);
if (user?.totp_enabled) {
return res.status(400).json({ error: '2FA is already enabled' });
}
const secret = new OTPAuth.Secret({ size: 20 });
// Load app name from branding settings
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
let issuer = 'Redlight';
if (brandingSetting?.value) {
try { issuer = JSON.parse(brandingSetting.value).appName || issuer; } catch {}
}
const totp = new OTPAuth.TOTP({
issuer,
label: req.user.email,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret,
});
// Store the secret (but don't enable yet — user must verify first)
await db.run('UPDATE users SET totp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [secret.base32, req.user.id]);
res.json({ secret: secret.base32, uri: totp.toString() });
} catch (err) {
log.auth.error(`2FA setup error: ${err.message}`);
res.status(500).json({ error: '2FA setup failed' });
}
});
// POST /api/auth/2fa/enable - Verify code and activate 2FA
router.post('/2fa/enable', authenticateToken, twoFaLimiter, async (req, res) => {
try {
const { code } = req.body;
if (!code) {
return res.status(400).json({ error: 'Code is required' });
}
const db = getDb();
const user = await db.get('SELECT totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
if (!user?.totp_secret) {
return res.status(400).json({ error: 'Please run 2FA setup first' });
}
if (user.totp_enabled) {
return res.status(400).json({ error: '2FA is already enabled' });
}
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) {
return res.status(401).json({ error: 'Invalid code. Please try again.' });
}
await db.run('UPDATE users SET totp_enabled = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
res.json({ enabled: true, message: '2FA has been enabled' });
} catch (err) {
log.auth.error(`2FA enable error: ${err.message}`);
res.status(500).json({ error: '2FA could not be enabled' });
}
});
// POST /api/auth/2fa/disable - Disable 2FA (requires password + TOTP code)
router.post('/2fa/disable', authenticateToken, twoFaLimiter, async (req, res) => {
try {
const { password, code } = req.body;
if (!password || !code) {
return res.status(400).json({ error: 'Password and code are required' });
}
if (typeof password !== 'string' || password.length > MAX_PASSWORD_LENGTH) {
return res.status(400).json({ error: 'Invalid password' });
}
const db = getDb();
const user = await db.get('SELECT password_hash, totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
if (!user?.totp_enabled) {
return res.status(400).json({ error: '2FA is not enabled' });
}
if (!bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid password' });
}
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(user.totp_secret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}
await db.run('UPDATE users SET totp_enabled = 0, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
res.json({ enabled: false, message: '2FA has been disabled' });
} catch (err) {
log.auth.error(`2FA disable error: ${err.message}`);
res.status(500).json({ error: '2FA could not be disabled' });
}
});
export default router;
+2 -2
View File
@@ -278,7 +278,7 @@ ${propXml}
}
// ── OPTIONS ────────────────────────────────────────────────────────────────
router.options('*', (req, res) => {
router.options('/*splat', (req, res) => {
setDAVHeaders(res);
res.status(200).end();
});
@@ -529,7 +529,7 @@ router.delete('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, a
});
// ── Fallback ───────────────────────────────────────────────────────────────
router.all('*', caldavAuth, (req, res) => {
router.all('/*splat', caldavAuth, (req, res) => {
setDAVHeaders(res);
res.status(405).end();
});
+20 -20
View File
@@ -9,11 +9,11 @@ import { createNotification } from '../config/notifications.js';
// M13: rate limit the unauthenticated federation receive endpoint
const federationReceiveLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many federation requests. Please try again later.' },
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many federation requests. Please try again later.' },
});
import {
@@ -40,7 +40,7 @@ export function wellKnownHandler(req, res) {
federation_api: '/api/federation',
public_key: getPublicKey(),
software: 'Redlight',
version: '2.0.0',
version: '2.1.3',
});
}
@@ -236,24 +236,24 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
// Send notification email (truly fire-and-forget - never blocks the response)
if (targetUser.email) {
const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => {
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
});
const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => {
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
});
}
// In-app notification
await createNotification(
targetUser.id,
'federation_invite_received',
from_user,
room_name,
'/federation/inbox',
targetUser.id,
'federation_invite_received',
from_user,
room_name,
'/federation/inbox',
);
res.json({ success: true });
+7 -1
View File
@@ -200,7 +200,13 @@ router.get('/callback', callbackLimiter, async (req, res) => {
);
if (user) {
// Link OAuth to existing account
// Only auto-link to an existing local account if the IdP has actually
// verified the email. Otherwise an attacker who registers at the IdP
// with someone else's email could take over the local account.
if (userInfo.email_verified !== true) {
log.auth.warn(`OAuth account-linking blocked: provider did not assert email_verified=true for ${email}`);
return errorRedirect('Your OAuth provider has not verified this email address. Please verify it with the provider before logging in here.');
}
await db.run(
'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
['oidc', sub, user.id],
+201 -63
View File
@@ -11,6 +11,171 @@ import {
const router = Router();
/**
* Format a raw BBB recording into a normalised object.
*/
function formatBbbRecording(rec, fallbackName) {
const playback = rec.playback?.format;
let formats = [];
if (playback) {
formats = Array.isArray(playback) ? playback : [playback];
}
return {
recordID: rec.recordID,
meetingID: rec.meetingID,
name: rec.name || fallbackName || 'Recording',
state: rec.state,
published: rec.published === 'true',
startTime: rec.startTime,
endTime: rec.endTime,
participants: rec.participants,
size: rec.size,
formats: formats.map(f => ({
type: f.type,
url: f.url,
length: f.length,
size: f.size,
})),
metadata: rec.metadata || {},
};
}
/**
* Format a DB row into the same shape the frontend expects.
*/
function formatDbRecording(row) {
return {
recordID: row.record_id,
meetingID: row.meeting_id,
name: row.name || 'Recording',
state: row.state,
published: row.published === 1,
startTime: row.start_time,
endTime: row.end_time,
participants: row.participants,
size: row.size,
formats: JSON.parse(row.formats || '[]'),
metadata: JSON.parse(row.metadata || '{}'),
fromCache: true,
};
}
/**
* Upsert a single formatted recording into the DB.
* - If the record_id already exists AND is the same recording, merge new formats.
* - If it's a new recording or a different one with the same record_id, overwrite.
*/
async function upsertRecording(db, rec) {
const existing = await db.get('SELECT * FROM recordings WHERE record_id = ?', [rec.recordID]);
const formatsJson = JSON.stringify(rec.formats);
const metadataJson = JSON.stringify(rec.metadata || {});
if (existing) {
// Verify it's still the same recording (same startTime)
if (String(rec.startTime) === String(existing.start_time)) {
// Merge formats: keep existing formats, add any new types
const existingFormats = JSON.parse(existing.formats || '[]');
const existingTypes = new Set(existingFormats.map(f => f.type));
const mergedFormats = [...existingFormats];
for (const f of rec.formats) {
if (existingTypes.has(f.type)) {
// Update URL for existing format type (server may have changed)
const idx = mergedFormats.findIndex(ef => ef.type === f.type);
if (idx !== -1) mergedFormats[idx] = f;
} else {
// New format type added
mergedFormats.push(f);
}
}
await db.run(
`UPDATE recordings SET name = ?, state = ?, published = ?, end_time = ?,
participants = ?, size = ?, formats = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP
WHERE record_id = ?`,
[rec.name, rec.state, rec.published ? 1 : 0, rec.endTime,
rec.participants, rec.size, JSON.stringify(mergedFormats), metadataJson,
rec.recordID]
);
} else {
// Different recording with same record_id overwrite completely
await db.run(
`UPDATE recordings SET meeting_id = ?, name = ?, state = ?, published = ?,
start_time = ?, end_time = ?, participants = ?, size = ?, formats = ?,
metadata = ?, updated_at = CURRENT_TIMESTAMP
WHERE record_id = ?`,
[rec.meetingID, rec.name, rec.state, rec.published ? 1 : 0,
rec.startTime, rec.endTime, rec.participants, rec.size,
formatsJson, metadataJson, rec.recordID]
);
}
} else {
// Completely new recording insert
await db.run(
`INSERT INTO recordings (record_id, meeting_id, name, state, published, start_time,
end_time, participants, size, formats, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[rec.recordID, rec.meetingID, rec.name, rec.state, rec.published ? 1 : 0,
rec.startTime, rec.endTime, rec.participants, rec.size,
formatsJson, metadataJson]
);
}
}
/**
* Core logic: fetch recordings from BBB, sync with DB, fall back to DB on error.
* Returns an array of formatted recordings.
*/
async function fetchAndSyncRecordings(meetingID, fallbackName) {
const db = getDb();
let bbbRecordings = null;
try {
const raw = await getRecordings(meetingID || undefined);
bbbRecordings = raw.map(rec => formatBbbRecording(rec, fallbackName));
} catch (err) {
log.recordings.warn(`BBB API unreachable, falling back to cached recordings: ${err.message}`);
}
if (bbbRecordings !== null) {
// BBB API was reachable sync each recording into the DB
for (const rec of bbbRecordings) {
try {
await upsertRecording(db, rec);
} catch (err) {
log.recordings.error(`Failed to cache recording ${rec.recordID}: ${err.message}`);
}
}
// For recordings in DB that were NOT returned by BBB (possibly deleted on server
// or server changed), keep them in DB with their cached URLs don't delete.
// However, we return the BBB result merged with any DB-only recordings.
const bbbIds = new Set(bbbRecordings.map(r => r.recordID));
const params = meetingID ? [meetingID] : [];
const query = meetingID
? 'SELECT * FROM recordings WHERE meeting_id = ?'
: 'SELECT * FROM recordings';
const dbRows = await db.all(query, params);
// Add DB-only recordings (not on current BBB server) with cached URLs
for (const row of dbRows) {
if (!bbbIds.has(row.record_id)) {
bbbRecordings.push(formatDbRecording(row));
}
}
return bbbRecordings;
} else {
// BBB API unreachable serve everything from DB
const params = meetingID ? [meetingID] : [];
const query = meetingID
? 'SELECT * FROM recordings WHERE meeting_id = ?'
: 'SELECT * FROM recordings';
const rows = await db.all(query, params);
return rows.map(formatDbRecording);
}
}
// GET /api/recordings - Get recordings for a room (by meetingID/uid)
router.get('/', authenticateToken, async (req, res) => {
try {
@@ -34,36 +199,7 @@ router.get('/', authenticateToken, async (req, res) => {
return res.status(400).json({ error: 'meetingID query parameter is required' });
}
const recordings = await getRecordings(meetingID || undefined);
// Format recordings
const formatted = recordings.map(rec => {
const playback = rec.playback?.format;
let formats = [];
if (playback) {
formats = Array.isArray(playback) ? playback : [playback];
}
return {
recordID: rec.recordID,
meetingID: rec.meetingID,
name: rec.name || 'Recording',
state: rec.state,
published: rec.published === 'true',
startTime: rec.startTime,
endTime: rec.endTime,
participants: rec.participants,
size: rec.size,
formats: formats.map(f => ({
type: f.type,
url: f.url,
length: f.length,
size: f.size,
})),
metadata: rec.metadata || {},
};
});
const formatted = await fetchAndSyncRecordings(meetingID);
res.json({ recordings: formatted });
} catch (err) {
log.recordings.error(`Get recordings error: ${err.message}`);
@@ -89,33 +225,7 @@ router.get('/room/:uid', authenticateToken, async (req, res) => {
}
}
const recordings = await getRecordings(room.uid);
const formatted = recordings.map(rec => {
const playback = rec.playback?.format;
let formats = [];
if (playback) {
formats = Array.isArray(playback) ? playback : [playback];
}
return {
recordID: rec.recordID,
meetingID: rec.meetingID,
name: rec.name || room.name,
state: rec.state,
published: rec.published === 'true',
startTime: rec.startTime,
endTime: rec.endTime,
participants: rec.participants,
size: rec.size,
formats: formats.map(f => ({
type: f.type,
url: f.url,
length: f.length,
size: f.size,
})),
};
});
const formatted = await fetchAndSyncRecordings(room.uid, room.name);
res.json({ recordings: formatted });
} catch (err) {
log.recordings.error(`Get room recordings error: ${err.message}`);
@@ -126,14 +236,21 @@ router.get('/room/:uid', authenticateToken, async (req, res) => {
// DELETE /api/recordings/:recordID
router.delete('/:recordID', authenticateToken, async (req, res) => {
try {
const db = getDb();
// M14 fix: look up the recording from BBB to find its meetingID (room UID),
// then verify the user owns or shares that room.
if (req.user.role !== 'admin') {
const rec = await getRecordingByRecordId(req.params.recordID);
// Try BBB first, fall back to DB cache
let rec = await getRecordingByRecordId(req.params.recordID).catch(() => null);
if (!rec) {
const dbRow = await db.get('SELECT * FROM recordings WHERE record_id = ?', [req.params.recordID]);
if (dbRow) {
rec = { meetingID: dbRow.meeting_id };
}
}
if (!rec) {
return res.status(404).json({ error: 'Recording not found' });
}
const db = getDb();
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
@@ -145,7 +262,14 @@ router.delete('/:recordID', authenticateToken, async (req, res) => {
}
}
}
await deleteRecording(req.params.recordID);
// Try to delete on BBB (may fail if server changed that's OK)
try {
await deleteRecording(req.params.recordID);
} catch (err) {
log.recordings.warn(`BBB deleteRecording failed (may already be gone): ${err.message}`);
}
// Always remove from local cache
await db.run('DELETE FROM recordings WHERE record_id = ?', [req.params.recordID]);
res.json({ message: 'Recording deleted' });
} catch (err) {
log.recordings.error(`Delete recording error: ${err.message}`);
@@ -156,14 +280,20 @@ router.delete('/:recordID', authenticateToken, async (req, res) => {
// PUT /api/recordings/:recordID/publish
router.put('/:recordID/publish', authenticateToken, async (req, res) => {
try {
const db = getDb();
// M14 fix: look up the recording from BBB to find its meetingID (room UID),
// then verify the user owns or shares that room.
if (req.user.role !== 'admin') {
const rec = await getRecordingByRecordId(req.params.recordID);
let rec = await getRecordingByRecordId(req.params.recordID).catch(() => null);
if (!rec) {
const dbRow = await db.get('SELECT * FROM recordings WHERE record_id = ?', [req.params.recordID]);
if (dbRow) {
rec = { meetingID: dbRow.meeting_id };
}
}
if (!rec) {
return res.status(404).json({ error: 'Recording not found' });
}
const db = getDb();
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
@@ -176,7 +306,15 @@ router.put('/:recordID/publish', authenticateToken, async (req, res) => {
}
}
const { publish } = req.body;
await publishRecording(req.params.recordID, publish);
// Try BBB API
try {
await publishRecording(req.params.recordID, publish);
} catch (err) {
log.recordings.warn(`BBB publishRecording failed: ${err.message}`);
}
// Update DB cache
await db.run('UPDATE recordings SET published = ?, updated_at = CURRENT_TIMESTAMP WHERE record_id = ?',
[publish ? 1 : 0, req.params.recordID]);
res.json({ message: publish ? 'Recording published' : 'Recording unpublished' });
} catch (err) {
log.recordings.error(`Publish recording error: ${err.message}`);
+177 -19
View File
@@ -8,6 +8,7 @@ import { getDb } from '../config/database.js';
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
import { sendGuestInviteEmail } from '../config/mailer.js';
import {
createMeeting,
joinMeeting,
@@ -23,6 +24,14 @@ import {
discoverInstance,
} from '../config/federation.js';
// Avatar image filenames are produced by the upload endpoint as
// "<userId>_<timestamp>.<ext>". Reject anything that doesn't match this shape
// so a guest can't smuggle path traversal or arbitrary URL fragments through
// the unauthenticated guest-join endpoint.
const SAFE_AVATAR_FILENAME_RE = /^[0-9]+_[0-9]+\.(jpg|png|gif|webp)$/;
// Mirrors auth.js: only allow hex/hsl/named CSS colors.
const SAFE_AVATAR_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
// L6: constant-time string comparison for access/moderator codes
function timingSafeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
@@ -37,6 +46,17 @@ const __dirname = path.dirname(__filename);
const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations');
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
const guestJoinLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
@@ -245,6 +265,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
guest_access,
moderator_code,
learning_analytics,
analytics_visibility,
} = req.body;
// M12: field length limits (same as create)
@@ -285,6 +306,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
guest_access = COALESCE(?, guest_access),
moderator_code = ?,
learning_analytics = COALESCE(?, learning_analytics),
analytics_visibility = COALESCE(?, analytics_visibility),
updated_at = CURRENT_TIMESTAMP
WHERE uid = ?
`, [
@@ -300,6 +322,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
guest_access !== undefined ? (guest_access ? 1 : 0) : null,
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null,
analytics_visibility && ['owner', 'shared'].includes(analytics_visibility) ? analytics_visibility : null,
req.params.uid,
]);
@@ -470,9 +493,10 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
return res.status(404).json({ error: 'Room not found' });
}
// Check access: owner or shared
// Check access: owner, admin, shared, or anyone_can_start
const isOwner = room.user_id === req.user.id;
if (!isOwner) {
const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin && !room.anyone_can_start) {
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' });
@@ -481,9 +505,11 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
const baseUrl = getBaseUrl(req);
const loginURL = `${baseUrl}/join/${room.uid}`;
const presentationUrl = room.presentation_file
? `${baseUrl}/uploads/presentations/${room.presentation_file}`
: null;
let presentationUrl = null;
if (room.presentation_file) {
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
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
: null;
@@ -542,9 +568,10 @@ router.post('/:uid/end', authenticateToken, async (req, res) => {
return res.status(404).json({ error: 'Room not found' });
}
// Check access: owner or shared user
// Check access: owner, admin, or shared user
const isOwner = room.user_id === req.user.id;
if (!isOwner) {
const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin) {
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' });
@@ -599,7 +626,7 @@ router.get('/:uid/public', async (req, res) => {
// POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed)
router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
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) {
return res.status(400).json({ error: 'Name is required' });
@@ -645,7 +672,20 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
}
const baseUrl = getBaseUrl(req);
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
// Validate client-supplied avatar fields before embedding them in a URL
// that BBB will fetch — guest-join is unauthenticated.
const safeAvatarImage = (typeof avatar_image === 'string' && SAFE_AVATAR_FILENAME_RE.test(avatar_image))
? avatar_image : null;
const safeAvatarColor = (typeof avatar_color === 'string' && SAFE_AVATAR_COLOR_RE.test(avatar_color))
? avatar_color : null;
let guestAvatarURL;
if (safeAvatarImage) {
guestAvatarURL = `${baseUrl}/api/auth/avatar/${encodeURIComponent(safeAvatarImage)}`;
} else if (safeAvatarColor) {
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}?color=${encodeURIComponent(safeAvatarColor)}`;
} else {
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
}
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
res.json({ joinUrl });
} catch (err) {
@@ -676,6 +716,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
router.post('/:uid/presentation', authenticateToken, async (req, res) => {
try {
@@ -730,22 +807,28 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
// Preserve original filename (sent as X-Filename header)
const rawName = req.headers['x-filename'];
const originalName = rawName
const filename = rawName
? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200)
: `presentation.${ext}`;
const filename = `${room.uid}_${Date.now()}.${ext}`;
const filepath = path.join(presentationsDir, filename);
// Each room gets its own folder: uploads/presentations/{roomUID}/
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
if (room.presentation_file) {
// S8: defense-in-depth path traversal check
const oldPath = path.resolve(presentationsDir, room.presentation_file);
if (oldPath.startsWith(presentationsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
const oldPath = path.resolve(roomDir, room.presentation_file);
if (oldPath.startsWith(roomDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
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]);
res.json({ room: updated });
} catch (err) {
@@ -763,11 +846,14 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
if (room.presentation_file) {
// S8: defense-in-depth path traversal check
const filepath = path.resolve(presentationsDir, room.presentation_file);
if (filepath.startsWith(presentationsDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath);
const roomDir = path.join(presentationsDir, room.uid);
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]);
res.json({ room: updated });
} catch (err) {
@@ -776,4 +862,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;
+66 -3
View File
@@ -1,12 +1,13 @@
import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp, Hand, BarChart2 } from 'lucide-react';
import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp, Hand, BarChart2, Download } from 'lucide-react';
import { useState } from 'react';
import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast';
export default function AnalyticsList({ analytics, onRefresh }) {
export default function AnalyticsList({ analytics, onRefresh, isOwner = true }) {
const [loading, setLoading] = useState({});
const [expanded, setExpanded] = useState({});
const [exportMenu, setExportMenu] = useState({});
const { t, language } = useLanguage();
const formatDate = (dateStr) => {
@@ -49,6 +50,34 @@ export default function AnalyticsList({ analytics, onRefresh }) {
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
};
const toggleExportMenu = (id) => {
setExportMenu(prev => ({ ...prev, [id]: !prev[id] }));
};
const handleExport = async (id, format) => {
setExportMenu(prev => ({ ...prev, [id]: false }));
setLoading(prev => ({ ...prev, [id]: 'exporting' }));
try {
const response = await api.get(`/analytics/${id}/export/${format}`, { responseType: 'blob' });
const disposition = response.headers['content-disposition'];
const match = disposition?.match(/filename="?([^"]+)"?/);
const filename = match?.[1] || `analytics.${format}`;
const url = window.URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
toast.success(t('analytics.exportSuccess'));
} catch {
toast.error(t('analytics.exportFailed'));
} finally {
setLoading(prev => ({ ...prev, [id]: null }));
}
};
// Extract user summary from BBB learning analytics callback data
// Payload: { meeting_id, data: { duration, start, finish, attendees: [{ name, moderator, duration, engagement: { chats, talks, raisehand, emojis, poll_votes, talk_time } }] } }
const getUserSummary = (data) => {
@@ -123,7 +152,39 @@ export default function AnalyticsList({ analytics, onRefresh }) {
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className="flex items-center gap-1 shrink-0">
<div className="relative">
<button
onClick={() => toggleExportMenu(entry.id)}
disabled={loading[entry.id] === 'exporting'}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
title={t('analytics.export')}
>
<Download size={16} />
</button>
{exportMenu[entry.id] && (
<div className="absolute right-0 top-full mt-1 bg-th-bg border border-th-border rounded-lg shadow-lg z-10 min-w-[120px] py-1">
<button
onClick={() => handleExport(entry.id, 'csv')}
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
CSV
</button>
<button
onClick={() => handleExport(entry.id, 'xlsx')}
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
Excel (.xlsx)
</button>
<button
onClick={() => handleExport(entry.id, 'pdf')}
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
PDF
</button>
</div>
)}
</div>
<button
onClick={() => toggleExpand(entry.id)}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
@@ -131,6 +192,7 @@ export default function AnalyticsList({ analytics, onRefresh }) {
>
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{isOwner && (
<button
onClick={() => handleDelete(entry.id)}
disabled={loading[entry.id] === 'deleting'}
@@ -139,6 +201,7 @@ export default function AnalyticsList({ analytics, onRefresh }) {
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
+3 -3
View File
@@ -2,9 +2,9 @@ import { Video } from 'lucide-react';
import { useBranding } from '../contexts/BrandingContext';
const sizes = {
sm: { box: 'w-8 h-8', h: 'h-8', maxW: 'max-w-[8rem]', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
md: { box: 'w-9 h-9', h: 'h-12', maxW: 'max-w-[10rem]', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
lg: { box: 'w-10 h-10', h: 'h-10', maxW: 'max-w-[12rem]', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
sm: { box: 'w-8 h-8', h: 'h-8', maxW: 'max-w-32', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
md: { box: 'w-9 h-9', h: 'h-12', maxW: 'max-w-40', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
lg: { box: 'w-10 h-10', h: 'h-10', maxW: 'max-w-48', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
};
export default function BrandLogo({ size = 'md', className = '' }) {
+1 -1
View File
@@ -90,7 +90,7 @@ export default function DateTimePicker({
</label>
)}
<div className="relative">
<Icon size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none z-[1]" />
<Icon size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none z-1" />
<input
ref={inputRef}
type="text"
+7 -7
View File
@@ -35,17 +35,17 @@ export default function FederatedRoomCard({ room, onRemove }) {
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Globe size={14} className="text-th-accent flex-shrink-0" />
<Globe size={14} className="text-th-accent shrink-0" />
<h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors">
{room.room_name}
</h3>
{isDeleted ? (
<span className="flex-shrink-0 px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium flex items-center gap-1">
<span className="shrink-0 px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium flex items-center gap-1">
<AlertTriangle size={10} />
{t('federation.roomDeleted')}
</span>
) : (
<span className="flex-shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
<span className="shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
{t('federation.federated')}
</span>
)}
@@ -60,12 +60,12 @@ export default function FederatedRoomCard({ room, onRemove }) {
<div className="grid grid-cols-2 gap-2 mb-4">
{room.meet_id && (
<div className="flex items-center gap-1.5 text-xs text-th-text-s">
<Hash size={12} className="text-th-accent flex-shrink-0" />
<Hash size={12} className="text-th-accent shrink-0" />
<span className="truncate font-mono" title={room.meet_id}>{room.meet_id.slice(0, 10)}</span>
</div>
)}
<div className="flex items-center gap-1.5 text-xs text-th-text-s">
<Users size={12} className="text-th-accent flex-shrink-0" />
<Users size={12} className="text-th-accent shrink-0" />
<span>
{t('federation.maxParticipants')}:{' '}
<span className="text-th-text font-medium">
@@ -76,12 +76,12 @@ export default function FederatedRoomCard({ room, onRemove }) {
<div className="flex items-center gap-1.5 text-xs col-span-2">
{recordingOn ? (
<>
<Video size={12} className="text-amber-500 flex-shrink-0" />
<Video size={12} className="text-amber-500 shrink-0" />
<span className="text-amber-500 font-medium">{t('federation.recordingOn')}</span>
</>
) : (
<>
<VideoOff size={12} className="text-th-text-s flex-shrink-0" />
<VideoOff size={12} className="text-th-text-s shrink-0" />
<span className="text-th-text-s">{t('federation.recordingOff')}</span>
</>
)}
+1 -1
View File
@@ -52,7 +52,7 @@ export default function Layout() {
{/* Email verification banner */}
{user && user.email_verified === 0 && (
<div className="bg-amber-500/15 border-b border-amber-500/30 px-4 py-2.5 flex items-center justify-center gap-3 text-sm">
<AlertTriangle size={15} className="text-amber-400 flex-shrink-0" />
<AlertTriangle size={15} className="text-amber-400 shrink-0" />
<span className="text-amber-200">{t('auth.emailVerificationBanner')}</span>
<button
onClick={handleResendVerification}
+1 -1
View File
@@ -3,7 +3,7 @@ import { X } from 'lucide-react';
export default function Modal({ title, children, onClose, maxWidth = 'max-w-lg' }) {
return (
<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={onClose} />
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={onClose} />
<div className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth}`}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border rounded-t-2xl">
+1 -1
View File
@@ -38,7 +38,7 @@ export default function Navbar({ onMenuClick }) {
: '?';
return (
<header className="sticky top-0 z-20 bg-th-nav border-b border-th-border backdrop-blur-sm">
<header className="sticky top-0 z-20 bg-th-nav border-b border-th-border backdrop-blur-xs">
<div className="flex items-center justify-between h-16 px-4 md:px-6">
{/* Left section */}
<div className="flex items-center gap-3">
+3 -3
View File
@@ -148,7 +148,7 @@ export default function NotificationBell() {
${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`}
>
{/* Icon */}
<span className="text-lg flex-shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
<span className="text-lg shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
{/* Content */}
<div className="flex-1 min-w-0">
@@ -164,7 +164,7 @@ export default function NotificationBell() {
</div>
{/* Right side: unread dot, link icon, delete button */}
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<div className="flex flex-col items-end gap-1 shrink-0">
{!n.read && (
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
)}
@@ -173,7 +173,7 @@ export default function NotificationBell() {
)}
<button
onClick={(e) => handleDelete(e, n.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:text-th-error transition-all text-th-text-s/50"
className="opacity-0 group-hover:opacity-100 p-0.5 rounded-sm hover:text-th-error transition-all text-th-text-s/50"
title={t('notifications.delete')}
>
<X size={13} />
+3 -3
View File
@@ -122,9 +122,9 @@ export default function RecordingList({ recordings, onRefresh }) {
href={format.url}
target="_blank"
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}
</a>
))}
@@ -133,7 +133,7 @@ export default function RecordingList({ recordings, onRefresh }) {
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handlePublish(rec.recordID, !rec.published)}
disabled={loading[rec.recordID] === 'publishing'}
+1 -1
View File
@@ -80,7 +80,7 @@ export default function RoomCard({ room, onDelete }) {
<span>Max: {room.max_participants}</span>
)}
{room.access_code && (
<span className="px-1.5 py-0.5 bg-th-warning/15 text-th-warning rounded text-[10px] font-medium">
<span className="px-1.5 py-0.5 bg-th-warning/15 text-th-warning rounded-sm text-[10px] font-medium">
{t('common.protected')}
</span>
)}
+2 -2
View File
@@ -43,7 +43,7 @@ export default function Sidebar({ open, onClose }) {
const linkClasses = ({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive
? 'bg-th-accent text-th-accent-t shadow-sm'
? 'bg-th-accent text-th-accent-t shadow-xs'
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
}`;
@@ -106,7 +106,7 @@ export default function Sidebar({ open, onClose }) {
<div className="p-4 border-t border-th-border">
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden"
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
>
{user?.avatar_image ? (
+1 -1
View File
@@ -10,7 +10,7 @@ export default function ThemeSelector({ onClose }) {
return (
<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={onClose} />
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={onClose} />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
{/* Header */}
+11 -1
View File
@@ -23,6 +23,16 @@ export function AuthProvider({ children }) {
const login = useCallback(async (email, password) => {
const res = await api.post('/auth/login', { email, password });
if (res.data.requires2FA) {
return { requires2FA: true, tempToken: res.data.tempToken };
}
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data.user;
}, []);
const verify2FA = useCallback(async (tempToken, code) => {
const res = await api.post('/auth/login/2fa', { tempToken, code });
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data.user;
@@ -75,7 +85,7 @@ export function AuthProvider({ children }) {
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, register, logout, loginWithOAuth, updateUser }}>
<AuthContext.Provider value={{ user, loading, login, verify2FA, register, logout, loginWithOAuth, updateUser }}>
{children}
</AuthContext.Provider>
);
+13 -5
View File
@@ -49,14 +49,20 @@ export function NotificationProvider({ children }) {
const { user } = useAuth();
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const activeUserId = useRef(null);
// Track seen IDs to detect genuinely new arrivals and show toasts
const seenIds = useRef(new Set());
const initialized = useRef(false);
const fetch = useCallback(async () => {
if (!user) return;
const requestUserId = user?.id;
if (!requestUserId) return;
try {
const res = await api.get('/notifications');
// Ignore stale responses that arrived after logout or account switch.
if (activeUserId.current !== requestUserId) return;
const incoming = res.data.notifications || [];
setNotifications(incoming);
setUnreadCount(res.data.unreadCount || 0);
@@ -92,10 +98,10 @@ export function NotificationProvider({ children }) {
}
}, [user]);
// Unlock audio playback on the first real user interaction.
// Browsers block audio from timer callbacks unless the element was previously
// "touched" inside a gesture handler — this one-time listener does exactly that.
// Unlock audio playback only for authenticated sessions.
// This avoids any audio interaction while logged out (e.g. anonymous/incognito tabs).
useEffect(() => {
if (!user?.id) return;
const events = ['click', 'keydown', 'pointerdown'];
const handler = () => {
unlockAudio();
@@ -103,10 +109,12 @@ export function NotificationProvider({ children }) {
};
events.forEach(e => window.addEventListener(e, handler, { once: true }));
return () => events.forEach(e => window.removeEventListener(e, handler));
}, []);
}, [user?.id]);
useEffect(() => {
activeUserId.current = user?.id ?? null;
if (!user) {
_audioUnlocked = false;
setNotifications([]);
setUnreadCount(0);
seenIds.current = new Set();
+75 -11
View File
@@ -1,4 +1,4 @@
{
{
"common": {
"appName": "Redlight",
"loading": "Laden...",
@@ -99,13 +99,21 @@
"oauthError": "Anmeldung fehlgeschlagen",
"oauthNoToken": "Kein Authentifizierungstoken erhalten.",
"oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
"oauthRedirecting": "Du wirst angemeldet..."
"oauthRedirecting": "Du wirst angemeldet...",
"2fa": {
"title": "Zwei-Faktor-Authentifizierung",
"prompt": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
"codeLabel": "Bestätigungscode",
"verify": "Bestätigen",
"verifyFailed": "Überprüfung fehlgeschlagen",
"backToLogin": "← Zurück zum Login"
}
},
"home": {
"madeFor": "Made for BigBlueButton",
"heroTitle": "Meetings neu ",
"heroTitleHighlight": "definiert",
"heroSubtitle": "Das moderne, selbst gehostete BigBlueButton-Frontend. Erstellen Sie Räume, verwalten Sie Aufnahmen und genießen Sie ein wunderschönes Interface mit über 15 Themes.",
"heroSubtitle": "Das moderne, selbst gehostete BigBlueButton-Frontend. Erstellen Sie Räume, verwalten Sie Aufnahmen, verbinden Sie sich mit anderen Instanzen und genießen Sie ein wunderschönes Interface mit über 15 Themes.",
"getStarted": "Jetzt starten",
"features": "Alles was Sie brauchen",
"featuresSubtitle": "Redlight bietet alle Funktionen, die Sie für professionelle Videokonferenzen benötigen.",
@@ -113,12 +121,22 @@
"featureVideoDesc": "Erstellen und verwalten Sie Meetings direkt über BigBlueButton.",
"featureRoomsTitle": "Raumverwaltung",
"featureRoomsDesc": "Unbegrenzte Räume mit individuellen Einstellungen und Zugangscodes.",
"featureUsersTitle": "Benutzerverwaltung",
"featureUsersDesc": "Registrierung, Login und Rollenverwaltung für Ihre Organisation.",
"featureThemesTitle": "15+ Themes",
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox und viele mehr.",
"featureRecordingsTitle": "Aufnahmen",
"featureRecordingsDesc": "Alle Aufnahmen pro Raum einsehen, veröffentlichen oder löschen.",
"featureFederationTitle": "Federation",
"featureFederationDesc": "Laden Sie Nutzer anderer Redlight-Instanzen in Ihre Räume ein und nehmen Sie instanzübergreifende Einladungen an.",
"featureCalendarTitle": "Kalender & Erinnerungen",
"featureCalendarDesc": "Meetings planen, per CalDAV synchronisieren und E-Mail-Erinnerungen vor dem Start erhalten.",
"featureNotificationsTitle": "Benachrichtigungen",
"featureNotificationsDesc": "Echtzeit-Benachrichtigungen in der App und per E-Mail für Einladungen und bevorstehende Meetings.",
"featureOAuthTitle": "OAuth / SSO",
"featureOAuthDesc": "Melden Sie sich über OAuth 2.0 mit Ihrem bestehenden Identity Provider an kein separates Konto nötig.",
"featureAnalyticsTitle": "Analytics",
"featureAnalyticsDesc": "Meeting-Statistiken einsehen und die Nutzung über alle Räume hinweg verfolgen.",
"featureThemesTitle": "15+ Themes",
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox und viele mehr plus eigenes Branding.",
"featureUsersTitle": "Benutzerverwaltung",
"featureUsersDesc": "Registrierung, Login und Rollenverwaltung für Ihre Organisation.",
"featureOpenSourceTitle": "Open Source",
"featureOpenSourceDesc": "Vollständig quelloffen und selbst gehostet. Ihre Daten bleiben bei Ihnen.",
"statThemes": "Themes",
@@ -258,7 +276,11 @@
"defaultWelcome": "Willkommen zum Meeting!",
"analytics": "Lernanalyse",
"enableAnalytics": "Lernanalyse aktivieren",
"enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting."
"enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting.",
"analyticsVisibility": "Wer kann die Analyse sehen?",
"analyticsOwnerOnly": "Nur Raumbesitzer",
"analyticsSharedUsers": "Alle geteilten Benutzer",
"analyticsVisibilityHint": "Legt fest, wer die Analysedaten dieses Raums einsehen und exportieren kann."
},
"recordings": {
"title": "Aufnahmen",
@@ -295,7 +317,10 @@
"duration": "Dauer",
"meetingDuration": "Meeting-Dauer",
"raiseHand": "Handheben",
"reactions": "Reaktionen"
"reactions": "Reaktionen",
"export": "Herunterladen",
"exportSuccess": "Download gestartet",
"exportFailed": "Fehler beim Herunterladen"
},
"settings": {
"title": "Einstellungen",
@@ -327,6 +352,26 @@
"passwordChangeFailed": "Fehler beim Ändern",
"passwordMismatch": "Passwörter stimmen nicht überein",
"selectLanguage": "Sprache auswählen",
"security": {
"title": "Sicherheit",
"subtitle": "Schütze dein Konto mit Zwei-Faktor-Authentifizierung (2FA). Nach der Aktivierung benötigst du sowohl dein Passwort als auch einen Code aus deiner Authenticator-App zum Anmelden.",
"statusEnabled": "2FA ist aktiviert",
"statusEnabledDesc": "Dein Konto ist durch Zwei-Faktor-Authentifizierung geschützt.",
"statusDisabled": "2FA ist nicht aktiviert",
"statusDisabledDesc": "Aktiviere die Zwei-Faktor-Authentifizierung für zusätzliche Sicherheit.",
"enable": "2FA aktivieren",
"disable": "2FA deaktivieren",
"enabled": "Zwei-Faktor-Authentifizierung aktiviert!",
"disabled": "Zwei-Faktor-Authentifizierung deaktiviert.",
"enableFailed": "2FA konnte nicht aktiviert werden",
"disableFailed": "2FA konnte nicht deaktiviert werden",
"setupFailed": "2FA-Einrichtung konnte nicht gestartet werden",
"scanQR": "Scanne diesen QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, etc.).",
"manualKey": "Oder gib diesen Schlüssel manuell ein:",
"verifyCode": "Gib den Code aus deiner App zur Überprüfung ein",
"codeLabel": "6-stelliger Code",
"disableConfirm": "Gib dein Passwort und einen aktuellen 2FA-Code ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren."
},
"caldav": {
"title": "CalDAV",
"subtitle": "Verbinde deine Kalender-App (z. B. Apple Kalender, Thunderbird, DAVx⁵) über das CalDAV-Protokoll. Verwende deine E-Mail-Adresse und ein App-Token als Passwort.",
@@ -449,7 +494,21 @@
"oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.",
"oauthNotConfigured": "OAuth ist noch nicht konfiguriert.",
"oauthSave": "OAuth speichern",
"oauthRemove": "OAuth entfernen"
"oauthRemove": "OAuth entfernen",
"roomsTitle": "Raumverwaltung",
"roomsDescription": "Alle Räume der Instanz einsehen, verwalten und bei Bedarf löschen.",
"searchRooms": "Räume suchen...",
"roomName": "Name",
"roomOwner": "Besitzer",
"roomShares": "Geteilt",
"roomCreated": "Erstellt",
"roomView": "Raum öffnen",
"deleteRoom": "Raum löschen",
"deleteRoomConfirm": "Raum \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
"roomDeleted": "Raum gelöscht",
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
"noRoomsFound": "Keine Räume vorhanden",
"showAllRooms": "Alle {count} Räume anzeigen"
},
"notifications": {
"bell": "Benachrichtigungen",
@@ -465,14 +524,19 @@
"inbox": "Einladungen",
"inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen",
"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",
"addressPlaceholder": "@benutzer@andere-instanz.com",
"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)",
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
"send": "Einladung senden",
"sent": "Einladung gesendet!",
"emailSent": "E-Mail-Einladung(en) gesendet!",
"emailSendFailed": "E-Mail-Einladung konnte nicht gesendet werden",
"sendFailed": "Einladung konnte nicht gesendet werden",
"from": "Von",
"accept": "Annehmen",
+75 -11
View File
@@ -1,4 +1,4 @@
{
{
"common": {
"appName": "Redlight",
"loading": "Loading...",
@@ -99,13 +99,21 @@
"oauthError": "Authentication failed",
"oauthNoToken": "No authentication token received.",
"oauthLoginFailed": "Could not complete sign in. Please try again.",
"oauthRedirecting": "Signing you in..."
"oauthRedirecting": "Signing you in...",
"2fa": {
"title": "Two-Factor Authentication",
"prompt": "Enter the 6-digit code from your authenticator app.",
"codeLabel": "Verification code",
"verify": "Verify",
"verifyFailed": "Verification failed",
"backToLogin": "← Back to login"
}
},
"home": {
"madeFor": "Made for BigBlueButton",
"heroTitle": "Meetings re",
"heroTitleHighlight": "defined",
"heroSubtitle": "The modern, self-hosted BigBlueButton frontend. Create rooms, manage recordings and enjoy a beautiful interface with over 15 themes.",
"heroSubtitle": "The modern, self-hosted BigBlueButton frontend. Create rooms, manage recordings, federate with other instances and enjoy a beautiful interface with over 15 themes.",
"getStarted": "Get started",
"features": "Everything you need",
"featuresSubtitle": "Redlight provides all the features you need for professional video conferencing.",
@@ -113,12 +121,22 @@
"featureVideoDesc": "Create and manage meetings directly via BigBlueButton.",
"featureRoomsTitle": "Room Management",
"featureRoomsDesc": "Unlimited rooms with individual settings and access codes.",
"featureUsersTitle": "User Management",
"featureUsersDesc": "Registration, login and role management for your organization.",
"featureThemesTitle": "15+ Themes",
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox and many more.",
"featureRecordingsTitle": "Recordings",
"featureRecordingsDesc": "View, publish or delete all recordings per room.",
"featureFederationTitle": "Federation",
"featureFederationDesc": "Invite users from other Redlight instances into your rooms and accept cross-instance meeting invitations.",
"featureCalendarTitle": "Calendar & Reminders",
"featureCalendarDesc": "Schedule meetings with your rooms, sync via CalDAV and receive email reminders before they start.",
"featureNotificationsTitle": "Notifications",
"featureNotificationsDesc": "Real-time in-app and email notifications for room invitations and upcoming meetings.",
"featureOAuthTitle": "OAuth / SSO",
"featureOAuthDesc": "Sign in with your existing identity provider via OAuth 2.0 — no separate account needed.",
"featureAnalyticsTitle": "Analytics",
"featureAnalyticsDesc": "Track meeting statistics and monitor usage across all your rooms.",
"featureThemesTitle": "15+ Themes",
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox and many more — plus custom branding.",
"featureUsersTitle": "User Management",
"featureUsersDesc": "Registration, login and role management for your organization.",
"featureOpenSourceTitle": "Open Source",
"featureOpenSourceDesc": "Fully open source and self-hosted. Your data stays with you.",
"statThemes": "Themes",
@@ -258,7 +276,11 @@
"defaultWelcome": "Welcome to the meeting!",
"analytics": "Learning Analytics",
"enableAnalytics": "Enable learning analytics",
"enableAnalyticsHint": "Collects participant engagement data after each meeting."
"enableAnalyticsHint": "Collects participant engagement data after each meeting.",
"analyticsVisibility": "Who can see analytics?",
"analyticsOwnerOnly": "Room owner only",
"analyticsSharedUsers": "All shared users",
"analyticsVisibilityHint": "Controls who can view and export analytics data for this room."
},
"recordings": {
"title": "Recordings",
@@ -295,7 +317,10 @@
"duration": "Duration",
"meetingDuration": "Meeting duration",
"raiseHand": "Raise hand",
"reactions": "Reactions"
"reactions": "Reactions",
"export": "Download",
"exportSuccess": "Download started",
"exportFailed": "Error downloading data"
},
"settings": {
"title": "Settings",
@@ -327,6 +352,26 @@
"passwordChangeFailed": "Error changing password",
"passwordMismatch": "Passwords do not match",
"selectLanguage": "Select language",
"security": {
"title": "Security",
"subtitle": "Protect your account with two-factor authentication (2FA). After enabling, you will need both your password and a code from your authenticator app to sign in.",
"statusEnabled": "2FA is enabled",
"statusEnabledDesc": "Your account is protected with two-factor authentication.",
"statusDisabled": "2FA is not enabled",
"statusDisabledDesc": "Enable two-factor authentication for an extra layer of security.",
"enable": "Enable 2FA",
"disable": "Disable 2FA",
"enabled": "Two-factor authentication enabled!",
"disabled": "Two-factor authentication disabled.",
"enableFailed": "Could not enable 2FA",
"disableFailed": "Could not disable 2FA",
"setupFailed": "Could not start 2FA setup",
"scanQR": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.).",
"manualKey": "Or enter this key manually:",
"verifyCode": "Enter the code from your app to verify",
"codeLabel": "6-digit code",
"disableConfirm": "Enter your password and a current 2FA code to disable two-factor authentication."
},
"caldav": {
"title": "CalDAV",
"subtitle": "Connect your calendar app (e.g. Apple Calendar, Thunderbird, DAVx⁵) using the CalDAV protocol. Use your email address and an app token as password.",
@@ -449,7 +494,21 @@
"oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.",
"oauthNotConfigured": "OAuth is not configured yet.",
"oauthSave": "Save OAuth",
"oauthRemove": "Remove OAuth"
"oauthRemove": "Remove OAuth",
"roomsTitle": "Room Management",
"roomsDescription": "View, manage, and delete all rooms on this instance.",
"searchRooms": "Search rooms...",
"roomName": "Name",
"roomOwner": "Owner",
"roomShares": "Shared",
"roomCreated": "Created",
"roomView": "View room",
"deleteRoom": "Delete room",
"deleteRoomConfirm": "Really delete room \"{name}\"? This cannot be undone.",
"roomDeleted": "Room deleted",
"roomDeleteFailed": "Room could not be deleted",
"noRoomsFound": "No rooms found",
"showAllRooms": "Show all {count} rooms"
},
"notifications": {
"bell": "Notifications",
@@ -465,14 +524,19 @@
"inbox": "Invitations",
"inboxSubtitle": "Meeting invitations from other Redlight instances",
"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",
"addressPlaceholder": "@user@other-instance.com",
"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)",
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
"send": "Send invitation",
"sent": "Invitation sent!",
"emailSent": "Email invitation(s) sent!",
"emailSendFailed": "Could not send email invitation",
"sendFailed": "Could not send invitation",
"from": "From",
"accept": "Accept",
+134 -84
View File
@@ -1,6 +1,126 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@theme {
--color-th-bg: var(--bg-primary);
--color-th-bg-s: var(--bg-secondary);
--color-th-bg-t: var(--bg-tertiary);
--color-th-text: var(--text-primary);
--color-th-text-s: var(--text-secondary);
--color-th-accent: var(--accent);
--color-th-accent-h: var(--accent-hover);
--color-th-accent-t: var(--accent-text);
--color-th-border: var(--border);
--color-th-card: var(--card-bg);
--color-th-input: var(--input-bg);
--color-th-input-b: var(--input-border);
--color-th-nav: var(--nav-bg);
--color-th-side: var(--sidebar-bg);
--color-th-hover: var(--hover-bg);
--color-th-success: var(--success);
--color-th-warning: var(--warning);
--color-th-error: var(--error);
--color-th-ring: var(--ring);
--font-sans: Inter, system-ui, -apple-system, sans-serif;
--shadow-th:
0 1px 3px 0 var(--shadow-color), 0 1px 2px -1px var(--shadow-color);
--shadow-th-lg:
0 10px 15px -3px var(--shadow-color), 0 4px 6px -4px var(--shadow-color);
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@utility btn-primary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-accent text-th-accent-t hover:bg-th-accent-h
transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility btn-secondary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-bg-s text-th-text border border-th-border
hover:bg-th-hover transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility btn-danger {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-error text-white hover:opacity-90
transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-error focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility btn-ghost {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
text-th-text-s hover:bg-th-hover hover:text-th-text
transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility input-field {
@apply w-full px-4 py-2.5 rounded-lg
bg-th-input text-th-text placeholder-th-text-s
border border-th-input-b
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:border-transparent
transition-all duration-200;
}
@utility card {
@apply bg-th-card rounded-xl border border-th-border
shadow-th transition-all duration-200;
}
@utility card-hover {
@apply card hover:shadow-th-lg cursor-pointer;
&:hover {
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
}
@utility gradient-text {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@utility gradient-bg {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
}
@layer base {
/* ===== DEFAULT LIGHT ===== */
@@ -444,8 +564,9 @@
}
}
@layer utilities {
/* ===== SCRUNKLY.CAT DARK ===== */
[data-theme="scrunkly-cat"] {
[data-theme='scrunkly-cat'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #161924;
@@ -472,8 +593,8 @@
--gradient-end: #d6336a;
}
/* ===== RED MODULAR LIGHT ===== */
[data-theme="red-modular-light"] {
/* ===== RED MODULAR LIGHT ===== */
[data-theme='red-modular-light'] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #ffffff;
@@ -501,7 +622,7 @@
}
/* ===== EVERFOREST DARK ===== */
[data-theme="everforest-dark"] {
[data-theme='everforest-dark'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #2d353b;
@@ -529,7 +650,7 @@
}
/* ===== EVERFOREST LIGHT ===== */
[data-theme="everforest-light"] {
[data-theme='everforest-light'] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fdf6e3;
@@ -557,7 +678,7 @@
}
/* ===== KANAGAWA ===== */
[data-theme="kanagawa"] {
[data-theme='kanagawa'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #1f1f28;
@@ -585,7 +706,7 @@
}
/* ===== AYU DARK ===== */
[data-theme="ayu-dark"] {
[data-theme='ayu-dark'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0d1017;
@@ -613,7 +734,7 @@
}
/* ===== MOONLIGHT ===== */
[data-theme="moonlight"] {
[data-theme='moonlight'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #212337;
@@ -641,7 +762,7 @@
}
/* ===== CYBERPUNK ===== */
[data-theme="cyberpunk"] {
[data-theme='cyberpunk'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0a0a0f;
@@ -669,7 +790,7 @@
}
/* ===== COTTON CANDY LIGHT ===== */
[data-theme="cotton-candy-light"] {
[data-theme='cotton-candy-light'] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fff5f9;
@@ -695,77 +816,6 @@
--gradient-start: #ff85a2;
--gradient-end: #c084fc;
}
@layer components {
.btn-primary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-accent text-th-accent-t hover:bg-th-accent-h
transition-all duration-200 ease-out
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
.btn-secondary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-bg-s text-th-text border border-th-border
hover:bg-th-hover transition-all duration-200 ease-out
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
.btn-danger {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-error text-white hover:opacity-90
transition-all duration-200 ease-out
focus:outline-none focus:ring-2 focus:ring-th-error focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
.btn-ghost {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
text-th-text-s hover:bg-th-hover hover:text-th-text
transition-all duration-200 ease-out
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
.input-field {
@apply w-full px-4 py-2.5 rounded-lg
bg-th-input text-th-text placeholder-th-text-s
border border-th-input-b
focus:outline-none focus:ring-2 focus:ring-th-ring focus:border-transparent
transition-all duration-200;
}
.card {
@apply bg-th-card rounded-xl border border-th-border
shadow-th transition-all duration-200;
}
.card-hover {
@apply card hover:shadow-th-lg cursor-pointer;
}
.card-hover:hover {
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
}
@layer utilities {
.gradient-text {
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-bg {
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
}
}
/* ═══════════════════════════════════════════════════════════════
+272 -11
View File
@@ -4,7 +4,7 @@ import {
Users, Shield, Search, Trash2, ChevronDown, Loader2,
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
ShieldCheck, Globe, Link as LinkIcon, LogIn,
ShieldCheck, Globe, Link as LinkIcon, LogIn, DoorOpen, Eye, ExternalLink,
} from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
@@ -55,6 +55,14 @@ export default function Admin() {
const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
const [savingOauth, setSavingOauth] = useState(false);
// Rooms state
const [adminRooms, setAdminRooms] = useState([]);
const [adminRoomsLoading, setAdminRoomsLoading] = useState(true);
const [roomSearch, setRoomSearch] = useState('');
const [roomsExpanded, setRoomsExpanded] = useState(false);
const [showAllRoomsModal, setShowAllRoomsModal] = useState(false);
const [allRoomsSearch, setAllRoomsSearch] = useState('');
useEffect(() => {
if (user?.role !== 'admin') {
navigate('/dashboard');
@@ -63,6 +71,7 @@ export default function Admin() {
fetchUsers();
fetchInvites();
fetchOauthConfig();
fetchAdminRooms();
}, [user]);
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) => {
try {
await api.put(`/admin/users/${userId}/role`, { role: newRole });
@@ -470,7 +502,7 @@ export default function Admin() {
type="button"
disabled={savingHideAppName}
onClick={() => handleHideAppNameToggle(!hideAppName)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-1 disabled:opacity-50 ml-4 ${
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-1 disabled:opacity-50 ml-4 ${
hideAppName ? 'bg-th-accent' : 'bg-th-border'
}`}
aria-checked={hideAppName}
@@ -507,7 +539,7 @@ export default function Admin() {
<button
onClick={handleDefaultThemeSave}
disabled={savingDefaultTheme || editDefaultTheme === (defaultTheme || 'dark')}
className="btn-primary text-sm px-4 flex-shrink-0"
className="btn-primary text-sm px-4 shrink-0"
>
{savingDefaultTheme ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
@@ -537,7 +569,7 @@ export default function Admin() {
<button
onClick={handleImprintUrlSave}
disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')}
className="btn-primary text-sm px-4 flex-shrink-0"
className="btn-primary text-sm px-4 shrink-0"
>
{savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
@@ -558,7 +590,7 @@ export default function Admin() {
<button
onClick={handlePrivacyUrlSave}
disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')}
className="btn-primary text-sm px-4 flex-shrink-0"
className="btn-primary text-sm px-4 shrink-0"
>
{savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
@@ -628,7 +660,7 @@ export default function Admin() {
<button
type="submit"
disabled={sendingInvite || !inviteEmail.trim()}
className="btn-primary text-sm px-4 flex-shrink-0"
className="btn-primary text-sm px-4 shrink-0"
>
{sendingInvite ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
{t('admin.sendInvite')}
@@ -644,7 +676,7 @@ export default function Admin() {
return (
<div key={inv.id} className="flex items-center justify-between gap-3 p-3 rounded-xl bg-th-bg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
isUsed ? 'bg-green-500/15 text-green-400' : isExpired ? 'bg-red-500/15 text-red-400' : 'bg-th-accent/15 text-th-accent'
}`}>
{isUsed ? <Check size={14} /> : isExpired ? <XIcon size={14} /> : <Clock size={14} />}
@@ -661,7 +693,7 @@ export default function Admin() {
</p>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className="flex items-center gap-1 shrink-0">
{!isUsed && !isExpired && (
<button
onClick={() => handleCopyInviteLink(inv.token)}
@@ -790,6 +822,122 @@ export default function Admin() {
)}
</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 */}
<div className="card p-4 mb-6">
<div className="relative">
@@ -833,7 +981,7 @@ export default function Admin() {
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden"
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{u.avatar_image ? (
@@ -951,7 +1099,7 @@ export default function Admin() {
{/* Reset password modal */}
{resetPwModal && (
<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={() => setResetPwModal(null)} />
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={() => setResetPwModal(null)} />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-sm p-6">
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.resetPasswordTitle')}</h3>
<form onSubmit={handleResetPassword}>
@@ -983,7 +1131,7 @@ export default function Admin() {
{/* Create user modal */}
{showCreateUser && (
<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={() => setShowCreateUser(false)} />
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={() => setShowCreateUser(false)} />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-md p-6">
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3>
<form onSubmit={handleCreateUser} className="space-y-4">
@@ -1067,6 +1215,119 @@ export default function Admin() {
</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-xs" 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>
);
}
+7 -7
View File
@@ -454,7 +454,7 @@ export default function Calendar() {
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-[10px] leading-tight px-1.5 py-0.5 rounded truncate text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
className="text-[10px] leading-tight px-1.5 py-0.5 rounded-sm truncate text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }}
title={ev.title}
>
@@ -492,11 +492,11 @@ export default function Calendar() {
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-xs px-2 py-1.5 rounded text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
className="text-xs px-2 py-1.5 rounded-sm text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }}
>
<div className="flex items-center gap-1 truncate">
{ev.reminder_minutes && <Bell size={9} className="flex-shrink-0 opacity-70" />}
{ev.reminder_minutes && <Bell size={9} className="shrink-0 opacity-70" />}
<span className="truncate">{ev.title}</span>
</div>
<div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div>
@@ -555,7 +555,7 @@ export default function Calendar() {
/>
</div>
<div className="flex items-center gap-1.5 -mt-2 text-xs text-th-text-s">
<Globe size={12} className="flex-shrink-0" />
<Globe size={12} className="shrink-0" />
<span>{getLocalTimezone()}</span>
</div>
@@ -750,7 +750,7 @@ export default function Calendar() {
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
@@ -772,7 +772,7 @@ export default function Calendar() {
<div key={u.user_id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border border-dashed">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
@@ -799,7 +799,7 @@ export default function Calendar() {
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
+2 -2
View File
@@ -250,7 +250,7 @@ export default function Dashboard() {
type="checkbox"
checked={newRoom.mute_on_join}
onChange={e => setNewRoom({ ...newRoom, mute_on_join: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('dashboard.muteOnJoin')}</span>
</label>
@@ -259,7 +259,7 @@ export default function Dashboard() {
type="checkbox"
checked={newRoom.record_meeting}
onChange={e => setNewRoom({ ...newRoom, record_meeting: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('dashboard.allowRecording')}</span>
</label>
+5 -5
View File
@@ -93,7 +93,7 @@ export default function FederatedRoomDetail() {
{isDeleted && (
<div className="card p-4 mb-4 border-red-500/30 bg-red-500/10">
<div className="flex items-center gap-3">
<AlertTriangle size={20} className="text-red-500 flex-shrink-0" />
<AlertTriangle size={20} className="text-red-500 shrink-0" />
<div>
<p className="text-sm font-semibold text-red-500">{t('federation.roomDeleted')}</p>
<p className="text-xs text-th-text-s mt-0.5">{t('federation.roomDeletedNotice')}</p>
@@ -106,7 +106,7 @@ export default function FederatedRoomDetail() {
<div className="card p-6 mb-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${isDeleted ? 'bg-red-500/15' : 'bg-th-accent/15'}`}>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 mt-0.5 ${isDeleted ? 'bg-red-500/15' : 'bg-th-accent/15'}`}>
{isDeleted ? <AlertTriangle size={20} className="text-red-500" /> : <Globe size={20} className="text-th-accent" />}
</div>
<div className="min-w-0">
@@ -128,7 +128,7 @@ export default function FederatedRoomDetail() {
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
{!isDeleted && (
<button
onClick={handleJoin}
@@ -189,7 +189,7 @@ export default function FederatedRoomDetail() {
{room.meet_id && (
<div className="flex items-start gap-3">
<Hash size={16} className="text-th-accent flex-shrink-0 mt-0.5" />
<Hash size={16} className="text-th-accent shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-xs text-th-text-s mb-0.5">{t('federation.meetingId')}</p>
<p className="text-sm font-mono text-th-text break-all">{room.meet_id}</p>
@@ -198,7 +198,7 @@ export default function FederatedRoomDetail() {
)}
<div className="flex items-start gap-3">
<Link2 size={16} className="text-th-accent flex-shrink-0 mt-0.5" />
<Link2 size={16} className="text-th-accent shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<p className="text-xs text-th-text-s mb-0.5">{t('federation.joinUrl')}</p>
<p className="text-sm font-mono text-th-text break-all opacity-60 select-all">{room.join_url}</p>
+12 -12
View File
@@ -167,7 +167,7 @@ export default function FederationInbox() {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<Mail size={16} className="text-th-accent flex-shrink-0" />
<Mail size={16} className="text-th-accent shrink-0" />
<h3 className="text-base font-semibold text-th-text truncate">{inv.room_name}</h3>
</div>
<p className="text-sm text-th-text-s">
@@ -180,7 +180,7 @@ export default function FederationInbox() {
{new Date(inv.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => handleAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
@@ -200,7 +200,7 @@ export default function FederationInbox() {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<Calendar size={16} className="text-th-success flex-shrink-0" />
<Calendar size={16} className="text-th-success shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide text-th-success mr-1">
{t('federation.calendarEvent')}
</span>
@@ -219,7 +219,7 @@ export default function FederationInbox() {
{new Date(inv.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => handleCalAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
@@ -239,7 +239,7 @@ export default function FederationInbox() {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<Calendar size={16} className="text-th-accent flex-shrink-0" />
<Calendar size={16} className="text-th-accent shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide text-th-accent mr-1">
{t('federation.localCalendarEvent')}
</span>
@@ -258,7 +258,7 @@ export default function FederationInbox() {
{new Date(inv.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => handleLocalCalAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
@@ -287,13 +287,13 @@ export default function FederationInbox() {
<div key={`room-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2">
<Mail size={14} className="text-th-text-s flex-shrink-0" />
<Mail size={14} className="text-th-text-s shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
<p className="text-xs text-th-text-s">{inv.from_user}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
@@ -326,13 +326,13 @@ export default function FederationInbox() {
<div key={`cal-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2">
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
<Calendar size={14} className="text-th-text-s shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
<p className="text-xs text-th-text-s">{inv.from_user} · {new Date(inv.start_time).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
@@ -365,13 +365,13 @@ export default function FederationInbox() {
<div key={`localcal-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2">
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
<Calendar size={14} className="text-th-text-s shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
<p className="text-xs text-th-text-s">{inv.from_name} · {new Date(inv.start_time).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
+104 -86
View File
@@ -1,5 +1,5 @@
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 BrandLogo from '../components/BrandLogo';
import api from '../services/api';
@@ -11,6 +11,7 @@ import { useBranding } from '../contexts/BrandingContext';
export default function GuestJoin() {
const { uid } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { t } = useLanguage();
const { user } = useAuth();
const { imprintUrl, privacyUrl } = useBranding();
@@ -19,7 +20,7 @@ export default function GuestJoin() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
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 [moderatorCode, setModeratorCode] = useState('');
const [status, setStatus] = useState({ running: false });
@@ -30,11 +31,17 @@ export default function GuestJoin() {
const joinMeeting = async () => {
setJoining(true);
try {
const res = await api.post(`/rooms/${uid}/guest-join`, {
const payload = {
name: name.trim(),
access_code: accessCode || 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) {
window.location.href = res.data.joinUrl;
}
@@ -54,6 +61,17 @@ export default function GuestJoin() {
useEffect(() => {
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 {
const res = await api.get(`/rooms/${uid}/public`);
setRoomInfo(res.data.room);
@@ -89,7 +107,7 @@ export default function GuestJoin() {
// Auto-join when meeting starts while waiting
useEffect(() => {
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'));
joinMeeting();
}
@@ -106,7 +124,7 @@ export default function GuestJoin() {
toast.error(t('room.guestRecordingConsent'));
return;
}
if (!status.running) {
if (!status.running && !roomInfo?.anyone_can_start) {
setWaiting(true);
return;
}
@@ -210,97 +228,97 @@ export default function GuestJoin() {
)}
</div>
) : (
<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 && (
<form onSubmit={handleJoin} className="space-y-4">
<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">
<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
type="text"
value={accessCode}
onChange={e => setAccessCode(e.target.value)}
className="input-field pl-11"
placeholder={t('room.guestAccessCodePlaceholder')}
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>
)}
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">
{t('room.guestModeratorCode')}
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
</label>
<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>
{roomInfo.has_access_code && (
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={accessCode}
onChange={e => setAccessCode(e.target.value)}
className="input-field pl-11"
placeholder={t('room.guestAccessCodePlaceholder')}
/>
</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 && (
<p className="text-xs text-th-text-s text-center">
{t('room.guestWaitingMessage')}
</p>
)}
</form>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">
{t('room.guestModeratorCode')}
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
</label>
<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 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-sm 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 && (
+29 -14
View File
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom';
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe, FileText, Lock } from 'lucide-react';
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe, FileText, Lock, Network, CalendarDays, Bell, KeyRound, BarChart2, Settings2 } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
@@ -20,25 +20,40 @@ export default function Home() {
title: t('home.featureRoomsTitle'),
desc: t('home.featureRoomsDesc'),
},
{
icon: Shield,
title: t('home.featureUsersTitle'),
desc: t('home.featureUsersDesc'),
},
{
icon: Palette,
title: t('home.featureThemesTitle'),
desc: t('home.featureThemesDesc'),
},
{
icon: Zap,
title: t('home.featureRecordingsTitle'),
desc: t('home.featureRecordingsDesc'),
},
{
icon: Globe,
title: t('home.featureOpenSourceTitle'),
desc: t('home.featureOpenSourceDesc'),
icon: Network,
title: t('home.featureFederationTitle'),
desc: t('home.featureFederationDesc'),
},
{
icon: CalendarDays,
title: t('home.featureCalendarTitle'),
desc: t('home.featureCalendarDesc'),
},
{
icon: Bell,
title: t('home.featureNotificationsTitle'),
desc: t('home.featureNotificationsDesc'),
},
{
icon: KeyRound,
title: t('home.featureOAuthTitle'),
desc: t('home.featureOAuthDesc'),
},
{
icon: BarChart2,
title: t('home.featureAnalyticsTitle'),
desc: t('home.featureAnalyticsDesc'),
},
{
icon: Palette,
title: t('home.featureThemesTitle'),
desc: t('home.featureThemesDesc'),
},
];
+199 -98
View File
@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn } from 'lucide-react';
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn, ShieldCheck } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
import toast from 'react-hot-toast';
@@ -15,7 +15,14 @@ export default function Login() {
const [needsVerification, setNeedsVerification] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const [resending, setResending] = useState(false);
const { login } = useAuth();
// 2FA state
const [needs2FA, setNeeds2FA] = useState(false);
const [tempToken, setTempToken] = useState('');
const [totpCode, setTotpCode] = useState('');
const [verifying2FA, setVerifying2FA] = useState(false);
const totpInputRef = useRef(null);
const { login, verify2FA } = useAuth();
const { t } = useLanguage();
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
const navigate = useNavigate();
@@ -26,6 +33,13 @@ export default function Login() {
return () => clearTimeout(timer);
}, [resendCooldown]);
// Auto-focus TOTP input when 2FA screen appears
useEffect(() => {
if (needs2FA && totpInputRef.current) {
totpInputRef.current.focus();
}
}, [needs2FA]);
const handleResend = async () => {
if (resendCooldown > 0 || resending) return;
setResending(true);
@@ -48,7 +62,13 @@ export default function Login() {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
const result = await login(email, password);
if (result?.requires2FA) {
setTempToken(result.tempToken);
setNeeds2FA(true);
setLoading(false);
return;
}
toast.success(t('auth.loginSuccess'));
navigate('/dashboard');
} catch (err) {
@@ -62,6 +82,27 @@ export default function Login() {
}
};
const handle2FASubmit = async (e) => {
e.preventDefault();
setVerifying2FA(true);
try {
await verify2FA(tempToken, totpCode);
toast.success(t('auth.loginSuccess'));
navigate('/dashboard');
} catch (err) {
toast.error(err.response?.data?.error || t('auth.2fa.verifyFailed'));
setTotpCode('');
} finally {
setVerifying2FA(false);
}
};
const handleBack = () => {
setNeeds2FA(false);
setTempToken('');
setTotpCode('');
};
return (
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
{/* Animated background */}
@@ -81,111 +122,171 @@ export default function Login() {
<BrandLogo size="lg" />
</div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
<p className="text-th-text-s">
{t('auth.loginSubtitle')}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.emailPlaceholder')}
required
/>
{needs2FA ? (
<>
{/* 2FA verification step */}
<div className="mb-8 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-th-accent/10 mb-4">
<ShieldCheck size={28} className="text-th-accent" />
</div>
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.2fa.title')}</h2>
<p className="text-th-text-s text-sm">
{t('auth.2fa.prompt')}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.passwordPlaceholder')}
required
/>
<form onSubmit={handle2FASubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.2fa.codeLabel')}</label>
<div className="relative">
<ShieldCheck size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
ref={totpInputRef}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
className="input-field pl-11 text-center text-lg tracking-[0.3em] font-mono"
placeholder="000 000"
required
maxLength={7}
/>
</div>
</div>
<button
type="submit"
disabled={verifying2FA || totpCode.replace(/\s/g, '').length < 6}
className="btn-primary w-full py-3"
>
{verifying2FA ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('auth.2fa.verify')}
<ArrowRight size={18} />
</>
)}
</button>
</form>
<button
onClick={handleBack}
className="block mt-4 w-full text-center text-sm text-th-text-s hover:text-th-text transition-colors"
>
{t('auth.2fa.backToLogin')}
</button>
</>
) : (
<>
<div className="mb-8">
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
<p className="text-th-text-s">
{t('auth.loginSubtitle')}
</p>
</div>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full py-3"
>
{loading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.emailPlaceholder')}
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.passwordPlaceholder')}
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full py-3"
>
{loading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
{t('auth.login')}
<ArrowRight size={18} />
</>
)}
</button>
</form>
{oauthEnabled && (
<>
{t('auth.login')}
<ArrowRight size={18} />
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-th-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
</div>
</div>
<a
href="/api/oauth/authorize"
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
>
<LogIn size={18} />
{t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
</a>
</>
)}
</button>
</form>
{oauthEnabled && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-th-border" />
{needsVerification && (
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
<div className="flex items-start gap-2">
<AlertTriangle size={16} className="text-amber-400 shrink-0 mt-0.5" />
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
</div>
<button
onClick={handleResend}
disabled={resendCooldown > 0 || resending}
className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
>
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
{resendCooldown > 0
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
: t('auth.emailVerificationResend')}
</button>
</div>
<div className="relative flex justify-center text-xs">
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
</div>
</div>
<a
href="/api/oauth/authorize"
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
>
<LogIn size={18} />
{t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
</a>
)}
{registrationMode !== 'invite' && (
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-th-accent hover:underline font-medium">
{t('auth.signUpNow')}
</Link>
</p>
)}
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToHome')}
</Link>
</>
)}
{needsVerification && (
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
<div className="flex items-start gap-2">
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
</div>
<button
onClick={handleResend}
disabled={resendCooldown > 0 || resending}
className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
>
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
{resendCooldown > 0
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
: t('auth.emailVerificationResend')}
</button>
</div>
)}
{registrationMode !== 'invite' && (
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-th-accent hover:underline font-medium">
{t('auth.signUpNow')}
</Link>
</p>
)}
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToHome')}
</Link>
</div>
</div>
</div>
+96 -31
View File
@@ -51,6 +51,7 @@ export default function RoomDetail() {
// Federation invite state
const [showFedInvite, setShowFedInvite] = useState(false);
const [fedAddress, setFedAddress] = useState('');
const [fedEmails, setFedEmails] = useState('');
const [fedMessage, setFedMessage] = useState('');
const [fedSending, setFedSending] = useState(false);
@@ -61,7 +62,10 @@ export default function RoomDetail() {
const isOwner = room && user && room.user_id === user.id;
const isShared = room && !!room.shared;
const isAdmin = user?.role === 'admin';
const canManage = isOwner || isShared;
const canStart = canManage || isAdmin || !!room?.anyone_can_start;
const canEnd = canManage || isAdmin;
const fetchRoom = async () => {
try {
@@ -196,6 +200,7 @@ export default function RoomDetail() {
guest_access: !!editRoom.guest_access,
moderator_code: editRoom.moderator_code,
learning_analytics: !!editRoom.learning_analytics,
analytics_visibility: editRoom.analytics_visibility || 'owner',
});
setRoom(res.data.room);
setEditRoom(res.data.room);
@@ -265,25 +270,51 @@ export default function RoomDetail() {
const handleFedInvite = async (e) => {
e.preventDefault();
// Accept @user@domain or user@domain — must have a domain part
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
if (!normalized.includes('@') || normalized.endsWith('@')) {
const hasAddress = fedAddress.trim().length > 0;
const hasEmails = fedEmails.trim().length > 0;
if (!hasAddress && !hasEmails) {
toast.error(t('federation.addressHint'));
return;
}
setFedSending(true);
try {
await api.post('/federation/invite', {
room_uid: uid,
to: fedAddress,
message: fedMessage || undefined,
});
toast.success(t('federation.sent'));
if (hasAddress) {
// Federation address mode
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
if (!normalized.includes('@') || normalized.endsWith('@')) {
toast.error(t('federation.addressHint'));
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);
setFedAddress('');
setFedEmails('');
setFedMessage('');
} catch (err) {
toast.error(err.response?.data?.error || t('federation.sendFailed'));
toast.error(err.response?.data?.error || t(hasAddress ? 'federation.sendFailed' : 'federation.emailSendFailed'));
} finally {
setFedSending(false);
}
@@ -344,7 +375,7 @@ export default function RoomDetail() {
const tabs = [
{ id: 'overview', label: t('room.overview'), icon: Play },
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length },
{ id: 'analytics', label: t('room.analytics'), icon: BarChart3, count: analytics.length },
{ id: 'analytics', label: t('room.analytics'), icon: BarChart3, count: analytics.length, hidden: !room.learning_analytics || (isShared && room.analytics_visibility !== 'shared') },
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
];
@@ -424,7 +455,7 @@ export default function RoomDetail() {
<span className="hidden sm:inline">{t('federation.inviteRemote')}</span>
</button>
)}
{canManage && !status.running && !waitingToJoin && (
{canStart && !status.running && !waitingToJoin && (
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
{t('room.start')}
@@ -439,7 +470,7 @@ export default function RoomDetail() {
{(actionLoading === 'join' || waitingToJoin) ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
{waitingToJoin ? t('room.waitingToJoin') : t('room.join')}
</button>
{canManage && status.running && (
{canEnd && status.running && (
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
{t('room.end')}
@@ -451,7 +482,7 @@ export default function RoomDetail() {
{/* Tabs */}
<div className="flex items-center gap-1 mb-6 border-b border-th-border">
{tabs.map(tab => (
{tabs.filter(tab => !tab.hidden).map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
@@ -480,7 +511,7 @@ export default function RoomDetail() {
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-th-text-s">{t('room.meetingId')}</span>
<code className="bg-th-bg-s px-2 py-0.5 rounded text-xs text-th-text font-mono">{room.uid}</code>
<code className="bg-th-bg-s px-2 py-0.5 rounded-sm text-xs text-th-text font-mono">{room.uid}</code>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-th-text-s">{t('room.status')}</span>
@@ -543,7 +574,7 @@ export default function RoomDetail() {
)}
{activeTab === 'analytics' && (
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} />
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} isOwner={isOwner} />
)}
{activeTab === 'settings' && isOwner && editRoom && (
@@ -599,7 +630,7 @@ export default function RoomDetail() {
type="checkbox"
checked={!!editRoom.mute_on_join}
onChange={e => setEditRoom({ ...editRoom, mute_on_join: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.muteOnJoin')}</span>
</label>
@@ -608,7 +639,7 @@ export default function RoomDetail() {
type="checkbox"
checked={!!editRoom.require_approval}
onChange={e => setEditRoom({ ...editRoom, require_approval: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.requireApproval')}</span>
</label>
@@ -617,7 +648,7 @@ export default function RoomDetail() {
type="checkbox"
checked={!!editRoom.anyone_can_start}
onChange={e => setEditRoom({ ...editRoom, anyone_can_start: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.anyoneCanStart')}</span>
</label>
@@ -626,7 +657,7 @@ export default function RoomDetail() {
type="checkbox"
checked={!!editRoom.all_join_moderator}
onChange={e => setEditRoom({ ...editRoom, all_join_moderator: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.allJoinModerator')}</span>
</label>
@@ -635,7 +666,7 @@ export default function RoomDetail() {
type="checkbox"
checked={!!editRoom.record_meeting}
onChange={e => setEditRoom({ ...editRoom, record_meeting: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.allowRecording')}</span>
</label>
@@ -644,10 +675,24 @@ export default function RoomDetail() {
type="checkbox"
checked={!!editRoom.learning_analytics}
onChange={e => setEditRoom({ ...editRoom, learning_analytics: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
</label>
{!!editRoom.learning_analytics && (
<div className="ml-7">
<label className="block text-xs font-medium text-th-text-s mb-1">{t('room.analyticsVisibility')}</label>
<select
value={editRoom.analytics_visibility || 'owner'}
onChange={e => setEditRoom({ ...editRoom, analytics_visibility: e.target.value })}
className="input-field text-sm py-1.5 max-w-xs"
>
<option value="owner">{t('room.analyticsOwnerOnly')}</option>
<option value="shared">{t('room.analyticsSharedUsers')}</option>
</select>
<p className="text-xs text-th-text-s mt-1">{t('room.analyticsVisibilityHint')}</p>
</div>
)}
</div>
{/* Guest access section */}
@@ -698,11 +743,11 @@ export default function RoomDetail() {
{room.presentation_file ? (
<div className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-2 min-w-0">
<FileText size={16} className="text-th-accent flex-shrink-0" />
<FileText size={16} className="text-th-accent shrink-0" />
<div className="min-w-0">
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
<p className="text-sm text-th-text font-medium truncate">
{room.presentation_name || `presentation.${room.presentation_file?.split('.').pop()}`}
{room.presentation_file}
</p>
</div>
</div>
@@ -710,7 +755,7 @@ export default function RoomDetail() {
type="button"
onClick={handlePresentationRemove}
disabled={removingPresentation}
className="btn-ghost text-th-error hover:bg-th-error/10 flex-shrink-0 text-xs py-1.5 px-3"
className="btn-ghost text-th-error hover:bg-th-error/10 shrink-0 text-xs py-1.5 px-3"
>
{removingPresentation ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
{t('room.presentationRemove')}
@@ -769,7 +814,7 @@ export default function RoomDetail() {
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{u.avatar_image ? (
@@ -795,7 +840,7 @@ export default function RoomDetail() {
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{u.avatar_image ? (
@@ -812,7 +857,7 @@ export default function RoomDetail() {
<button
type="button"
onClick={() => handleUnshare(u.id)}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors flex-shrink-0"
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors shrink-0"
title={t('room.shareRemove')}
>
<X size={16} />
@@ -842,13 +887,33 @@ export default function RoomDetail() {
<input
type="text"
value={fedAddress}
onChange={e => setFedAddress(e.target.value)}
onChange={e => { setFedAddress(e.target.value); if (e.target.value) setFedEmails(''); }}
className="input-field"
placeholder={t('federation.addressPlaceholder')}
required
disabled={fedEmails.trim().length > 0}
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
</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>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
<textarea
@@ -863,7 +928,7 @@ export default function RoomDetail() {
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
{t('common.cancel')}
</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} />}
{t('federation.send')}
</button>
+218 -8
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff } from 'lucide-react';
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff, Shield, ShieldCheck, ShieldOff } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
import { useLanguage } from '../contexts/LanguageContext';
@@ -46,6 +46,17 @@ export default function Settings() {
const [newlyCreatedToken, setNewlyCreatedToken] = useState(null);
const [tokenVisible, setTokenVisible] = useState(false);
// 2FA state
const [twoFaEnabled, setTwoFaEnabled] = useState(!!user?.totp_enabled);
const [twoFaLoading, setTwoFaLoading] = useState(false);
const [twoFaSetupData, setTwoFaSetupData] = useState(null); // { secret, uri, qrDataUrl }
const [twoFaCode, setTwoFaCode] = useState('');
const [twoFaEnabling, setTwoFaEnabling] = useState(false);
const [twoFaDisablePassword, setTwoFaDisablePassword] = useState('');
const [twoFaDisableCode, setTwoFaDisableCode] = useState('');
const [twoFaDisabling, setTwoFaDisabling] = useState(false);
const [showDisableForm, setShowDisableForm] = useState(false);
useEffect(() => {
if (activeSection === 'caldav') {
setCaldavLoading(true);
@@ -54,6 +65,13 @@ export default function Settings() {
.catch(() => {})
.finally(() => setCaldavLoading(false));
}
if (activeSection === 'security') {
setTwoFaLoading(true);
api.get('/auth/2fa/status')
.then(r => setTwoFaEnabled(r.data.enabled))
.catch(() => {})
.finally(() => setTwoFaLoading(false));
}
}, [activeSection]);
const handleCreateToken = async (e) => {
@@ -85,6 +103,56 @@ export default function Settings() {
}
};
// 2FA handlers
const handleSetup2FA = async () => {
setTwoFaLoading(true);
try {
const res = await api.post('/auth/2fa/setup');
// Generate QR code data URL client-side
const QRCode = (await import('qrcode')).default;
const qrDataUrl = await QRCode.toDataURL(res.data.uri, { width: 200, margin: 2, color: { dark: '#000000', light: '#ffffff' } });
setTwoFaSetupData({ secret: res.data.secret, uri: res.data.uri, qrDataUrl });
} catch (err) {
toast.error(err.response?.data?.error || t('settings.security.setupFailed'));
} finally {
setTwoFaLoading(false);
}
};
const handleEnable2FA = async (e) => {
e.preventDefault();
setTwoFaEnabling(true);
try {
await api.post('/auth/2fa/enable', { code: twoFaCode });
setTwoFaEnabled(true);
setTwoFaSetupData(null);
setTwoFaCode('');
toast.success(t('settings.security.enabled'));
} catch (err) {
toast.error(err.response?.data?.error || t('settings.security.enableFailed'));
setTwoFaCode('');
} finally {
setTwoFaEnabling(false);
}
};
const handleDisable2FA = async (e) => {
e.preventDefault();
setTwoFaDisabling(true);
try {
await api.post('/auth/2fa/disable', { password: twoFaDisablePassword, code: twoFaDisableCode });
setTwoFaEnabled(false);
setShowDisableForm(false);
setTwoFaDisablePassword('');
setTwoFaDisableCode('');
toast.success(t('settings.security.disabled'));
} catch (err) {
toast.error(err.response?.data?.error || t('settings.security.disableFailed'));
} finally {
setTwoFaDisabling(false);
}
};
const groups = getThemeGroups();
const avatarColors = [
@@ -184,6 +252,7 @@ export default function Settings() {
const sections = [
{ id: 'profile', label: t('settings.profile'), icon: User },
{ id: 'password', label: t('settings.password'), icon: Lock },
{ id: 'security', label: t('settings.security.title'), icon: Shield },
{ id: 'language', label: t('settings.language'), icon: Globe },
{ id: 'themes', label: t('settings.themes'), icon: Palette },
{ id: 'caldav', label: t('settings.caldav.title'), icon: Calendar },
@@ -198,7 +267,7 @@ export default function Settings() {
<div className="flex flex-col md:flex-row gap-6">
{/* Section nav */}
<div className="md:w-56 flex-shrink-0">
<div className="md:w-56 shrink-0">
<nav className="flex md:flex-col gap-1">
{sections.map(s => (
<button
@@ -411,6 +480,147 @@ export default function Settings() {
</div>
)}
{/* Security / 2FA section */}
{activeSection === 'security' && (
<div className="space-y-5">
<div className="card p-6">
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.security.title')}</h2>
<p className="text-sm text-th-text-s mb-6">{t('settings.security.subtitle')}</p>
{twoFaLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={24} className="animate-spin text-th-text-s" />
</div>
) : twoFaEnabled ? (
/* 2FA is enabled */
<div>
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 mb-5">
<ShieldCheck size={22} className="text-emerald-400 shrink-0" />
<div>
<p className="text-sm font-medium text-emerald-300">{t('settings.security.statusEnabled')}</p>
<p className="text-xs text-emerald-400/70">{t('settings.security.statusEnabledDesc')}</p>
</div>
</div>
{!showDisableForm ? (
<button
onClick={() => setShowDisableForm(true)}
className="btn-ghost text-th-error hover:text-th-error text-sm"
>
<ShieldOff size={16} />
{t('settings.security.disable')}
</button>
) : (
<form onSubmit={handleDisable2FA} className="space-y-4 p-4 rounded-xl bg-th-bg-t border border-th-border">
<p className="text-sm text-th-text-s">{t('settings.security.disableConfirm')}</p>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="password"
value={twoFaDisablePassword}
onChange={e => setTwoFaDisablePassword(e.target.value)}
className="input-field pl-11"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.codeLabel')}</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={twoFaDisableCode}
onChange={e => setTwoFaDisableCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
className="input-field text-center text-lg tracking-[0.3em] font-mono"
placeholder="000 000"
required
maxLength={7}
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={twoFaDisabling} className="btn-primary bg-red-600 hover:bg-red-700 border-red-600">
{twoFaDisabling ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
{t('settings.security.disable')}
</button>
<button type="button" onClick={() => { setShowDisableForm(false); setTwoFaDisablePassword(''); setTwoFaDisableCode(''); }} className="btn-ghost text-sm">
{t('common.cancel')}
</button>
</div>
</form>
)}
</div>
) : twoFaSetupData ? (
/* Setup flow: show QR code + verification */
<div className="space-y-5">
<div className="text-center">
<p className="text-sm text-th-text mb-4">{t('settings.security.scanQR')}</p>
<div className="inline-block p-3 bg-white rounded-xl">
<img src={twoFaSetupData.qrDataUrl} alt="TOTP QR Code" className="w-[200px] h-[200px]" />
</div>
</div>
<div>
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.security.manualKey')}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-accent font-mono break-all">
{twoFaSetupData.secret}
</code>
<button
onClick={() => { navigator.clipboard.writeText(twoFaSetupData.secret); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 shrink-0"
>
<Copy size={14} />
</button>
</div>
</div>
<form onSubmit={handleEnable2FA} className="space-y-3">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.verifyCode')}</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={twoFaCode}
onChange={e => setTwoFaCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
className="input-field text-center text-lg tracking-[0.3em] font-mono"
placeholder="000 000"
required
maxLength={7}
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={twoFaEnabling || twoFaCode.replace(/\s/g, '').length < 6} className="btn-primary">
{twoFaEnabling ? <Loader2 size={14} className="animate-spin" /> : <ShieldCheck size={14} />}
{t('settings.security.enable')}
</button>
<button type="button" onClick={() => { setTwoFaSetupData(null); setTwoFaCode(''); }} className="btn-ghost text-sm">
{t('common.cancel')}
</button>
</div>
</form>
</div>
) : (
/* 2FA is disabled — show enable button */
<div>
<div className="flex items-center gap-3 p-4 rounded-xl bg-th-bg-t border border-th-border mb-5">
<ShieldOff size={22} className="text-th-text-s shrink-0" />
<div>
<p className="text-sm font-medium text-th-text">{t('settings.security.statusDisabled')}</p>
<p className="text-xs text-th-text-s">{t('settings.security.statusDisabledDesc')}</p>
</div>
</div>
<button onClick={handleSetup2FA} disabled={twoFaLoading} className="btn-primary">
{twoFaLoading ? <Loader2 size={16} className="animate-spin" /> : <Shield size={16} />}
{t('settings.security.enable')}
</button>
</div>
)}
</div>
</div>
)}
{/* Language section */}
{activeSection === 'language' && (
<div className="card p-6">
@@ -457,7 +667,7 @@ export default function Settings() {
>
{/* Color preview */}
<div
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 border"
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 border"
style={{ backgroundColor: th.colors.bg, borderColor: th.colors.accent + '40' }}
>
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: th.colors.accent }} />
@@ -489,7 +699,7 @@ export default function Settings() {
</code>
<button
onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/caldav/`); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0"
className="btn-ghost py-1.5 px-2 shrink-0"
>
<Copy size={14} />
</button>
@@ -503,7 +713,7 @@ export default function Settings() {
</code>
<button
onClick={() => { navigator.clipboard.writeText(user?.email || ''); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0"
className="btn-ghost py-1.5 px-2 shrink-0"
>
<Copy size={14} />
</button>
@@ -522,12 +732,12 @@ export default function Settings() {
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg font-mono text-th-text break-all">
{tokenVisible ? newlyCreatedToken : '•'.repeat(48)}
</code>
<button onClick={() => setTokenVisible(v => !v)} className="btn-ghost py-1.5 px-2 flex-shrink-0">
<button onClick={() => setTokenVisible(v => !v)} className="btn-ghost py-1.5 px-2 shrink-0">
{tokenVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
onClick={() => { navigator.clipboard.writeText(newlyCreatedToken); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0"
className="btn-ghost py-1.5 px-2 shrink-0"
>
<Copy size={14} />
</button>
@@ -580,7 +790,7 @@ export default function Settings() {
</div>
<button
onClick={() => handleRevokeToken(tk.id)}
className="btn-ghost py-1 px-2 text-th-error hover:text-th-error flex-shrink-0"
className="btn-ghost py-1 px-2 text-th-error hover:text-th-error shrink-0"
title={t('settings.caldav.revoke')}
>
<Trash2 size={14} />
-39
View File
@@ -1,39 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
th: {
bg: 'var(--bg-primary)',
'bg-s': 'var(--bg-secondary)',
'bg-t': 'var(--bg-tertiary)',
text: 'var(--text-primary)',
'text-s': 'var(--text-secondary)',
accent: 'var(--accent)',
'accent-h': 'var(--accent-hover)',
'accent-t': 'var(--accent-text)',
border: 'var(--border)',
card: 'var(--card-bg)',
input: 'var(--input-bg)',
'input-b': 'var(--input-border)',
nav: 'var(--nav-bg)',
side: 'var(--sidebar-bg)',
hover: 'var(--hover-bg)',
success: 'var(--success)',
warning: 'var(--warning)',
error: 'var(--error)',
ring: 'var(--ring)',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
boxShadow: {
'th': '0 1px 3px 0 var(--shadow-color), 0 1px 2px -1px var(--shadow-color)',
'th-lg': '0 10px 15px -3px var(--shadow-color), 0 4px 6px -4px var(--shadow-color)',
},
},
},
plugins: [],
};