diff --git a/.env.example b/.env.example
index 8ef9057..711a195 100644
--- a/.env.example
+++ b/.env.example
@@ -12,9 +12,24 @@ JWT_SECRET=your-super-secret-jwt-key-change-this
# DATABASE_URL=postgres://user:password@localhost:5432/redlight
DATABASE_URL=
+POSTGRES_USER=redlight
+POSTGRES_PASSWORD=redlight
+POSTGRES_DB=redlight
+
# SQLite file path (only used when DATABASE_URL is not set)
# SQLITE_PATH=./redlight.db
# Default Admin Account (created on first run)
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=admin123
+
+# SMTP Configuration (for email verification)
+# If not set, registration works without email verification
+SMTP_HOST=smtp.example.com
+SMTP_PORT=587
+SMTP_USER=noreply@example.com
+SMTP_PASS=your-smtp-password
+SMTP_FROM=noreply@example.com
+
+# App URL (used for verification links, auto-detected if not set)
+# APP_URL=https://your-domain.com
diff --git a/compose.yml b/compose.yml
index 46ba61f..d70112f 100644
--- a/compose.yml
+++ b/compose.yml
@@ -4,13 +4,7 @@ services:
restart: unless-stopped
ports:
- "3001:3001"
- environment:
- DATABASE_URL: postgres://redlight:redlight@postgres:5432/redlight
- BBB_URL: https://your-bbb-server.com/bigbluebutton/api/
- BBB_SECRET: your-bbb-shared-secret
- JWT_SECRET: change-me-to-a-random-secret
- ADMIN_EMAIL: admin@example.com
- ADMIN_PASSWORD: admin123
+ env_file: ".env"
volumes:
- uploads:/app/uploads
depends_on:
@@ -20,10 +14,7 @@ services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
- environment:
- POSTGRES_USER: redlight
- POSTGRES_PASSWORD: redlight
- POSTGRES_DB: redlight
+ env_file: ".env"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
diff --git a/package-lock.json b/package-lock.json
index 058ccac..4271bda 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,11 +18,13 @@
"jsonwebtoken": "^9.0.0",
"lucide-react": "^0.460.0",
"multer": "^2.0.2",
+ "nodemailer": "^8.0.1",
"pg": "^8.18.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hot-toast": "^2.4.0",
"react-router-dom": "^6.28.0",
+ "uuid": "^13.0.0",
"xml2js": "^0.6.0"
},
"devDependencies": {
@@ -3180,6 +3182,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/nodemailer": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
+ "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -4607,6 +4618,19 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/uuid": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
diff --git a/package.json b/package.json
index c82082f..948adc6 100644
--- a/package.json
+++ b/package.json
@@ -22,11 +22,13 @@
"jsonwebtoken": "^9.0.0",
"lucide-react": "^0.460.0",
"multer": "^2.0.2",
+ "nodemailer": "^8.0.1",
"pg": "^8.18.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hot-toast": "^2.4.0",
"react-router-dom": "^6.28.0",
+ "uuid": "^13.0.0",
"xml2js": "^0.6.0"
},
"devDependencies": {
diff --git a/server/config/database.js b/server/config/database.js
index bc4ab58..379427e 100644
--- a/server/config/database.js
+++ b/server/config/database.js
@@ -134,6 +134,9 @@ export async function initDatabase() {
theme TEXT DEFAULT 'dark',
avatar_color TEXT DEFAULT '#6366f1',
avatar_image TEXT DEFAULT NULL,
+ email_verified INTEGER DEFAULT 0,
+ verification_token TEXT,
+ verification_token_expires TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
@@ -189,6 +192,9 @@ export async function initDatabase() {
theme TEXT DEFAULT 'dark',
avatar_color TEXT DEFAULT '#6366f1',
avatar_image TEXT DEFAULT NULL,
+ email_verified INTEGER DEFAULT 0,
+ verification_token TEXT,
+ verification_token_expires DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -247,6 +253,19 @@ export async function initDatabase() {
if (!(await db.columnExists('rooms', 'moderator_code'))) {
await db.exec('ALTER TABLE rooms ADD COLUMN moderator_code TEXT');
}
+ if (!(await db.columnExists('users', 'email_verified'))) {
+ await db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0');
+ }
+ if (!(await db.columnExists('users', 'verification_token'))) {
+ await db.exec('ALTER TABLE users ADD COLUMN verification_token TEXT');
+ }
+ if (!(await db.columnExists('users', 'verification_token_expires'))) {
+ if (isPostgres) {
+ await db.exec('ALTER TABLE users ADD COLUMN verification_token_expires TIMESTAMP');
+ } else {
+ await db.exec('ALTER TABLE users ADD COLUMN verification_token_expires DATETIME');
+ }
+ }
// ── Default admin ───────────────────────────────────────────────────────
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
@@ -256,7 +275,7 @@ export async function initDatabase() {
if (!existingAdmin) {
const hash = bcrypt.hashSync(adminPassword, 12);
await db.run(
- 'INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)',
+ 'INSERT INTO users (name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, 1)',
['Administrator', adminEmail, hash, 'admin']
);
console.log(`✅ Default admin created: ${adminEmail}`);
diff --git a/server/config/mailer.js b/server/config/mailer.js
new file mode 100644
index 0000000..02a45c9
--- /dev/null
+++ b/server/config/mailer.js
@@ -0,0 +1,70 @@
+import nodemailer from 'nodemailer';
+
+let transporter;
+
+export function initMailer() {
+ const host = process.env.SMTP_HOST;
+ const port = parseInt(process.env.SMTP_PORT || '587', 10);
+ const user = process.env.SMTP_USER;
+ const pass = process.env.SMTP_PASS;
+
+ if (!host || !user || !pass) {
+ console.warn('⚠️ SMTP not configured – email verification disabled');
+ return false;
+ }
+
+ transporter = nodemailer.createTransport({
+ host,
+ port,
+ secure: port === 465,
+ auth: { user, pass },
+ });
+
+ console.log('✅ SMTP mailer configured');
+ return true;
+}
+
+export function isMailerConfigured() {
+ return !!transporter;
+}
+
+/**
+ * Send the verification email with a clickable link.
+ * @param {string} to – recipient email
+ * @param {string} name – user's display name
+ * @param {string} verifyUrl – full verification URL
+ * @param {string} appName – branding app name (default "Redlight")
+ */
+export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') {
+ if (!transporter) {
+ throw new Error('SMTP not configured');
+ }
+
+ const from = process.env.SMTP_FROM || process.env.SMTP_USER;
+
+ await transporter.sendMail({
+ from: `"${appName}" <${from}>`,
+ to,
+ subject: `${appName} – E-Mail bestätigen / Verify your email`,
+ html: `
+
+
Hey ${name} 👋
+
Bitte bestätige deine E-Mail-Adresse, indem du auf den folgenden Button klickst:
+
+
+ E-Mail bestätigen
+
+
+
+ Oder kopiere diesen Link in deinen Browser:
+ ${verifyUrl}
+
+
Der Link ist 24 Stunden gültig.
+
+
Falls du dich nicht registriert hast, ignoriere diese E-Mail.
+
+ `,
+ text: `Hey ${name},\n\nBitte bestätige deine E-Mail: ${verifyUrl}\n\nDer Link ist 24 Stunden gültig.\n\n– ${appName}`,
+ });
+}
diff --git a/server/index.js b/server/index.js
index 8a0178e..6eeb2f7 100644
--- a/server/index.js
+++ b/server/index.js
@@ -4,6 +4,7 @@ import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import { initDatabase } from './config/database.js';
+import { initMailer } from './config/mailer.js';
import authRoutes from './routes/auth.js';
import roomRoutes from './routes/rooms.js';
import recordingRoutes from './routes/recordings.js';
@@ -26,6 +27,7 @@ app.use(express.json());
// Initialize database & start server
async function start() {
await initDatabase();
+ initMailer();
// API Routes
app.use('/api/auth', authRoutes);
diff --git a/server/routes/auth.js b/server/routes/auth.js
index 0d6a350..526cdb8 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -1,10 +1,12 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
+import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { getDb } from '../config/database.js';
import { authenticateToken, generateToken } from '../middleware/auth.js';
+import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -37,8 +39,36 @@ router.post('/register', async (req, res) => {
}
const hash = bcrypt.hashSync(password, 12);
+
+ // If SMTP is configured, require email verification
+ if (isMailerConfigured()) {
+ const verificationToken = uuidv4();
+ const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
+
+ await db.run(
+ 'INSERT INTO users (name, email, password_hash, email_verified, verification_token, verification_token_expires) VALUES (?, ?, ?, 0, ?, ?)',
+ [name, email.toLowerCase(), hash, verificationToken, expires]
+ );
+
+ // Build verification URL
+ const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
+ const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
+
+ // Load app name from branding settings
+ const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
+ let appName = 'Redlight';
+ if (brandingSetting?.value) {
+ try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
+ }
+
+ await sendVerificationEmail(email.toLowerCase(), name, verifyUrl, appName);
+
+ return res.status(201).json({ needsVerification: true, message: 'Verifizierungs-E-Mail wurde gesendet' });
+ }
+
+ // No SMTP configured – register and login immediately (legacy behaviour)
const result = await db.run(
- 'INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)',
+ 'INSERT INTO users (name, email, password_hash, email_verified) VALUES (?, ?, ?, 1)',
[name, email.toLowerCase(), hash]
);
@@ -52,6 +82,86 @@ router.post('/register', async (req, res) => {
}
});
+// GET /api/auth/verify-email?token=...
+router.get('/verify-email', async (req, res) => {
+ try {
+ const { token } = req.query;
+ if (!token) {
+ return res.status(400).json({ error: 'Token fehlt' });
+ }
+
+ const db = getDb();
+ const user = await db.get(
+ 'SELECT id, verification_token_expires FROM users WHERE verification_token = ? AND email_verified = 0',
+ [token]
+ );
+
+ if (!user) {
+ return res.status(400).json({ error: 'Ungültiger oder bereits verwendeter Token' });
+ }
+
+ if (new Date(user.verification_token_expires) < new Date()) {
+ return res.status(400).json({ error: 'Token ist abgelaufen. Bitte registriere dich erneut.' });
+ }
+
+ await db.run(
+ 'UPDATE users SET email_verified = 1, verification_token = NULL, verification_token_expires = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
+ [user.id]
+ );
+
+ res.json({ verified: true, message: 'E-Mail erfolgreich verifiziert' });
+ } catch (err) {
+ console.error('Verify email error:', err);
+ res.status(500).json({ error: 'Verifizierung fehlgeschlagen' });
+ }
+});
+
+// POST /api/auth/resend-verification
+router.post('/resend-verification', async (req, res) => {
+ try {
+ const { email } = req.body;
+ if (!email) {
+ return res.status(400).json({ error: 'E-Mail ist erforderlich' });
+ }
+
+ if (!isMailerConfigured()) {
+ return res.status(400).json({ error: 'SMTP ist nicht konfiguriert' });
+ }
+
+ const db = getDb();
+ const user = await db.get('SELECT id, name, email_verified FROM users WHERE email = ?', [email.toLowerCase()]);
+
+ if (!user || user.email_verified) {
+ // Don't reveal whether account exists
+ return res.json({ message: 'Falls ein Konto existiert, wurde eine neue E-Mail gesendet.' });
+ }
+
+ const verificationToken = uuidv4();
+ const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
+
+ await db.run(
+ 'UPDATE users SET verification_token = ?, verification_token_expires = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
+ [verificationToken, expires, user.id]
+ );
+
+ const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
+ const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
+
+ const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
+ let appName = 'Redlight';
+ if (brandingSetting?.value) {
+ try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
+ }
+
+ await sendVerificationEmail(email.toLowerCase(), user.name, verifyUrl, appName);
+
+ res.json({ message: 'Falls ein Konto existiert, wurde eine neue E-Mail gesendet.' });
+ } catch (err) {
+ console.error('Resend verification error:', err);
+ res.status(500).json({ error: 'E-Mail konnte nicht gesendet werden' });
+ }
+});
+
// POST /api/auth/login
router.post('/login', async (req, res) => {
try {
@@ -68,6 +178,10 @@ router.post('/login', async (req, res) => {
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
}
+ if (!user.email_verified && isMailerConfigured()) {
+ return res.status(403).json({ error: 'E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.', needsVerification: true });
+ }
+
const token = generateToken(user.id);
const { password_hash, ...safeUser } = user;
diff --git a/src/App.jsx b/src/App.jsx
index 15fcc5e..5d94aa9 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -8,6 +8,7 @@ import ProtectedRoute from './components/ProtectedRoute';
import Home from './pages/Home';
import Login from './pages/Login';
import Register from './pages/Register';
+import VerifyEmail from './pages/VerifyEmail';
import Dashboard from './pages/Dashboard';
import RoomDetail from './pages/RoomDetail';
import Settings from './pages/Settings';
@@ -45,6 +46,7 @@ export default function App() {
: } />
: } />
: } />
+ } />
} />
{/* Protected routes */}
diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx
index fb61f95..c34d68e 100644
--- a/src/contexts/AuthContext.jsx
+++ b/src/contexts/AuthContext.jsx
@@ -30,6 +30,9 @@ export function AuthProvider({ children }) {
const register = useCallback(async (name, email, password) => {
const res = await api.post('/auth/register', { name, email, password });
+ if (res.data.needsVerification) {
+ return { needsVerification: true };
+ }
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data.user;
diff --git a/src/i18n/de.json b/src/i18n/de.json
index b386bc4..c6b56e7 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -61,7 +61,17 @@
"registerSuccess": "Registrierung erfolgreich!",
"loginFailed": "Anmeldung fehlgeschlagen",
"registerFailed": "Registrierung fehlgeschlagen",
- "allFieldsRequired": "Alle Felder sind erforderlich"
+ "allFieldsRequired": "Alle Felder sind erforderlich",
+ "verificationSent": "Verifizierungs-E-Mail wurde gesendet!",
+ "verificationSentDesc": "Wir haben dir eine E-Mail mit einem Bestätigungslink geschickt. Bitte klicke auf den Link, um dein Konto zu aktivieren.",
+ "checkYourEmail": "Prüfe dein Postfach",
+ "verifying": "E-Mail wird verifiziert...",
+ "verifySuccess": "Deine E-Mail-Adresse wurde erfolgreich bestätigt. Du kannst dich jetzt anmelden.",
+ "verifySuccessTitle": "E-Mail bestätigt!",
+ "verifyFailed": "Verifizierung fehlgeschlagen",
+ "verifyFailedTitle": "Verifizierung fehlgeschlagen",
+ "verifyTokenMissing": "Kein Verifizierungstoken vorhanden.",
+ "emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach."
},
"home": {
"poweredBy": "Powered by BigBlueButton",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index b979fb9..bdc8391 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -61,7 +61,17 @@
"registerSuccess": "Registration successful!",
"loginFailed": "Login failed",
"registerFailed": "Registration failed",
- "allFieldsRequired": "All fields are required"
+ "allFieldsRequired": "All fields are required",
+ "verificationSent": "Verification email sent!",
+ "verificationSentDesc": "We've sent you an email with a verification link. Please click the link to activate your account.",
+ "checkYourEmail": "Check your inbox",
+ "verifying": "Verifying your email...",
+ "verifySuccess": "Your email has been verified successfully. You can now sign in.",
+ "verifySuccessTitle": "Email verified!",
+ "verifyFailed": "Verification failed",
+ "verifyFailedTitle": "Verification failed",
+ "verifyTokenMissing": "No verification token provided.",
+ "emailNotVerified": "Email not yet verified. Please check your inbox."
},
"home": {
"poweredBy": "Powered by BigBlueButton",
diff --git a/src/pages/Register.jsx b/src/pages/Register.jsx
index 259859c..8b77630 100644
--- a/src/pages/Register.jsx
+++ b/src/pages/Register.jsx
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
-import { Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
+import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import toast from 'react-hot-toast';
@@ -12,6 +12,7 @@ export default function Register() {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
+ const [needsVerification, setNeedsVerification] = useState(false);
const { register } = useAuth();
const { t } = useLanguage();
const navigate = useNavigate();
@@ -31,9 +32,14 @@ export default function Register() {
setLoading(true);
try {
- await register(name, email, password);
- toast.success(t('auth.registerSuccess'));
- navigate('/dashboard');
+ const result = await register(name, email, password);
+ if (result?.needsVerification) {
+ setNeedsVerification(true);
+ toast.success(t('auth.verificationSent'));
+ } else {
+ toast.success(t('auth.registerSuccess'));
+ navigate('/dashboard');
+ }
} catch (err) {
toast.error(err.response?.data?.error || t('auth.registerFailed'));
} finally {
@@ -60,6 +66,18 @@ export default function Register() {
+ {needsVerification ? (
+
+
+
{t('auth.checkYourEmail')}
+
{t('auth.verificationSentDesc')}
+
{email}
+
+ {t('auth.login')}
+
+
+ ) : (
+ <>
{t('auth.createAccount')}
@@ -156,6 +174,8 @@ export default function Register() {
{t('auth.backToHome')}
+ >
+ )}
diff --git a/src/pages/VerifyEmail.jsx b/src/pages/VerifyEmail.jsx
new file mode 100644
index 0000000..5e3c183
--- /dev/null
+++ b/src/pages/VerifyEmail.jsx
@@ -0,0 +1,87 @@
+import { useState, useEffect } from 'react';
+import { Link, useSearchParams } from 'react-router-dom';
+import { useLanguage } from '../contexts/LanguageContext';
+import { CheckCircle, XCircle, Loader2, Mail } from 'lucide-react';
+import BrandLogo from '../components/BrandLogo';
+import api from '../services/api';
+
+export default function VerifyEmail() {
+ const [searchParams] = useSearchParams();
+ const token = searchParams.get('token');
+ const { t } = useLanguage();
+
+ const [status, setStatus] = useState('loading'); // loading | success | error
+ const [message, setMessage] = useState('');
+
+ useEffect(() => {
+ if (!token) {
+ setStatus('error');
+ setMessage(t('auth.verifyTokenMissing'));
+ return;
+ }
+
+ api.get(`/auth/verify-email?token=${token}`)
+ .then(() => {
+ setStatus('success');
+ setMessage(t('auth.verifySuccess'));
+ })
+ .catch(err => {
+ setStatus('error');
+ setMessage(err.response?.data?.error || t('auth.verifyFailed'));
+ });
+ }, [token]);
+
+ return (
+
+ {/* Animated background */}
+
+
+
+
+
+
+
+
+ {status === 'loading' && (
+
+
+
{t('auth.verifying')}
+
+ )}
+
+ {status === 'success' && (
+
+
+
{t('auth.verifySuccessTitle')}
+
{message}
+
+ {t('auth.login')}
+
+
+ )}
+
+ {status === 'error' && (
+
+
+
{t('auth.verifyFailedTitle')}
+
{message}
+
+ {t('auth.register')}
+
+
+ )}
+
+
+ {t('auth.backToHome')}
+
+
+
+
+ );
+}