Add mail verification and use .env insteads of environment in compose
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled

This commit is contained in:
2026-02-24 20:35:08 +01:00
parent 3898bf1b4b
commit 8be973a166
14 changed files with 388 additions and 19 deletions

View File

@@ -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

View File

@@ -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:

24
package-lock.json generated
View File

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

View File

@@ -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": {

View File

@@ -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}`);

70
server/config/mailer.js Normal file
View File

@@ -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: `
<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;">Hey ${name} 👋</h2>
<p>Bitte bestätige deine E-Mail-Adresse, indem du auf den folgenden Button klickst:</p>
<p style="text-align:center;margin:28px 0;">
<a href="${verifyUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
E-Mail bestätigen
</a>
</p>
<p style="font-size:13px;color:#7f849c;">
Oder kopiere diesen Link in deinen Browser:<br/>
<a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${verifyUrl}</a>
</p>
<p style="font-size:13px;color:#7f849c;">Der Link ist 24 Stunden gültig.</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">Falls du dich nicht registriert hast, ignoriere diese E-Mail.</p>
</div>
`,
text: `Hey ${name},\n\nBitte bestätige deine E-Mail: ${verifyUrl}\n\nDer Link ist 24 Stunden gültig.\n\n ${appName}`,
});
}

View File

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

View File

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

View File

@@ -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() {
<Route path="/" element={user ? <Navigate to="/dashboard" /> : <Home />} />
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/join/:uid" element={<GuestJoin />} />
{/* Protected routes */}

View File

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

View File

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

View File

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

View File

@@ -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() {
<BrandLogo size="lg" />
</div>
{needsVerification ? (
<div className="text-center space-y-4">
<CheckCircle size={48} className="mx-auto text-green-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.checkYourEmail')}</h2>
<p className="text-th-text-s">{t('auth.verificationSentDesc')}</p>
<p className="text-sm text-th-text-s font-medium">{email}</p>
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.login')}
</Link>
</div>
) : (
<>
<div className="mb-8">
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.createAccount')}</h2>
<p className="text-th-text-s">
@@ -156,6 +174,8 @@ export default function Register() {
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToHome')}
</Link>
</>
)}
</div>
</div>
</div>

87
src/pages/VerifyEmail.jsx Normal file
View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
{/* Animated background */}
<div className="absolute inset-0 bg-th-bg">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
</div>
</div>
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl text-center">
<div className="flex justify-center mb-8">
<BrandLogo size="lg" />
</div>
{status === 'loading' && (
<div className="space-y-4">
<Loader2 size={48} className="mx-auto animate-spin text-th-accent" />
<p className="text-th-text">{t('auth.verifying')}</p>
</div>
)}
{status === 'success' && (
<div className="space-y-4">
<CheckCircle size={48} className="mx-auto text-green-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.verifySuccessTitle')}</h2>
<p className="text-th-text-s">{message}</p>
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.login')}
</Link>
</div>
)}
{status === 'error' && (
<div className="space-y-4">
<XCircle size={48} className="mx-auto text-red-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.verifyFailedTitle')}</h2>
<p className="text-th-text-s">{message}</p>
<Link to="/register" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.register')}
</Link>
</div>
)}
<Link to="/" className="block mt-6 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToHome')}
</Link>
</div>
</div>
</div>
);
}