Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cbe28f915 | |||
| 5472e190d9 | |||
| 45be976de1 | |||
| 6dcb1e959b | |||
| bb2d179871 | |||
| 82b7d060ba | |||
| 0836436fe7 | |||
| 99d3b22f62 | |||
| eed5d98ccc | |||
| 6513fdee41 | |||
| cae84754e4 | |||
| a0a972b53a | |||
| 9b98803053 |
27
Dockerfile
27
Dockerfile
@@ -1,28 +1,31 @@
|
|||||||
# ── Stage 1: Build frontend ──────────────────────────────────────────────────
|
# ── Stage 1: Install dependencies ────────────────────────────────────────────
|
||||||
FROM node:20-bullseye-slim AS builder
|
FROM node:22-trixie-slim AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
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 \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
# Install all dependencies (including dev for vite build)
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# ── Stage 2: Build frontend ─────────────────────────────────────────────────
|
||||||
|
FROM deps AS builder
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
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
|
# ── Stage 3: Production image ───────────────────────────────────────────────
|
||||||
|
FROM node:22-trixie-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}
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
3494
package-lock.json
generated
3494
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "redlight",
|
"name": "redlight",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0",
|
"version": "2.1.2",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,11 +14,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^12.6.2",
|
||||||
"concurrently": "^9.0.0",
|
"concurrently": "^9.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^17.3.1",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^7.5.1",
|
"express-rate-limit": "^7.5.1",
|
||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
@@ -27,7 +28,10 @@
|
|||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"multer": "^2.1.0",
|
"multer": "^2.1.0",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
|
"otpauth": "^9.5.0",
|
||||||
|
"pdfkit": "^0.17.2",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"rate-limit-redis": "^4.3.1",
|
"rate-limit-redis": "^4.3.1",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
@@ -39,10 +43,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^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",
|
"autoprefixer": "^10.4.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vite": "^6.4.1"
|
"vite": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { log } from './logger.js';
|
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) ────────────────────────────
|
// ── Default admin (only on very first start) ────────────────────────────
|
||||||
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
||||||
if (!adminAlreadySeeded) {
|
if (!adminAlreadySeeded) {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function authenticateToken(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
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) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: 'User not found' });
|
return res.status(401).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import { authenticateToken } from '../middleware/auth.js';
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
import { log } from '../config/logger.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) => {
|
router.get('/room/:uid', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
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) {
|
if (!room) {
|
||||||
return res.status(404).json({ error: 'Room not found' });
|
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.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]);
|
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||||
if (!share) {
|
if (!share) {
|
||||||
return res.status(403).json({ error: 'No permission to view analytics for this room' });
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@@ -7,6 +7,7 @@ import path from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { rateLimit } from 'express-rate-limit';
|
import { rateLimit } from 'express-rate-limit';
|
||||||
import { RedisStore } from 'rate-limit-redis';
|
import { RedisStore } from 'rate-limit-redis';
|
||||||
|
import * as OTPAuth from 'otpauth';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import redis from '../config/redis.js';
|
import redis from '../config/redis.js';
|
||||||
import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js';
|
import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js';
|
||||||
@@ -99,6 +100,15 @@ const resendVerificationLimiter = rateLimit({
|
|||||||
store: makeRedisStore('rl:resend:'),
|
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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
|
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 });
|
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 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 });
|
res.json({ token, user: safeUser });
|
||||||
} catch (err) {
|
} 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
|
// POST /api/auth/logout - revoke JWT via DragonflyDB blacklist
|
||||||
router.post('/logout', authenticateToken, async (req, res) => {
|
router.post('/logout', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -670,4 +733,125 @@ router.get('/avatar/:filename', (req, res) => {
|
|||||||
fs.createReadStream(filepath).pipe(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;
|
export default router;
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import { createNotification } from '../config/notifications.js';
|
|||||||
|
|
||||||
// M13: rate limit the unauthenticated federation receive endpoint
|
// M13: rate limit the unauthenticated federation receive endpoint
|
||||||
const federationReceiveLimiter = rateLimit({
|
const federationReceiveLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
max: 100,
|
max: 100,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: { error: 'Too many federation requests. Please try again later.' },
|
message: { error: 'Too many federation requests. Please try again later.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -40,7 +40,7 @@ export function wellKnownHandler(req, res) {
|
|||||||
federation_api: '/api/federation',
|
federation_api: '/api/federation',
|
||||||
public_key: getPublicKey(),
|
public_key: getPublicKey(),
|
||||||
software: 'Redlight',
|
software: 'Redlight',
|
||||||
version: '2.0.0',
|
version: '2.1.2',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,24 +236,24 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
|
|||||||
|
|
||||||
// Send notification email (truly fire-and-forget - never blocks the response)
|
// Send notification email (truly fire-and-forget - never blocks the response)
|
||||||
if (targetUser.email) {
|
if (targetUser.email) {
|
||||||
const appUrl = getBaseUrl(req);
|
const appUrl = getBaseUrl(req);
|
||||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||||
const appName = process.env.APP_NAME || 'Redlight';
|
const appName = process.env.APP_NAME || 'Redlight';
|
||||||
sendFederationInviteEmail(
|
sendFederationInviteEmail(
|
||||||
targetUser.email, targetUser.name, from_user,
|
targetUser.email, targetUser.name, from_user,
|
||||||
room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
|
room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
|
||||||
).catch(mailErr => {
|
).catch(mailErr => {
|
||||||
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
|
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-app notification
|
// In-app notification
|
||||||
await createNotification(
|
await createNotification(
|
||||||
targetUser.id,
|
targetUser.id,
|
||||||
'federation_invite_received',
|
'federation_invite_received',
|
||||||
from_user,
|
from_user,
|
||||||
room_name,
|
room_name,
|
||||||
'/federation/inbox',
|
'/federation/inbox',
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
|||||||
guest_access,
|
guest_access,
|
||||||
moderator_code,
|
moderator_code,
|
||||||
learning_analytics,
|
learning_analytics,
|
||||||
|
analytics_visibility,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// M12: field length limits (same as create)
|
// M12: field length limits (same as create)
|
||||||
@@ -285,6 +286,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
|||||||
guest_access = COALESCE(?, guest_access),
|
guest_access = COALESCE(?, guest_access),
|
||||||
moderator_code = ?,
|
moderator_code = ?,
|
||||||
learning_analytics = COALESCE(?, learning_analytics),
|
learning_analytics = COALESCE(?, learning_analytics),
|
||||||
|
analytics_visibility = COALESCE(?, analytics_visibility),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE uid = ?
|
WHERE uid = ?
|
||||||
`, [
|
`, [
|
||||||
@@ -300,6 +302,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
|||||||
guest_access !== undefined ? (guest_access ? 1 : 0) : null,
|
guest_access !== undefined ? (guest_access ? 1 : 0) : null,
|
||||||
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
|
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
|
||||||
learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null,
|
learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null,
|
||||||
|
analytics_visibility && ['owner', 'shared'].includes(analytics_visibility) ? analytics_visibility : null,
|
||||||
req.params.uid,
|
req.params.uid,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useState } from 'react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import toast from 'react-hot-toast';
|
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 [loading, setLoading] = useState({});
|
||||||
const [expanded, setExpanded] = useState({});
|
const [expanded, setExpanded] = useState({});
|
||||||
|
const [exportMenu, setExportMenu] = useState({});
|
||||||
const { t, language } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
@@ -49,6 +50,34 @@ export default function AnalyticsList({ analytics, onRefresh }) {
|
|||||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
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
|
// 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 } }] } }
|
// Payload: { meeting_id, data: { duration, start, finish, attendees: [{ name, moderator, duration, engagement: { chats, talks, raisehand, emojis, poll_votes, talk_time } }] } }
|
||||||
const getUserSummary = (data) => {
|
const getUserSummary = (data) => {
|
||||||
@@ -124,6 +153,38 @@ 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 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
|
<button
|
||||||
onClick={() => toggleExpand(entry.id)}
|
onClick={() => toggleExpand(entry.id)}
|
||||||
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
|
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} />}
|
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
{isOwner && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(entry.id)}
|
onClick={() => handleDelete(entry.id)}
|
||||||
disabled={loading[entry.id] === 'deleting'}
|
disabled={loading[entry.id] === 'deleting'}
|
||||||
@@ -139,6 +201,7 @@ export default function AnalyticsList({ analytics, onRefresh }) {
|
|||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -122,9 +122,9 @@ export default function RecordingList({ recordings, onRefresh }) {
|
|||||||
href={format.url}
|
href={format.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-th-accent/10 text-th-accent text-xs font-medium hover:bg-th-accent/20 transition-colors"
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-th-accent/10 text-th-accent text-sm font-medium hover:bg-th-accent/20 transition-colors"
|
||||||
>
|
>
|
||||||
<Play size={12} />
|
<Play size={14} />
|
||||||
{format.type === 'presentation' ? t('recordings.presentation') : format.type}
|
{format.type === 'presentation' ? t('recordings.presentation') : format.type}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ export function AuthProvider({ children }) {
|
|||||||
|
|
||||||
const login = useCallback(async (email, password) => {
|
const login = useCallback(async (email, password) => {
|
||||||
const res = await api.post('/auth/login', { 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);
|
localStorage.setItem('token', res.data.token);
|
||||||
setUser(res.data.user);
|
setUser(res.data.user);
|
||||||
return res.data.user;
|
return res.data.user;
|
||||||
@@ -75,7 +85,7 @@ export function AuthProvider({ children }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, register, logout, loginWithOAuth, updateUser }}>
|
<AuthContext.Provider value={{ user, loading, login, verify2FA, register, logout, loginWithOAuth, updateUser }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,14 +49,20 @@ export function NotificationProvider({ children }) {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const activeUserId = useRef(null);
|
||||||
// Track seen IDs to detect genuinely new arrivals and show toasts
|
// Track seen IDs to detect genuinely new arrivals and show toasts
|
||||||
const seenIds = useRef(new Set());
|
const seenIds = useRef(new Set());
|
||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
|
|
||||||
const fetch = useCallback(async () => {
|
const fetch = useCallback(async () => {
|
||||||
if (!user) return;
|
const requestUserId = user?.id;
|
||||||
|
if (!requestUserId) return;
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/notifications');
|
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 || [];
|
const incoming = res.data.notifications || [];
|
||||||
setNotifications(incoming);
|
setNotifications(incoming);
|
||||||
setUnreadCount(res.data.unreadCount || 0);
|
setUnreadCount(res.data.unreadCount || 0);
|
||||||
@@ -92,10 +98,10 @@ export function NotificationProvider({ children }) {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// Unlock audio playback on the first real user interaction.
|
// Unlock audio playback only for authenticated sessions.
|
||||||
// Browsers block audio from timer callbacks unless the element was previously
|
// This avoids any audio interaction while logged out (e.g. anonymous/incognito tabs).
|
||||||
// "touched" inside a gesture handler — this one-time listener does exactly that.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!user?.id) return;
|
||||||
const events = ['click', 'keydown', 'pointerdown'];
|
const events = ['click', 'keydown', 'pointerdown'];
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
unlockAudio();
|
unlockAudio();
|
||||||
@@ -103,10 +109,12 @@ export function NotificationProvider({ children }) {
|
|||||||
};
|
};
|
||||||
events.forEach(e => window.addEventListener(e, handler, { once: true }));
|
events.forEach(e => window.addEventListener(e, handler, { once: true }));
|
||||||
return () => events.forEach(e => window.removeEventListener(e, handler));
|
return () => events.forEach(e => window.removeEventListener(e, handler));
|
||||||
}, []);
|
}, [user?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
activeUserId.current = user?.id ?? null;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
_audioUnlocked = false;
|
||||||
setNotifications([]);
|
setNotifications([]);
|
||||||
setUnreadCount(0);
|
setUnreadCount(0);
|
||||||
seenIds.current = new Set();
|
seenIds.current = new Set();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"appName": "Redlight",
|
"appName": "Redlight",
|
||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
@@ -99,7 +99,15 @@
|
|||||||
"oauthError": "Anmeldung fehlgeschlagen",
|
"oauthError": "Anmeldung fehlgeschlagen",
|
||||||
"oauthNoToken": "Kein Authentifizierungstoken erhalten.",
|
"oauthNoToken": "Kein Authentifizierungstoken erhalten.",
|
||||||
"oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
|
"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": {
|
"home": {
|
||||||
"madeFor": "Made for BigBlueButton",
|
"madeFor": "Made for BigBlueButton",
|
||||||
@@ -258,7 +266,11 @@
|
|||||||
"defaultWelcome": "Willkommen zum Meeting!",
|
"defaultWelcome": "Willkommen zum Meeting!",
|
||||||
"analytics": "Lernanalyse",
|
"analytics": "Lernanalyse",
|
||||||
"enableAnalytics": "Lernanalyse aktivieren",
|
"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": {
|
"recordings": {
|
||||||
"title": "Aufnahmen",
|
"title": "Aufnahmen",
|
||||||
@@ -295,7 +307,10 @@
|
|||||||
"duration": "Dauer",
|
"duration": "Dauer",
|
||||||
"meetingDuration": "Meeting-Dauer",
|
"meetingDuration": "Meeting-Dauer",
|
||||||
"raiseHand": "Handheben",
|
"raiseHand": "Handheben",
|
||||||
"reactions": "Reaktionen"
|
"reactions": "Reaktionen",
|
||||||
|
"export": "Herunterladen",
|
||||||
|
"exportSuccess": "Download gestartet",
|
||||||
|
"exportFailed": "Fehler beim Herunterladen"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
@@ -327,6 +342,26 @@
|
|||||||
"passwordChangeFailed": "Fehler beim Ändern",
|
"passwordChangeFailed": "Fehler beim Ändern",
|
||||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||||
"selectLanguage": "Sprache auswählen",
|
"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": {
|
"caldav": {
|
||||||
"title": "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.",
|
"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.",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"appName": "Redlight",
|
"appName": "Redlight",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -99,7 +99,15 @@
|
|||||||
"oauthError": "Authentication failed",
|
"oauthError": "Authentication failed",
|
||||||
"oauthNoToken": "No authentication token received.",
|
"oauthNoToken": "No authentication token received.",
|
||||||
"oauthLoginFailed": "Could not complete sign in. Please try again.",
|
"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": {
|
"home": {
|
||||||
"madeFor": "Made for BigBlueButton",
|
"madeFor": "Made for BigBlueButton",
|
||||||
@@ -258,7 +266,11 @@
|
|||||||
"defaultWelcome": "Welcome to the meeting!",
|
"defaultWelcome": "Welcome to the meeting!",
|
||||||
"analytics": "Learning Analytics",
|
"analytics": "Learning Analytics",
|
||||||
"enableAnalytics": "Enable 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": {
|
"recordings": {
|
||||||
"title": "Recordings",
|
"title": "Recordings",
|
||||||
@@ -295,7 +307,10 @@
|
|||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"meetingDuration": "Meeting duration",
|
"meetingDuration": "Meeting duration",
|
||||||
"raiseHand": "Raise hand",
|
"raiseHand": "Raise hand",
|
||||||
"reactions": "Reactions"
|
"reactions": "Reactions",
|
||||||
|
"export": "Download",
|
||||||
|
"exportSuccess": "Download started",
|
||||||
|
"exportFailed": "Error downloading data"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -327,6 +342,26 @@
|
|||||||
"passwordChangeFailed": "Error changing password",
|
"passwordChangeFailed": "Error changing password",
|
||||||
"passwordMismatch": "Passwords do not match",
|
"passwordMismatch": "Passwords do not match",
|
||||||
"selectLanguage": "Select language",
|
"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": {
|
"caldav": {
|
||||||
"title": "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.",
|
"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.",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function GuestJoin() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [joining, setJoining] = useState(false);
|
const [joining, setJoining] = useState(false);
|
||||||
const [name, setName] = useState(user?.name || '');
|
const [name, setName] = useState(user?.display_name || user?.name || '');
|
||||||
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
|
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
|
||||||
const [moderatorCode, setModeratorCode] = useState('');
|
const [moderatorCode, setModeratorCode] = useState('');
|
||||||
const [status, setStatus] = useState({ running: false });
|
const [status, setStatus] = useState({ running: false });
|
||||||
@@ -89,7 +89,7 @@ export default function GuestJoin() {
|
|||||||
// Auto-join when meeting starts while waiting
|
// Auto-join when meeting starts while waiting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!prevRunningRef.current && status.running && waiting) {
|
if (!prevRunningRef.current && status.running && waiting) {
|
||||||
new Audio('/sounds/meeting-started.mp3').play().catch(() => {});
|
new Audio('/sounds/meeting-started.mp3').play().catch(() => { });
|
||||||
toast.success(t('room.guestMeetingStartedJoining'));
|
toast.success(t('room.guestMeetingStartedJoining'));
|
||||||
joinMeeting();
|
joinMeeting();
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ export default function GuestJoin() {
|
|||||||
toast.error(t('room.guestRecordingConsent'));
|
toast.error(t('room.guestRecordingConsent'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!status.running) {
|
if (!status.running && !roomInfo?.anyone_can_start) {
|
||||||
setWaiting(true);
|
setWaiting(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -210,97 +210,97 @@ export default function GuestJoin() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleJoin} className="space-y-4">
|
<form onSubmit={handleJoin} className="space-y-4">
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={e => !isLoggedIn && setName(e.target.value)}
|
|
||||||
readOnly={isLoggedIn}
|
|
||||||
className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`}
|
|
||||||
placeholder={t('room.guestNamePlaceholder')}
|
|
||||||
required
|
|
||||||
autoFocus={!isLoggedIn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{roomInfo.has_access_code && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={accessCode}
|
value={name}
|
||||||
onChange={e => setAccessCode(e.target.value)}
|
onChange={e => !isLoggedIn && setName(e.target.value)}
|
||||||
className="input-field pl-11"
|
readOnly={isLoggedIn}
|
||||||
placeholder={t('room.guestAccessCodePlaceholder')}
|
className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||||||
|
placeholder={t('room.guestNamePlaceholder')}
|
||||||
|
required
|
||||||
|
autoFocus={!isLoggedIn}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
{roomInfo.has_access_code && (
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">
|
<div>
|
||||||
{t('room.guestModeratorCode')}
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
|
||||||
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
|
<div className="relative">
|
||||||
</label>
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
<div className="relative">
|
<input
|
||||||
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
type="text"
|
||||||
<input
|
value={accessCode}
|
||||||
type="text"
|
onChange={e => setAccessCode(e.target.value)}
|
||||||
value={moderatorCode}
|
className="input-field pl-11"
|
||||||
onChange={e => setModeratorCode(e.target.value)}
|
placeholder={t('room.guestAccessCodePlaceholder')}
|
||||||
className="input-field pl-11"
|
/>
|
||||||
placeholder={t('room.guestModeratorPlaceholder')}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recording consent notice */}
|
|
||||||
{roomInfo.allow_recording && (
|
|
||||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertCircle size={16} className="text-amber-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={recordingConsent}
|
|
||||||
onChange={e => setRecordingConsent(e.target.checked)}
|
|
||||||
className="w-4 h-4 rounded accent-amber-500 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={joining || (roomInfo.allow_recording && !recordingConsent)}
|
|
||||||
className="btn-primary w-full py-3"
|
|
||||||
>
|
|
||||||
{joining ? (
|
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{t('room.guestJoinButton')}
|
|
||||||
<ArrowRight size={18} />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
|
|
||||||
{!status.running && (
|
<div>
|
||||||
<p className="text-xs text-th-text-s text-center">
|
<label className="block text-sm font-medium text-th-text mb-1.5">
|
||||||
{t('room.guestWaitingMessage')}
|
{t('room.guestModeratorCode')}
|
||||||
</p>
|
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
|
||||||
)}
|
</label>
|
||||||
</form>
|
<div className="relative">
|
||||||
|
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={moderatorCode}
|
||||||
|
onChange={e => setModeratorCode(e.target.value)}
|
||||||
|
className="input-field pl-11"
|
||||||
|
placeholder={t('room.guestModeratorPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recording consent notice */}
|
||||||
|
{roomInfo.allow_recording && (
|
||||||
|
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle size={16} className="text-amber-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={recordingConsent}
|
||||||
|
onChange={e => setRecordingConsent(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded accent-amber-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={joining || (roomInfo.allow_recording && !recordingConsent)}
|
||||||
|
className="btn-primary w-full py-3"
|
||||||
|
>
|
||||||
|
{joining ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('room.guestJoinButton')}
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!status.running && !roomInfo?.anyone_can_start && (
|
||||||
|
<p className="text-xs text-th-text-s text-center">
|
||||||
|
{t('room.guestWaitingMessage')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoggedIn && (
|
{!isLoggedIn && (
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { useBranding } from '../contexts/BrandingContext';
|
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 BrandLogo from '../components/BrandLogo';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
@@ -15,7 +15,14 @@ export default function Login() {
|
|||||||
const [needsVerification, setNeedsVerification] = useState(false);
|
const [needsVerification, setNeedsVerification] = useState(false);
|
||||||
const [resendCooldown, setResendCooldown] = useState(0);
|
const [resendCooldown, setResendCooldown] = useState(0);
|
||||||
const [resending, setResending] = useState(false);
|
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 { t } = useLanguage();
|
||||||
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
|
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -26,6 +33,13 @@ export default function Login() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [resendCooldown]);
|
}, [resendCooldown]);
|
||||||
|
|
||||||
|
// Auto-focus TOTP input when 2FA screen appears
|
||||||
|
useEffect(() => {
|
||||||
|
if (needs2FA && totpInputRef.current) {
|
||||||
|
totpInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [needs2FA]);
|
||||||
|
|
||||||
const handleResend = async () => {
|
const handleResend = async () => {
|
||||||
if (resendCooldown > 0 || resending) return;
|
if (resendCooldown > 0 || resending) return;
|
||||||
setResending(true);
|
setResending(true);
|
||||||
@@ -48,7 +62,13 @@ export default function Login() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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'));
|
toast.success(t('auth.loginSuccess'));
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
} catch (err) {
|
} 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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||||
{/* Animated background */}
|
{/* Animated background */}
|
||||||
@@ -81,111 +122,171 @@ export default function Login() {
|
|||||||
<BrandLogo size="lg" />
|
<BrandLogo size="lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
{needs2FA ? (
|
||||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
|
<>
|
||||||
<p className="text-th-text-s">
|
{/* 2FA verification step */}
|
||||||
{t('auth.loginSubtitle')}
|
<div className="mb-8 text-center">
|
||||||
</p>
|
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-th-accent/10 mb-4">
|
||||||
</div>
|
<ShieldCheck size={28} className="text-th-accent" />
|
||||||
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.2fa.title')}</h2>
|
||||||
<div>
|
<p className="text-th-text-s text-sm">
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
{t('auth.2fa.prompt')}
|
||||||
<div className="relative">
|
</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
<form onSubmit={handle2FASubmit} className="space-y-5">
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
<div>
|
||||||
<div className="relative">
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.2fa.codeLabel')}</label>
|
||||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
<div className="relative">
|
||||||
<input
|
<ShieldCheck size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
type="password"
|
<input
|
||||||
value={password}
|
ref={totpInputRef}
|
||||||
onChange={e => setPassword(e.target.value)}
|
type="text"
|
||||||
className="input-field pl-11"
|
inputMode="numeric"
|
||||||
placeholder={t('auth.passwordPlaceholder')}
|
autoComplete="one-time-code"
|
||||||
required
|
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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
type="submit"
|
<div>
|
||||||
disabled={loading}
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||||
className="btn-primary w-full py-3"
|
<div className="relative">
|
||||||
>
|
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
{loading ? (
|
<input
|
||||||
<Loader2 size={18} className="animate-spin" />
|
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')}
|
<div className="relative my-6">
|
||||||
<ArrowRight size={18} />
|
<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 && (
|
{needsVerification && (
|
||||||
<>
|
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
|
||||||
<div className="relative my-6">
|
<div className="flex items-start gap-2">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
<div className="w-full border-t border-th-border" />
|
<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>
|
||||||
<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>
|
{registrationMode !== 'invite' && (
|
||||||
</div>
|
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||||
<a
|
{t('auth.noAccount')}{' '}
|
||||||
href="/api/oauth/authorize"
|
<Link to="/register" className="text-th-accent hover:underline font-medium">
|
||||||
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
|
{t('auth.signUpNow')}
|
||||||
>
|
</Link>
|
||||||
<LogIn size={18} />
|
</p>
|
||||||
{t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
|
)}
|
||||||
</a>
|
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ export default function RoomDetail() {
|
|||||||
guest_access: !!editRoom.guest_access,
|
guest_access: !!editRoom.guest_access,
|
||||||
moderator_code: editRoom.moderator_code,
|
moderator_code: editRoom.moderator_code,
|
||||||
learning_analytics: !!editRoom.learning_analytics,
|
learning_analytics: !!editRoom.learning_analytics,
|
||||||
|
analytics_visibility: editRoom.analytics_visibility || 'owner',
|
||||||
});
|
});
|
||||||
setRoom(res.data.room);
|
setRoom(res.data.room);
|
||||||
setEditRoom(res.data.room);
|
setEditRoom(res.data.room);
|
||||||
@@ -344,7 +345,7 @@ export default function RoomDetail() {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'overview', label: t('room.overview'), icon: Play },
|
{ id: 'overview', label: t('room.overview'), icon: Play },
|
||||||
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length },
|
{ 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 }] : []),
|
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -451,7 +452,7 @@ export default function RoomDetail() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex items-center gap-1 mb-6 border-b border-th-border">
|
<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
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
@@ -543,7 +544,7 @@ export default function RoomDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'analytics' && (
|
{activeTab === 'analytics' && (
|
||||||
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} />
|
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} isOwner={isOwner} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'settings' && isOwner && editRoom && (
|
{activeTab === 'settings' && isOwner && editRoom && (
|
||||||
@@ -648,6 +649,20 @@ export default function RoomDetail() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
|
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Guest access section */}
|
{/* Guest access section */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
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 { useAuth } from '../contexts/AuthContext';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -46,6 +46,17 @@ export default function Settings() {
|
|||||||
const [newlyCreatedToken, setNewlyCreatedToken] = useState(null);
|
const [newlyCreatedToken, setNewlyCreatedToken] = useState(null);
|
||||||
const [tokenVisible, setTokenVisible] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (activeSection === 'caldav') {
|
if (activeSection === 'caldav') {
|
||||||
setCaldavLoading(true);
|
setCaldavLoading(true);
|
||||||
@@ -54,6 +65,13 @@ export default function Settings() {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setCaldavLoading(false));
|
.finally(() => setCaldavLoading(false));
|
||||||
}
|
}
|
||||||
|
if (activeSection === 'security') {
|
||||||
|
setTwoFaLoading(true);
|
||||||
|
api.get('/auth/2fa/status')
|
||||||
|
.then(r => setTwoFaEnabled(r.data.enabled))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setTwoFaLoading(false));
|
||||||
|
}
|
||||||
}, [activeSection]);
|
}, [activeSection]);
|
||||||
|
|
||||||
const handleCreateToken = async (e) => {
|
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 groups = getThemeGroups();
|
||||||
|
|
||||||
const avatarColors = [
|
const avatarColors = [
|
||||||
@@ -184,6 +252,7 @@ export default function Settings() {
|
|||||||
const sections = [
|
const sections = [
|
||||||
{ id: 'profile', label: t('settings.profile'), icon: User },
|
{ id: 'profile', label: t('settings.profile'), icon: User },
|
||||||
{ id: 'password', label: t('settings.password'), icon: Lock },
|
{ 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: 'language', label: t('settings.language'), icon: Globe },
|
||||||
{ id: 'themes', label: t('settings.themes'), icon: Palette },
|
{ id: 'themes', label: t('settings.themes'), icon: Palette },
|
||||||
{ id: 'caldav', label: t('settings.caldav.title'), icon: Calendar },
|
{ id: 'caldav', label: t('settings.caldav.title'), icon: Calendar },
|
||||||
@@ -411,6 +480,147 @@ export default function Settings() {
|
|||||||
</div>
|
</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 */}
|
{/* Language section */}
|
||||||
{activeSection === 'language' && (
|
{activeSection === 'language' && (
|
||||||
<div className="card p-6">
|
<div className="card p-6">
|
||||||
|
|||||||
Reference in New Issue
Block a user