13 Commits
2.0.0 ... main

Author SHA1 Message Date
8cbe28f915 chore: bump version to 2.1.2 and update user name handling in GuestJoin component
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m28s
Build & Push Docker Image / build (release) Successful in 4m20s
2026-03-26 09:40:41 +01:00
5472e190d9 chore: Bump version to 2.1.1
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m19s
Build & Push Docker Image / build (release) Successful in 4m12s
2026-03-25 11:34:38 +01:00
45be976de1 Don't show guestWaitingMessage when "anyone_can_start" is set
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m14s
2026-03-25 10:13:02 +01:00
6dcb1e959b feat: allow guests to start a room if anyone_can_start is enabled
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m58s
2026-03-25 09:55:47 +01:00
bb2d179871 style: Update button styling and icon size in RecordingList component for improved UI
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m29s
2026-03-24 11:28:15 +01:00
82b7d060ba Merge remote-tracking branch 'refs/remotes/origin/main'
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m34s
2026-03-16 13:32:45 +01:00
0836436fe7 feat: Implement Two-Factor Authentication (2FA) for enhanced user account security. 2026-03-16 13:28:43 +01:00
99d3b22f62 chore: update bcryptjs and better-sqlite3 dependencies; upgrade dotenv version
All checks were successful
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
eed5d98ccc chore: update dependencies for Vite and React plugin
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m9s
2026-03-13 22:48:08 +01:00
6513fdee41 fix(Dockerfile): update base image and streamline build stages
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m17s
2026-03-13 22:41:18 +01:00
cae84754e4 feat: add analytics visibility settings and export functionality
All checks were successful
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
a0a972b53a fix(NotificationContext): ensure audio playback is unlocked only for authenticated users
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m32s
2026-03-13 13:00:54 +01:00
9b98803053 fix(NotificationContext): handle user ID for notifications fetching and prevent stale responses
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m6s
2026-03-13 12:43:20 +01:00
19 changed files with 2801 additions and 2043 deletions

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

3494
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "redlight",
"private": true,
"version": "2.0.0",
"version": "2.1.2",
"license": "GPL-3.0-or-later",
"type": "module",
"scripts": {
@@ -14,11 +14,12 @@
},
"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",
"dotenv": "^17.3.1",
"exceljs": "^4.4.0",
"express": "^4.21.0",
"express-rate-limit": "^7.5.1",
"flatpickr": "^4.6.13",
@@ -27,7 +28,10 @@
"lucide-react": "^0.460.0",
"multer": "^2.1.0",
"nodemailer": "^8.0.1",
"otpauth": "^9.5.0",
"pdfkit": "^0.17.2",
"pg": "^8.18.0",
"qrcode": "^1.5.4",
"rate-limit-redis": "^4.3.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
@@ -39,10 +43,10 @@
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.7.0",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"vite": "^6.4.1"
"vite": "^8.0.0"
}
}

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,19 @@ 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'");
}
// ── Default admin (only on very first start) ────────────────────────────
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
if (!adminAlreadySeeded) {

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' });
}

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';
@@ -72,14 +74,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 +139,171 @@ 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') {
const header = COLUMNS.map(c => c.header).join(',');
const csvRows = rows.map(r =>
COLUMNS.map(c => {
const val = r[c.key];
if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n'))) {
return '"' + val.replace(/"/g, '""') + '"';
}
return val;
}).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') {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Analytics');
sheet.columns = COLUMNS;
rows.forEach(r => sheet.addRow(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;

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';
@@ -99,6 +100,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');
@@ -352,8 +362,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 +378,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 {
@@ -670,4 +733,125 @@ 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' });
}
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;

View File

@@ -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.2',
});
}

View File

@@ -245,6 +245,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 +286,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 +302,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,
]);

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) => {
@@ -124,6 +153,38 @@ export default function AnalyticsList({ analytics, onRefresh }) {
</div>
<div className="flex items-center gap-1 flex-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>

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>
))}

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>
);

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();

View File

@@ -1,4 +1,4 @@
{
{
"common": {
"appName": "Redlight",
"loading": "Laden...",
@@ -99,7 +99,15 @@
"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",
@@ -258,7 +266,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 +307,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 +342,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.",

View File

@@ -1,4 +1,4 @@
{
{
"common": {
"appName": "Redlight",
"loading": "Loading...",
@@ -99,7 +99,15 @@
"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",
@@ -258,7 +266,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 +307,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 +342,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.",

View File

@@ -19,7 +19,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 });
@@ -106,7 +106,7 @@ export default function GuestJoin() {
toast.error(t('room.guestRecordingConsent'));
return;
}
if (!status.running) {
if (!status.running && !roomInfo?.anyone_can_start) {
setWaiting(true);
return;
}
@@ -295,7 +295,7 @@ export default function GuestJoin() {
)}
</button>
{!status.running && (
{!status.running && !roomInfo?.anyone_can_start && (
<p className="text-xs text-th-text-s text-center">
{t('room.guestWaitingMessage')}
</p>

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,6 +122,64 @@ export default function Login() {
<BrandLogo size="lg" />
</div>
{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>
<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">
@@ -186,6 +285,8 @@ export default function Login() {
<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>

View File

@@ -196,6 +196,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);
@@ -344,7 +345,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 }] : []),
];
@@ -451,7 +452,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)}
@@ -543,7 +544,7 @@ export default function RoomDetail() {
)}
{activeTab === 'analytics' && (
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} />
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} isOwner={isOwner} />
)}
{activeTab === 'settings' && isOwner && editRoom && (
@@ -648,6 +649,20 @@ export default function RoomDetail() {
/>
<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 */}

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 },
@@ -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 flex-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 flex-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 flex-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">