Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0ce354eda | |||
| 1690a74c19 | |||
| 61585d8c63 | |||
| d04793148a | |||
| 9bf4228d04 | |||
| c058ba3bf1 | |||
| b3b559e164 | |||
| 0db9227c20 | |||
| 30f106a7ea | |||
| 8cbe28f915 | |||
| 5472e190d9 | |||
| 45be976de1 | |||
| 6dcb1e959b | |||
| bb2d179871 | |||
| 82b7d060ba | |||
| 0836436fe7 |
970
package-lock.json
generated
970
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "redlight",
|
"name": "redlight",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.1.0",
|
"version": "2.1.2",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,8 +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",
|
"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",
|
||||||
|
|||||||
@@ -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,14 @@ 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 ────────────────────────────────────────
|
// ── Analytics visibility setting ────────────────────────────────────────
|
||||||
if (!(await db.columnExists('rooms', 'analytics_visibility'))) {
|
if (!(await db.columnExists('rooms', 'analytics_visibility'))) {
|
||||||
await db.exec("ALTER TABLE rooms ADD COLUMN analytics_visibility TEXT DEFAULT 'owner'");
|
await db.exec("ALTER TABLE rooms ADD COLUMN analytics_visibility TEXT DEFAULT 'owner'");
|
||||||
|
|||||||
@@ -144,6 +144,61 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a guest meeting invitation email with a direct join link.
|
||||||
|
* @param {string} to - recipient email
|
||||||
|
* @param {string} fromUser - sender display name
|
||||||
|
* @param {string} roomName - name of the room
|
||||||
|
* @param {string} message - optional personal message
|
||||||
|
* @param {string} joinUrl - direct guest join URL
|
||||||
|
* @param {string} appName - branding app name
|
||||||
|
* @param {string} lang - language code
|
||||||
|
*/
|
||||||
|
export async function sendGuestInviteEmail(to, fromUser, roomName, message, joinUrl, appName = 'Redlight', lang = 'en') {
|
||||||
|
if (!transporter) {
|
||||||
|
throw new Error('SMTP not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||||
|
const headerAppName = sanitizeHeaderValue(appName);
|
||||||
|
const safeFromUser = escapeHtml(fromUser);
|
||||||
|
const safeRoomName = escapeHtml(roomName);
|
||||||
|
const safeMessage = message ? escapeHtml(message) : null;
|
||||||
|
|
||||||
|
const introHtml = t(lang, 'email.guestInvite.intro')
|
||||||
|
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${headerAppName}" <${from}>`,
|
||||||
|
to,
|
||||||
|
subject: t(lang, 'email.guestInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
|
||||||
|
html: `
|
||||||
|
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||||
|
<h2 style="color:#cba6f7;margin-top:0;">Meeting Invitation</h2>
|
||||||
|
<p>${introHtml}</p>
|
||||||
|
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
|
||||||
|
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">${t(lang, 'email.guestInvite.roomLabel')}</p>
|
||||||
|
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${safeRoomName}</p>
|
||||||
|
${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${safeMessage}"</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<p style="text-align:center;margin:28px 0;">
|
||||||
|
<a href="${joinUrl}"
|
||||||
|
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||||
|
${t(lang, 'email.guestInvite.joinButton')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size:13px;color:#7f849c;">
|
||||||
|
${t(lang, 'email.linkHint')}<br/>
|
||||||
|
<a href="${joinUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(joinUrl)}</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||||
|
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.guestInvite.footer')}</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
text: `${t(lang, 'email.guestInvite.intro', { fromUser })}\n${t(lang, 'email.guestInvite.roomLabel')} ${roomName}${message ? `\n"${message}"` : ''}\n\n${t(lang, 'email.guestInvite.joinButton')}: ${joinUrl}\n\n- ${appName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a calendar event invitation email (federated).
|
* Send a calendar event invitation email (federated).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
|
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
|
||||||
"roomLabel": "Raum:"
|
"roomLabel": "Raum:"
|
||||||
},
|
},
|
||||||
|
"guestInvite": {
|
||||||
|
"subject": "{appName} - Einladung zu einem Meeting",
|
||||||
|
"intro": "{fromUser} hat dich zu einem Meeting eingeladen.",
|
||||||
|
"roomLabel": "Raum:",
|
||||||
|
"joinButton": "Meeting beitreten",
|
||||||
|
"footer": "Klicke auf den Button oben, um dem Meeting beizutreten."
|
||||||
|
},
|
||||||
"calendarInvite": {
|
"calendarInvite": {
|
||||||
"subject": "{appName} - Kalendereinladung von {fromUser}",
|
"subject": "{appName} - Kalendereinladung von {fromUser}",
|
||||||
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
|
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"intro": "You have received a meeting invitation from {fromUser}.",
|
"intro": "You have received a meeting invitation from {fromUser}.",
|
||||||
"roomLabel": "Room:"
|
"roomLabel": "Room:"
|
||||||
},
|
},
|
||||||
|
"guestInvite": {
|
||||||
|
"subject": "{appName} - You're invited to a meeting",
|
||||||
|
"intro": "{fromUser} has invited you to a meeting.",
|
||||||
|
"roomLabel": "Room:",
|
||||||
|
"joinButton": "Join Meeting",
|
||||||
|
"footer": "Click the button above to join the meeting."
|
||||||
|
},
|
||||||
"calendarInvite": {
|
"calendarInvite": {
|
||||||
"subject": "{appName} - Calendar invitation from {fromUser}",
|
"subject": "{appName} - Calendar invitation from {fromUser}",
|
||||||
"intro": "You have received a calendar invitation from {fromUser}."
|
"intro": "You have received a calendar invitation from {fromUser}."
|
||||||
|
|||||||
@@ -60,9 +60,10 @@ async function start() {
|
|||||||
await initDatabase();
|
await initDatabase();
|
||||||
initMailer();
|
initMailer();
|
||||||
|
|
||||||
// Serve uploaded files (branding only — avatars served via /api/auth/avatar/:filename, presentations require auth)
|
// Serve uploaded files (avatars are served via /api/auth/avatar/:filename)
|
||||||
const uploadsPath = path.join(__dirname, '..', 'uploads');
|
const uploadsPath = path.join(__dirname, '..', 'uploads');
|
||||||
app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding')));
|
app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding')));
|
||||||
|
// Presentations are served via /api/rooms/presentations/:filename?token=… (HMAC-protected)
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -362,4 +362,28 @@ router.delete('/oauth', authenticateToken, requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Room Management (admin only) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/admin/rooms - List all rooms with owner info
|
||||||
|
router.get('/rooms', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const rooms = await db.all(`
|
||||||
|
SELECT r.id, r.uid, r.name, r.user_id, r.max_participants, r.access_code,
|
||||||
|
r.mute_on_join, r.record_meeting, r.guest_access, r.presentation_file,
|
||||||
|
r.created_at, r.updated_at,
|
||||||
|
COALESCE(NULLIF(u.display_name,''), u.name) as owner_name,
|
||||||
|
u.email as owner_email,
|
||||||
|
(SELECT COUNT(*) FROM room_shares rs WHERE rs.room_id = r.id) as share_count
|
||||||
|
FROM rooms r
|
||||||
|
JOIN users u ON r.user_id = u.id
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
`);
|
||||||
|
res.json({ rooms });
|
||||||
|
} catch (err) {
|
||||||
|
log.admin.error(`List rooms error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: 'Rooms could not be loaded' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function wellKnownHandler(req, res) {
|
|||||||
federation_api: '/api/federation',
|
federation_api: '/api/federation',
|
||||||
public_key: getPublicKey(),
|
public_key: getPublicKey(),
|
||||||
software: 'Redlight',
|
software: 'Redlight',
|
||||||
version: '2.1.0',
|
version: '2.1.2',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getDb } from '../config/database.js';
|
|||||||
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
|
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
import { createNotification } from '../config/notifications.js';
|
import { createNotification } from '../config/notifications.js';
|
||||||
|
import { sendGuestInviteEmail } from '../config/mailer.js';
|
||||||
import {
|
import {
|
||||||
createMeeting,
|
createMeeting,
|
||||||
joinMeeting,
|
joinMeeting,
|
||||||
@@ -37,6 +38,17 @@ const __dirname = path.dirname(__filename);
|
|||||||
const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations');
|
const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations');
|
||||||
if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true });
|
if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true });
|
||||||
|
|
||||||
|
const PRESENTATION_TOKEN_SECRET = process.env.BBB_SECRET || crypto.randomBytes(32).toString('hex');
|
||||||
|
const PRESENTATION_TOKEN_TTL = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
function signPresentationUrl(roomUid, filename) {
|
||||||
|
const expires = Date.now() + PRESENTATION_TOKEN_TTL;
|
||||||
|
const token = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET)
|
||||||
|
.update(`${roomUid}/${filename}:${expires}`)
|
||||||
|
.digest('hex');
|
||||||
|
return { token, expires };
|
||||||
|
}
|
||||||
|
|
||||||
// M8: rate limit unauthenticated guest-join to prevent access_code brute-force
|
// M8: rate limit unauthenticated guest-join to prevent access_code brute-force
|
||||||
const guestJoinLimiter = rateLimit({
|
const guestJoinLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
@@ -484,9 +496,11 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
const baseUrl = getBaseUrl(req);
|
const baseUrl = getBaseUrl(req);
|
||||||
const loginURL = `${baseUrl}/join/${room.uid}`;
|
const loginURL = `${baseUrl}/join/${room.uid}`;
|
||||||
const presentationUrl = room.presentation_file
|
let presentationUrl = null;
|
||||||
? `${baseUrl}/uploads/presentations/${room.presentation_file}`
|
if (room.presentation_file) {
|
||||||
: null;
|
const { token, expires } = signPresentationUrl(room.uid, room.presentation_file);
|
||||||
|
presentationUrl = `${baseUrl}/api/rooms/presentations/${token}/${expires}/${room.uid}/${encodeURIComponent(room.presentation_file)}`;
|
||||||
|
}
|
||||||
const analyticsCallbackURL = room.learning_analytics
|
const analyticsCallbackURL = room.learning_analytics
|
||||||
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
|
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
|
||||||
: null;
|
: null;
|
||||||
@@ -602,7 +616,7 @@ router.get('/:uid/public', async (req, res) => {
|
|||||||
// POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed)
|
// POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed)
|
||||||
router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
|
router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, access_code, moderator_code } = req.body;
|
const { name, access_code, moderator_code, avatar_image, avatar_color } = req.body;
|
||||||
|
|
||||||
if (!name || name.trim().length === 0) {
|
if (!name || name.trim().length === 0) {
|
||||||
return res.status(400).json({ error: 'Name is required' });
|
return res.status(400).json({ error: 'Name is required' });
|
||||||
@@ -648,7 +662,17 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = getBaseUrl(req);
|
const baseUrl = getBaseUrl(req);
|
||||||
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
|
let guestAvatarURL;
|
||||||
|
if (avatar_image) {
|
||||||
|
// Use avatar image of the logged-in user
|
||||||
|
guestAvatarURL = `${baseUrl}/api/auth/avatar/${avatar_image}`;
|
||||||
|
} else if (avatar_color) {
|
||||||
|
// Initials with user color
|
||||||
|
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}?color=${encodeURIComponent(avatar_color)}`;
|
||||||
|
} else {
|
||||||
|
// Default: initials without color
|
||||||
|
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
|
||||||
|
}
|
||||||
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
|
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
|
||||||
res.json({ joinUrl });
|
res.json({ joinUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -679,6 +703,43 @@ router.get('/:uid/status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/rooms/presentations/:token/:expires/:roomUid/:filename - Serve presentation file (token-protected for BBB)
|
||||||
|
// Token and expires are path segments so the URL ends with the original filename,
|
||||||
|
// allowing BBB to detect the file type from the extension.
|
||||||
|
router.get('/presentations/:token/:expires/:roomUid/:filename', (req, res) => {
|
||||||
|
const { token, expires, roomUid, filename } = req.params;
|
||||||
|
|
||||||
|
if (!token || !expires) {
|
||||||
|
return res.status(401).json({ error: 'Missing token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresNum = Number(expires);
|
||||||
|
if (isNaN(expiresNum) || Date.now() > expiresNum) {
|
||||||
|
return res.status(403).json({ error: 'Token expired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET)
|
||||||
|
.update(`${roomUid}/${filename}:${expires}`)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected))) {
|
||||||
|
return res.status(403).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// S8: prevent path traversal
|
||||||
|
const roomDir = path.resolve(presentationsDir, roomUid);
|
||||||
|
const filepath = path.resolve(roomDir, filename);
|
||||||
|
if (!filepath.startsWith(presentationsDir + path.sep) || !filepath.startsWith(roomDir + path.sep)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid filename' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(filepath)) {
|
||||||
|
return res.status(404).json({ error: 'File not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.sendFile(filepath);
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/rooms/:uid/presentation - Upload a presentation file for the room
|
// POST /api/rooms/:uid/presentation - Upload a presentation file for the room
|
||||||
router.post('/:uid/presentation', authenticateToken, async (req, res) => {
|
router.post('/:uid/presentation', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -733,22 +794,28 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
// Preserve original filename (sent as X-Filename header)
|
// Preserve original filename (sent as X-Filename header)
|
||||||
const rawName = req.headers['x-filename'];
|
const rawName = req.headers['x-filename'];
|
||||||
const originalName = rawName
|
const filename = rawName
|
||||||
? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200)
|
? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200)
|
||||||
: `presentation.${ext}`;
|
: `presentation.${ext}`;
|
||||||
|
|
||||||
const filename = `${room.uid}_${Date.now()}.${ext}`;
|
// Each room gets its own folder: uploads/presentations/{roomUID}/
|
||||||
const filepath = path.join(presentationsDir, filename);
|
const roomDir = path.join(presentationsDir, room.uid);
|
||||||
|
if (!fs.existsSync(roomDir)) fs.mkdirSync(roomDir, { recursive: true });
|
||||||
|
const filepath = path.join(roomDir, filename);
|
||||||
|
|
||||||
|
// S8: defense-in-depth path traversal check
|
||||||
|
if (!path.resolve(filepath).startsWith(roomDir + path.sep)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid filename' });
|
||||||
|
}
|
||||||
|
|
||||||
// Remove old presentation file if exists
|
// Remove old presentation file if exists
|
||||||
if (room.presentation_file) {
|
if (room.presentation_file) {
|
||||||
// S8: defense-in-depth path traversal check
|
const oldPath = path.resolve(roomDir, room.presentation_file);
|
||||||
const oldPath = path.resolve(presentationsDir, room.presentation_file);
|
if (oldPath.startsWith(roomDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||||
if (oldPath.startsWith(presentationsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(filepath, buffer);
|
fs.writeFileSync(filepath, buffer);
|
||||||
await db.run('UPDATE rooms SET presentation_file = ?, presentation_name = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, originalName, req.params.uid]);
|
await db.run('UPDATE rooms SET presentation_file = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, req.params.uid]);
|
||||||
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||||
res.json({ room: updated });
|
res.json({ room: updated });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -766,11 +833,14 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
if (room.presentation_file) {
|
if (room.presentation_file) {
|
||||||
// S8: defense-in-depth path traversal check
|
// S8: defense-in-depth path traversal check
|
||||||
const filepath = path.resolve(presentationsDir, room.presentation_file);
|
const roomDir = path.join(presentationsDir, room.uid);
|
||||||
if (filepath.startsWith(presentationsDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath);
|
const filepath = path.resolve(roomDir, room.presentation_file);
|
||||||
|
if (filepath.startsWith(roomDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath);
|
||||||
|
// Remove empty room folder
|
||||||
|
if (fs.existsSync(roomDir) && fs.readdirSync(roomDir).length === 0) fs.rmdirSync(roomDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.run('UPDATE rooms SET presentation_file = NULL, presentation_name = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]);
|
await db.run('UPDATE rooms SET presentation_file = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]);
|
||||||
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||||
res.json({ room: updated });
|
res.json({ room: updated });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -779,4 +849,76 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── POST /api/rooms/invite-email — Send email invitation to guest(s) ────────
|
||||||
|
router.post('/invite-email', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { room_uid, emails, message } = req.body;
|
||||||
|
if (!room_uid || !emails || !emails.length) {
|
||||||
|
return res.status(400).json({ error: 'room_uid and emails are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emails.length > 50) {
|
||||||
|
return res.status(400).json({ error: 'Maximum 50 email addresses allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all emails
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
for (const email of emails) {
|
||||||
|
if (!emailRegex.test(email) || email.length > 254) {
|
||||||
|
return res.status(400).json({ error: `Invalid email address: ${email}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message && message.length > 2000) {
|
||||||
|
return res.status(400).json({ error: 'Message must not exceed 2000 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Verify room exists and user has access
|
||||||
|
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [room_uid]);
|
||||||
|
if (!room) {
|
||||||
|
return res.status(404).json({ error: 'Room not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = room.user_id === req.user.id;
|
||||||
|
if (!isOwner) {
|
||||||
|
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||||
|
if (!share) {
|
||||||
|
return res.status(403).json({ error: 'No permission to invite from this room' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build guest join URL
|
||||||
|
const baseUrl = getBaseUrl(req);
|
||||||
|
const joinUrl = room.access_code
|
||||||
|
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
|
||||||
|
: `${baseUrl}/join/${room.uid}`;
|
||||||
|
|
||||||
|
const appName = process.env.APP_NAME || 'Redlight';
|
||||||
|
const fromUser = req.user.display_name || req.user.name;
|
||||||
|
const lang = req.user.language || 'en';
|
||||||
|
|
||||||
|
// Send emails (in parallel but collect errors)
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
emails.map(email =>
|
||||||
|
sendGuestInviteEmail(email, fromUser, room.name, message || null, joinUrl, appName, lang)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const failed = results.filter(r => r.status === 'rejected');
|
||||||
|
if (failed.length === emails.length) {
|
||||||
|
return res.status(500).json({ error: 'Failed to send all email invitations' });
|
||||||
|
}
|
||||||
|
if (failed.length > 0) {
|
||||||
|
log.rooms.warn(`${failed.length}/${emails.length} email invitations failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, sent: emails.length - failed.length, failed: failed.length });
|
||||||
|
} catch (err) {
|
||||||
|
log.rooms.error('Email invite error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to send email invitations' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -334,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.",
|
||||||
@@ -456,7 +484,21 @@
|
|||||||
"oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.",
|
"oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.",
|
||||||
"oauthNotConfigured": "OAuth ist noch nicht konfiguriert.",
|
"oauthNotConfigured": "OAuth ist noch nicht konfiguriert.",
|
||||||
"oauthSave": "OAuth speichern",
|
"oauthSave": "OAuth speichern",
|
||||||
"oauthRemove": "OAuth entfernen"
|
"oauthRemove": "OAuth entfernen",
|
||||||
|
"roomsTitle": "Raumverwaltung",
|
||||||
|
"roomsDescription": "Alle Räume der Instanz einsehen, verwalten und bei Bedarf löschen.",
|
||||||
|
"searchRooms": "Räume suchen...",
|
||||||
|
"roomName": "Name",
|
||||||
|
"roomOwner": "Besitzer",
|
||||||
|
"roomShares": "Geteilt",
|
||||||
|
"roomCreated": "Erstellt",
|
||||||
|
"roomView": "Raum öffnen",
|
||||||
|
"deleteRoom": "Raum löschen",
|
||||||
|
"deleteRoomConfirm": "Raum \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||||
|
"roomDeleted": "Raum gelöscht",
|
||||||
|
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
|
||||||
|
"noRoomsFound": "Keine Räume vorhanden",
|
||||||
|
"showAllRooms": "Alle {count} Räume anzeigen"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"bell": "Benachrichtigungen",
|
"bell": "Benachrichtigungen",
|
||||||
@@ -472,14 +514,19 @@
|
|||||||
"inbox": "Einladungen",
|
"inbox": "Einladungen",
|
||||||
"inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen",
|
"inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen",
|
||||||
"inviteTitle": "Remote-Benutzer einladen",
|
"inviteTitle": "Remote-Benutzer einladen",
|
||||||
"inviteSubtitle": "Einen Benutzer von einer anderen Redlight-Instanz zu diesem Meeting einladen.",
|
"inviteSubtitle": "Du kannst entweder einen Benutzer von einer anderen Redlight-Instanz über seine Adresse einladen oder direkt eine E-Mail-Einladung senden. Es kann nur eine Option gleichzeitig verwendet werden.",
|
||||||
"addressLabel": "Benutzeradresse",
|
"addressLabel": "Benutzeradresse",
|
||||||
"addressPlaceholder": "@benutzer@andere-instanz.com",
|
"addressPlaceholder": "@benutzer@andere-instanz.com",
|
||||||
"addressHint": "Format: @Benutzername@Domain der Redlight-Instanz",
|
"addressHint": "Format: @Benutzername@Domain der Redlight-Instanz",
|
||||||
|
"emailLabel": "Per E-Mail einladen",
|
||||||
|
"emailPlaceholder": "name@beispiel.de, name2@beispiel.de",
|
||||||
|
"emailHint": "Eine oder mehrere E-Mail-Adressen, durch Komma getrennt",
|
||||||
"messageLabel": "Nachricht (optional)",
|
"messageLabel": "Nachricht (optional)",
|
||||||
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
|
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
|
||||||
"send": "Einladung senden",
|
"send": "Einladung senden",
|
||||||
"sent": "Einladung gesendet!",
|
"sent": "Einladung gesendet!",
|
||||||
|
"emailSent": "E-Mail-Einladung(en) gesendet!",
|
||||||
|
"emailSendFailed": "E-Mail-Einladung konnte nicht gesendet werden",
|
||||||
"sendFailed": "Einladung konnte nicht gesendet werden",
|
"sendFailed": "Einladung konnte nicht gesendet werden",
|
||||||
"from": "Von",
|
"from": "Von",
|
||||||
"accept": "Annehmen",
|
"accept": "Annehmen",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -334,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.",
|
||||||
@@ -456,7 +484,21 @@
|
|||||||
"oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.",
|
"oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.",
|
||||||
"oauthNotConfigured": "OAuth is not configured yet.",
|
"oauthNotConfigured": "OAuth is not configured yet.",
|
||||||
"oauthSave": "Save OAuth",
|
"oauthSave": "Save OAuth",
|
||||||
"oauthRemove": "Remove OAuth"
|
"oauthRemove": "Remove OAuth",
|
||||||
|
"roomsTitle": "Room Management",
|
||||||
|
"roomsDescription": "View, manage, and delete all rooms on this instance.",
|
||||||
|
"searchRooms": "Search rooms...",
|
||||||
|
"roomName": "Name",
|
||||||
|
"roomOwner": "Owner",
|
||||||
|
"roomShares": "Shared",
|
||||||
|
"roomCreated": "Created",
|
||||||
|
"roomView": "View room",
|
||||||
|
"deleteRoom": "Delete room",
|
||||||
|
"deleteRoomConfirm": "Really delete room \"{name}\"? This cannot be undone.",
|
||||||
|
"roomDeleted": "Room deleted",
|
||||||
|
"roomDeleteFailed": "Room could not be deleted",
|
||||||
|
"noRoomsFound": "No rooms found",
|
||||||
|
"showAllRooms": "Show all {count} rooms"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"bell": "Notifications",
|
"bell": "Notifications",
|
||||||
@@ -472,14 +514,19 @@
|
|||||||
"inbox": "Invitations",
|
"inbox": "Invitations",
|
||||||
"inboxSubtitle": "Meeting invitations from other Redlight instances",
|
"inboxSubtitle": "Meeting invitations from other Redlight instances",
|
||||||
"inviteTitle": "Invite Remote User",
|
"inviteTitle": "Invite Remote User",
|
||||||
"inviteSubtitle": "Invite a user from another Redlight instance to this meeting.",
|
"inviteSubtitle": "You can either invite a user from another Redlight instance by their address, or send an email invitation directly. Only one option can be used at a time.",
|
||||||
"addressLabel": "User address",
|
"addressLabel": "User address",
|
||||||
"addressPlaceholder": "@user@other-instance.com",
|
"addressPlaceholder": "@user@other-instance.com",
|
||||||
"addressHint": "Format: @username@domain of the Redlight instance",
|
"addressHint": "Format: @username@domain of the Redlight instance",
|
||||||
|
"emailLabel": "Invite by email",
|
||||||
|
"emailPlaceholder": "name@example.com, name2@example.com",
|
||||||
|
"emailHint": "Enter one or more email addresses, separated by commas",
|
||||||
"messageLabel": "Message (optional)",
|
"messageLabel": "Message (optional)",
|
||||||
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
|
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
|
||||||
"send": "Send invitation",
|
"send": "Send invitation",
|
||||||
"sent": "Invitation sent!",
|
"sent": "Invitation sent!",
|
||||||
|
"emailSent": "Email invitation(s) sent!",
|
||||||
|
"emailSendFailed": "Could not send email invitation",
|
||||||
"sendFailed": "Could not send invitation",
|
"sendFailed": "Could not send invitation",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"accept": "Accept",
|
"accept": "Accept",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||||
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
|
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
|
||||||
ShieldCheck, Globe, Link as LinkIcon, LogIn,
|
ShieldCheck, Globe, Link as LinkIcon, LogIn, DoorOpen, Eye, ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -55,6 +55,14 @@ export default function Admin() {
|
|||||||
const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
|
const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
|
||||||
const [savingOauth, setSavingOauth] = useState(false);
|
const [savingOauth, setSavingOauth] = useState(false);
|
||||||
|
|
||||||
|
// Rooms state
|
||||||
|
const [adminRooms, setAdminRooms] = useState([]);
|
||||||
|
const [adminRoomsLoading, setAdminRoomsLoading] = useState(true);
|
||||||
|
const [roomSearch, setRoomSearch] = useState('');
|
||||||
|
const [roomsExpanded, setRoomsExpanded] = useState(false);
|
||||||
|
const [showAllRoomsModal, setShowAllRoomsModal] = useState(false);
|
||||||
|
const [allRoomsSearch, setAllRoomsSearch] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
@@ -63,6 +71,7 @@ export default function Admin() {
|
|||||||
fetchUsers();
|
fetchUsers();
|
||||||
fetchInvites();
|
fetchInvites();
|
||||||
fetchOauthConfig();
|
fetchOauthConfig();
|
||||||
|
fetchAdminRooms();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -101,6 +110,29 @@ export default function Admin() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchAdminRooms = async () => {
|
||||||
|
setAdminRoomsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/rooms');
|
||||||
|
setAdminRooms(res.data.rooms);
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setAdminRoomsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdminDeleteRoom = async (uid, name) => {
|
||||||
|
if (!confirm(t('admin.deleteRoomConfirm', { name }))) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/rooms/${uid}`);
|
||||||
|
toast.success(t('admin.roomDeleted'));
|
||||||
|
fetchAdminRooms();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('admin.roomDeleteFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRoleChange = async (userId, newRole) => {
|
const handleRoleChange = async (userId, newRole) => {
|
||||||
try {
|
try {
|
||||||
await api.put(`/admin/users/${userId}/role`, { role: newRole });
|
await api.put(`/admin/users/${userId}/role`, { role: newRole });
|
||||||
@@ -790,6 +822,122 @@ export default function Admin() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Room Management */}
|
||||||
|
<div className="card p-6 mb-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRoomsExpanded(v => !v)}
|
||||||
|
className="flex items-center justify-between w-full text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DoorOpen size={20} className="text-th-accent" />
|
||||||
|
<h2 className="text-lg font-semibold text-th-text">{t('admin.roomsTitle')}</h2>
|
||||||
|
<span className="text-sm text-th-text-s">({adminRooms.length})</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={18} className={`text-th-text-s transition-transform duration-200 ${roomsExpanded ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{roomsExpanded && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-sm text-th-text-s mb-5">{t('admin.roomsDescription')}</p>
|
||||||
|
|
||||||
|
{adminRoomsLoading ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Loader2 size={20} className="animate-spin text-th-accent" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{adminRooms.length > 0 && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-th-border">
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
|
||||||
|
{t('admin.roomName')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden sm:table-cell">
|
||||||
|
{t('admin.roomOwner')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden md:table-cell">
|
||||||
|
{t('admin.roomShares')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden lg:table-cell">
|
||||||
|
{t('admin.roomCreated')}
|
||||||
|
</th>
|
||||||
|
<th className="text-right text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
|
||||||
|
{t('admin.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{adminRooms.slice(0, 10).map(r => (
|
||||||
|
<tr key={r.id} className="border-b border-th-border last:border-0 hover:bg-th-hover transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-th-text">{r.name}</p>
|
||||||
|
<p className="text-xs text-th-text-s font-mono">{r.uid}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden sm:table-cell">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-th-text">{r.owner_name}</p>
|
||||||
|
<p className="text-xs text-th-text-s">{r.owner_email}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-th-text hidden md:table-cell">
|
||||||
|
{r.share_count}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-th-text-s hidden lg:table-cell">
|
||||||
|
{new Date(r.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/rooms/${r.uid}`)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||||
|
title={t('admin.roomView')}
|
||||||
|
>
|
||||||
|
<Eye size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAdminDeleteRoom(r.uid, r.name)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-error transition-colors"
|
||||||
|
title={t('admin.deleteRoom')}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adminRooms.length > 10 && (
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => { setAllRoomsSearch(''); setShowAllRoomsModal(true); }}
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
{t('admin.showAllRooms', { count: adminRooms.length })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adminRooms.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<DoorOpen size={36} className="mx-auto text-th-text-s/40 mb-2" />
|
||||||
|
<p className="text-th-text-s text-sm">{t('admin.noRoomsFound')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="card p-4 mb-6">
|
<div className="card p-4 mb-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -1067,6 +1215,119 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* All rooms modal */}
|
||||||
|
{showAllRoomsModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowAllRoomsModal(false)} />
|
||||||
|
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-th-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DoorOpen size={20} className="text-th-accent" />
|
||||||
|
<h3 className="text-lg font-semibold text-th-text">{t('admin.roomsTitle')}</h3>
|
||||||
|
<span className="text-sm text-th-text-s">({adminRooms.length})</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowAllRoomsModal(false)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors">
|
||||||
|
<XIcon size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-th-border">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={allRoomsSearch}
|
||||||
|
onChange={e => setAllRoomsSearch(e.target.value)}
|
||||||
|
className="input-field pl-9 text-sm"
|
||||||
|
placeholder={t('admin.searchRooms')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1 p-0">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="sticky top-0 bg-th-card z-10">
|
||||||
|
<tr className="border-b border-th-border">
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
|
||||||
|
{t('admin.roomName')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden sm:table-cell">
|
||||||
|
{t('admin.roomOwner')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden md:table-cell">
|
||||||
|
{t('admin.roomShares')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden lg:table-cell">
|
||||||
|
{t('admin.roomCreated')}
|
||||||
|
</th>
|
||||||
|
<th className="text-right text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
|
||||||
|
{t('admin.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{adminRooms
|
||||||
|
.filter(r =>
|
||||||
|
r.name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
|
||||||
|
r.owner_name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
|
||||||
|
r.uid.toLowerCase().includes(allRoomsSearch.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(r => (
|
||||||
|
<tr key={r.id} className="border-b border-th-border last:border-0 hover:bg-th-hover transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-th-text">{r.name}</p>
|
||||||
|
<p className="text-xs text-th-text-s font-mono">{r.uid}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden sm:table-cell">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-th-text">{r.owner_name}</p>
|
||||||
|
<p className="text-xs text-th-text-s">{r.owner_email}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-th-text hidden md:table-cell">
|
||||||
|
{r.share_count}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-th-text-s hidden lg:table-cell">
|
||||||
|
{new Date(r.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowAllRoomsModal(false); navigate(`/rooms/${r.uid}`); }}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||||
|
title={t('admin.roomView')}
|
||||||
|
>
|
||||||
|
<Eye size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAdminDeleteRoom(r.uid, r.name)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-error transition-colors"
|
||||||
|
title={t('admin.deleteRoom')}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{adminRooms.filter(r =>
|
||||||
|
r.name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
|
||||||
|
r.owner_name.toLowerCase().includes(allRoomsSearch.toLowerCase()) ||
|
||||||
|
r.uid.toLowerCase().includes(allRoomsSearch.toLowerCase())
|
||||||
|
).length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<DoorOpen size={36} className="mx-auto text-th-text-s/40 mb-2" />
|
||||||
|
<p className="text-th-text-s text-sm">{t('admin.noRoomsFound')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react';
|
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react';
|
||||||
import BrandLogo from '../components/BrandLogo';
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
@@ -11,6 +11,7 @@ import { useBranding } from '../contexts/BrandingContext';
|
|||||||
export default function GuestJoin() {
|
export default function GuestJoin() {
|
||||||
const { uid } = useParams();
|
const { uid } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { imprintUrl, privacyUrl } = useBranding();
|
const { imprintUrl, privacyUrl } = useBranding();
|
||||||
@@ -19,7 +20,7 @@ export default function GuestJoin() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [joining, setJoining] = useState(false);
|
const [joining, setJoining] = useState(false);
|
||||||
const [name, setName] = useState(user?.name || '');
|
const [name, setName] = useState(user?.display_name || user?.name || '');
|
||||||
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
|
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
|
||||||
const [moderatorCode, setModeratorCode] = useState('');
|
const [moderatorCode, setModeratorCode] = useState('');
|
||||||
const [status, setStatus] = useState({ running: false });
|
const [status, setStatus] = useState({ running: false });
|
||||||
@@ -30,11 +31,17 @@ export default function GuestJoin() {
|
|||||||
const joinMeeting = async () => {
|
const joinMeeting = async () => {
|
||||||
setJoining(true);
|
setJoining(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post(`/rooms/${uid}/guest-join`, {
|
const payload = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
access_code: accessCode || undefined,
|
access_code: accessCode || undefined,
|
||||||
moderator_code: moderatorCode || undefined,
|
moderator_code: moderatorCode || undefined,
|
||||||
});
|
};
|
||||||
|
// If logged in, send avatar data
|
||||||
|
if (isLoggedIn && user) {
|
||||||
|
if (user.avatar_image) payload.avatar_image = user.avatar_image;
|
||||||
|
if (user.avatar_color) payload.avatar_color = user.avatar_color;
|
||||||
|
}
|
||||||
|
const res = await api.post(`/rooms/${uid}/guest-join`, payload);
|
||||||
if (res.data.joinUrl) {
|
if (res.data.joinUrl) {
|
||||||
window.location.href = res.data.joinUrl;
|
window.location.href = res.data.joinUrl;
|
||||||
}
|
}
|
||||||
@@ -54,6 +61,17 @@ export default function GuestJoin() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRoom = async () => {
|
const fetchRoom = async () => {
|
||||||
|
// If logged in, check if user owns or has access to this room
|
||||||
|
if (isLoggedIn) {
|
||||||
|
try {
|
||||||
|
await api.get(`/rooms/${uid}`);
|
||||||
|
navigate(`/rooms/${uid}`, { replace: true });
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// User doesn't have access — continue as guest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/rooms/${uid}/public`);
|
const res = await api.get(`/rooms/${uid}/public`);
|
||||||
setRoomInfo(res.data.room);
|
setRoomInfo(res.data.room);
|
||||||
@@ -89,7 +107,7 @@ export default function GuestJoin() {
|
|||||||
// Auto-join when meeting starts while waiting
|
// Auto-join when meeting starts while waiting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!prevRunningRef.current && status.running && waiting) {
|
if (!prevRunningRef.current && status.running && waiting) {
|
||||||
new Audio('/sounds/meeting-started.mp3').play().catch(() => {});
|
new Audio('/sounds/meeting-started.mp3').play().catch(() => { });
|
||||||
toast.success(t('room.guestMeetingStartedJoining'));
|
toast.success(t('room.guestMeetingStartedJoining'));
|
||||||
joinMeeting();
|
joinMeeting();
|
||||||
}
|
}
|
||||||
@@ -106,7 +124,7 @@ export default function GuestJoin() {
|
|||||||
toast.error(t('room.guestRecordingConsent'));
|
toast.error(t('room.guestRecordingConsent'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!status.running) {
|
if (!status.running && !roomInfo?.anyone_can_start) {
|
||||||
setWaiting(true);
|
setWaiting(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -295,7 +313,7 @@ export default function GuestJoin() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!status.running && (
|
{!status.running && !roomInfo?.anyone_can_start && (
|
||||||
<p className="text-xs text-th-text-s text-center">
|
<p className="text-xs text-th-text-s text-center">
|
||||||
{t('room.guestWaitingMessage')}
|
{t('room.guestWaitingMessage')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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,6 +122,64 @@ export default function Login() {
|
|||||||
<BrandLogo size="lg" />
|
<BrandLogo size="lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{needs2FA ? (
|
||||||
|
<>
|
||||||
|
{/* 2FA verification step */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-th-accent/10 mb-4">
|
||||||
|
<ShieldCheck size={28} className="text-th-accent" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.2fa.title')}</h2>
|
||||||
|
<p className="text-th-text-s text-sm">
|
||||||
|
{t('auth.2fa.prompt')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handle2FASubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.2fa.codeLabel')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<ShieldCheck size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
|
<input
|
||||||
|
ref={totpInputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
value={totpCode}
|
||||||
|
onChange={e => setTotpCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
|
||||||
|
className="input-field pl-11 text-center text-lg tracking-[0.3em] font-mono"
|
||||||
|
placeholder="000 000"
|
||||||
|
required
|
||||||
|
maxLength={7}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={verifying2FA || totpCode.replace(/\s/g, '').length < 6}
|
||||||
|
className="btn-primary w-full py-3"
|
||||||
|
>
|
||||||
|
{verifying2FA ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('auth.2fa.verify')}
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="block mt-4 w-full text-center text-sm text-th-text-s hover:text-th-text transition-colors"
|
||||||
|
>
|
||||||
|
{t('auth.2fa.backToLogin')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
|
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
|
||||||
<p className="text-th-text-s">
|
<p className="text-th-text-s">
|
||||||
@@ -186,6 +285,8 @@ export default function Login() {
|
|||||||
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||||
{t('auth.backToHome')}
|
{t('auth.backToHome')}
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default function RoomDetail() {
|
|||||||
// Federation invite state
|
// Federation invite state
|
||||||
const [showFedInvite, setShowFedInvite] = useState(false);
|
const [showFedInvite, setShowFedInvite] = useState(false);
|
||||||
const [fedAddress, setFedAddress] = useState('');
|
const [fedAddress, setFedAddress] = useState('');
|
||||||
|
const [fedEmails, setFedEmails] = useState('');
|
||||||
const [fedMessage, setFedMessage] = useState('');
|
const [fedMessage, setFedMessage] = useState('');
|
||||||
const [fedSending, setFedSending] = useState(false);
|
const [fedSending, setFedSending] = useState(false);
|
||||||
|
|
||||||
@@ -266,25 +267,51 @@ export default function RoomDetail() {
|
|||||||
|
|
||||||
const handleFedInvite = async (e) => {
|
const handleFedInvite = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Accept @user@domain or user@domain — must have a domain part
|
const hasAddress = fedAddress.trim().length > 0;
|
||||||
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
|
const hasEmails = fedEmails.trim().length > 0;
|
||||||
if (!normalized.includes('@') || normalized.endsWith('@')) {
|
|
||||||
|
if (!hasAddress && !hasEmails) {
|
||||||
toast.error(t('federation.addressHint'));
|
toast.error(t('federation.addressHint'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFedSending(true);
|
setFedSending(true);
|
||||||
try {
|
try {
|
||||||
|
if (hasAddress) {
|
||||||
|
// Federation address mode
|
||||||
|
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
|
||||||
|
if (!normalized.includes('@') || normalized.endsWith('@')) {
|
||||||
|
toast.error(t('federation.addressHint'));
|
||||||
|
setFedSending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await api.post('/federation/invite', {
|
await api.post('/federation/invite', {
|
||||||
room_uid: uid,
|
room_uid: uid,
|
||||||
to: fedAddress,
|
to: fedAddress,
|
||||||
message: fedMessage || undefined,
|
message: fedMessage || undefined,
|
||||||
});
|
});
|
||||||
toast.success(t('federation.sent'));
|
toast.success(t('federation.sent'));
|
||||||
|
} else {
|
||||||
|
// Email mode
|
||||||
|
const emailList = fedEmails.split(',').map(e => e.trim()).filter(Boolean);
|
||||||
|
if (emailList.length === 0) {
|
||||||
|
toast.error(t('federation.emailHint'));
|
||||||
|
setFedSending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.post('/rooms/invite-email', {
|
||||||
|
room_uid: uid,
|
||||||
|
emails: emailList,
|
||||||
|
message: fedMessage || undefined,
|
||||||
|
});
|
||||||
|
toast.success(t('federation.emailSent'));
|
||||||
|
}
|
||||||
setShowFedInvite(false);
|
setShowFedInvite(false);
|
||||||
setFedAddress('');
|
setFedAddress('');
|
||||||
|
setFedEmails('');
|
||||||
setFedMessage('');
|
setFedMessage('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.response?.data?.error || t('federation.sendFailed'));
|
toast.error(err.response?.data?.error || t(hasAddress ? 'federation.sendFailed' : 'federation.emailSendFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setFedSending(false);
|
setFedSending(false);
|
||||||
}
|
}
|
||||||
@@ -717,7 +744,7 @@ export default function RoomDetail() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
|
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
|
||||||
<p className="text-sm text-th-text font-medium truncate">
|
<p className="text-sm text-th-text font-medium truncate">
|
||||||
{room.presentation_name || `presentation.${room.presentation_file?.split('.').pop()}`}
|
{room.presentation_file}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -857,13 +884,33 @@ export default function RoomDetail() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={fedAddress}
|
value={fedAddress}
|
||||||
onChange={e => setFedAddress(e.target.value)}
|
onChange={e => { setFedAddress(e.target.value); if (e.target.value) setFedEmails(''); }}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
placeholder={t('federation.addressPlaceholder')}
|
placeholder={t('federation.addressPlaceholder')}
|
||||||
required
|
disabled={fedEmails.trim().length > 0}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
|
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 my-2">
|
||||||
|
<div className="flex-1 border-t border-th-border" />
|
||||||
|
<span className="text-xs text-th-text-s uppercase">{t('common.or')}</span>
|
||||||
|
<div className="flex-1 border-t border-th-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.emailLabel')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={fedEmails}
|
||||||
|
onChange={e => { setFedEmails(e.target.value); if (e.target.value) setFedAddress(''); }}
|
||||||
|
className="input-field"
|
||||||
|
placeholder={t('federation.emailPlaceholder')}
|
||||||
|
disabled={fedAddress.trim().length > 0}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-th-text-s mt-1">{t('federation.emailHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -878,7 +925,7 @@ export default function RoomDetail() {
|
|||||||
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
|
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
|
<button type="submit" disabled={fedSending || (!fedAddress.trim() && !fedEmails.trim())} className="btn-primary flex-1">
|
||||||
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||||
{t('federation.send')}
|
{t('federation.send')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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