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