diff --git a/.gitignore b/.gitignore
index 3bb9b42..fdd4730 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ node_modules/
data.db
storage/
CLAUDE.md
+.env
diff --git a/index.js b/index.js
index e2cbbab..80d5951 100644
--- a/index.js
+++ b/index.js
@@ -1,3 +1,4 @@
+require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
@@ -41,6 +42,7 @@ const html = f => (req, res) => res.sendFile(path.join(__dirname, 'public', f));
app.get('/login', html('login.html'));
app.get('/admin', html('admin.html'));
app.get('/datenschutz', html('datenschutz.html'));
+app.get('/app', html('app.html'));
app.get('/{*path}', html('index.html'));
app.listen(PORT, '127.0.0.1', () => console.log(`info1 läuft auf :${PORT}`));
diff --git a/package-lock.json b/package-lock.json
index 4165d0f..220cd09 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,13 +12,15 @@
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.9.0",
"cookie-parser": "^1.4.7",
+ "dotenv": "^17.4.2",
"express": "^5.2.1",
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"otplib": "^13.4.0",
- "qrcode": "^1.5.4"
+ "qrcode": "^1.5.4",
+ "resend": "^6.12.0"
}
},
"node_modules/@noble/hashes": {
@@ -98,6 +100,12 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@stablelib/base64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -496,6 +504,18 @@
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
+ "node_modules/dotenv": {
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -673,6 +693,12 @@
"node": ">=6.6.0"
}
},
+ "node_modules/fast-sha256": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+ "license": "Unlicense"
+ },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -1338,6 +1364,12 @@
"node": ">=10.13.0"
}
},
+ "node_modules/postal-mime": {
+ "version": "2.7.4",
+ "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
+ "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
+ "license": "MIT-0"
+ },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1488,6 +1520,27 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
+ "node_modules/resend": {
+ "version": "6.12.0",
+ "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.0.tgz",
+ "integrity": "sha512-CaxEvX1z+/MGbgnhsM/bvmkbnZd1v1sEXELAjBNSDBQNMaB7MgqOyrBgI27CYikEgdaDnBrXghAXYWTBs/h5Bw==",
+ "license": "MIT",
+ "dependencies": {
+ "postal-mime": "2.7.4",
+ "svix": "1.90.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@react-email/render": "*"
+ },
+ "peerDependenciesMeta": {
+ "@react-email/render": {
+ "optional": true
+ }
+ }
+ },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -1716,6 +1769,16 @@
"simple-concat": "^1.0.0"
}
},
+ "node_modules/standardwebhooks": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
+ "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/base64": "^1.0.0",
+ "fast-sha256": "^1.3.0"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1777,6 +1840,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/svix": {
+ "version": "1.90.0",
+ "resolved": "https://registry.npmjs.org/svix/-/svix-1.90.0.tgz",
+ "integrity": "sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==",
+ "license": "MIT",
+ "dependencies": {
+ "standardwebhooks": "1.0.0",
+ "uuid": "^10.0.0"
+ }
+ },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@@ -1861,6 +1934,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
+ "node_modules/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/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 8031ec1..3174d5a 100644
--- a/package.json
+++ b/package.json
@@ -13,12 +13,14 @@
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.9.0",
"cookie-parser": "^1.4.7",
+ "dotenv": "^17.4.2",
"express": "^5.2.1",
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"otplib": "^13.4.0",
- "qrcode": "^1.5.4"
+ "qrcode": "^1.5.4",
+ "resend": "^6.12.0"
}
}
diff --git a/public/admin.html b/public/admin.html
index 3f14492..88d1ef5 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -3,7 +3,7 @@
-IFB · Admin
+INFO1 · Admin
@@ -81,6 +81,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; }
.btn-orange { background: #fef3c7; color: #92400e; }
.btn-blue { background: var(--blue-50); color: var(--blue); }
.btn-gray { background: var(--slate-100); color: var(--slate-600); }
+.btn-purple { background: #f3e8ff; color: #6b21a8; }
/* STATS ROW */
.stats-row { display: flex; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
@@ -126,7 +127,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; }
-
IFB-Berufsfachschule Rosenheim
+
Klassenportal
INFO1
@@ -172,7 +173,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; }
- | ID | Benutzername | E-Mail | Rolle | Status | Registriert | Aktionen |
+ ID | Benutzername | E-Mail | Rolle | Status | Bestätigt | Registriert | Aktionen |
@@ -288,11 +289,13 @@ async function api(method, path, body){
}
// ── AUTH GUARD ─────────────────────────────────────────────────
+let currentAdminId = null;
async function authGuard(){
const r = await fetch('/api/me');
if(!r.ok){ location.href='/login'; return false; }
const d = await r.json();
if(d.role !== 'admin'){ location.href='/'; return false; }
+ currentAdminId = d.id;
document.getElementById('admin-username-line').textContent = `Eingeloggt als ${d.username}`;
return true;
}
@@ -353,7 +356,7 @@ function renderUsers(){
}
function renderUsersRows(users, tbody){
- if(!users.length){ tbody.innerHTML=`
| Keine Benutzer gefunden |
`; return; }
+ if(!users.length){ tbody.innerHTML=`
| Keine Benutzer gefunden |
`; return; }
tbody.innerHTML=users.map(u=>`
| ${u.id} |
@@ -361,11 +364,18 @@ function renderUsersRows(users, tbody){
${esc(u.email)} |
${roleBadge(u.role)} |
${statusBadge(u.status)} |
+ ${verifiedBadge(u.email_verified)} |
${fmtDate(u.created_at)} |
${userActions(u)} |
`).join('');
}
+function verifiedBadge(v){
+ return v
+ ? `
✓ bestätigt`
+ : `
nicht bestätigt`;
+}
+
function roleBadge(r){
const m={admin:'badge-purple',teacher:'badge-blue',student:'badge-green'};
return `
${esc(r)}`;
@@ -379,7 +389,11 @@ function userActions(u){
const parts=[];
if(u.status==='active') parts.push(`
`);
if(u.status==='banned') parts.push(`
`);
- if(u.role!=='admin') parts.push(`
`);
+ if(!u.email_verified) parts.push(`
`);
+ else parts.push(`
`);
+ if(u.role!=='admin') parts.push(`
`);
+ else if(u.id!==currentAdminId) parts.push(`
`);
+ parts.push(`
`);
return parts.join('');
}
@@ -418,6 +432,28 @@ async function deleteUser(id, name){
toast(`${name} gelöscht`,'success'); loadUsers();
});
}
+async function verifyUser(id, name){
+ await api('PATCH',`admin/users/${id}`,{email_verified:1});
+ toast(`${name} E-Mail bestätigt`,'success'); loadUsers();
+}
+async function makeAdmin(id, name){
+ confirm2(`"${name}" zu Admin machen?`, `Der Benutzer erhält vollen Administratorzugriff.`, async()=>{
+ await api('PATCH',`admin/users/${id}`,{role:'admin'});
+ toast(`${name} ist jetzt Admin`,'success'); loadUsers();
+ });
+}
+async function demoteAdmin(id, name){
+ confirm2(`Admin-Rechte von "${name}" entfernen?`, `Der Benutzer wird zum Schüler herabgestuft.`, async()=>{
+ await api('PATCH',`admin/users/${id}`,{role:'student'});
+ toast(`${name} ist kein Admin mehr`,'success'); loadUsers();
+ });
+}
+async function unverifyUser(id, name){
+ confirm2(`Bestätigung für "${name}" entfernen?`, `Der Benutzer kann sich bis zur erneuten Bestätigung nicht mehr anmelden.`, async()=>{
+ await api('PATCH',`admin/users/${id}`,{email_verified:0});
+ toast(`${name} Bestätigung entfernt`,'success'); loadUsers();
+ });
+}
async function approveTeacher(id, name){
await api('POST',`admin/teachers/${id}/approve`);
toast(`${name} genehmigt`,'success'); loadUsers();
diff --git a/public/login.html b/public/login.html
index 532cbc8..4867138 100644
--- a/public/login.html
+++ b/public/login.html
@@ -3,7 +3,7 @@
-
IFB · INFO1 – Anmelden
+
INFO1 · Anmelden
@@ -196,7 +196,7 @@ footer a:hover { color: #2563eb; }
i1
- IFB-Berufsfachschule Rosenheim
+ Klassenportal
INFO1
@@ -228,7 +228,12 @@ footer a:hover { color: #2563eb; }
+ E-Mail erfolgreich bestätigt. Du kannst dich jetzt anmelden.
+
+
E-Mail noch nicht bestätigt?
+
+
@@ -262,14 +267,14 @@ footer a:hover { color: #2563eb; }
- Registrierung erfolgreich! Dein Lehrerkonto wird von einem Administrator geprüft und freigeschaltet.
+ Registrierung erfolgreich! Wir haben dir eine Bestätigungs-Mail geschickt. Bitte prüfe dein Postfach und klicke auf den Link.
@@ -317,12 +322,16 @@ function backToStep1() {
}
async function doLogin(e) {
- e.preventDefault(); clearErr('login-err');
+ e.preventDefault(); clearErr('login-err'); clearErr('login-resend');
const body = { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value };
if (totpPending) body.totp_token = document.getElementById('l-totp').value;
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const d = await r.json();
- if (!r.ok) { showErr('login-err', d.error); return; }
+ if (!r.ok) {
+ showErr('login-err', d.error);
+ if (d.needVerify) document.getElementById('login-resend').classList.add('show');
+ return;
+ }
if (d.requireTotp) {
totpPending = true;
document.getElementById('login-step-1').style.display = 'none';
@@ -332,7 +341,7 @@ async function doLogin(e) {
document.getElementById('l-totp').focus();
return;
}
- window.location.href = '/';
+ window.location.href = '/app';
}
async function doRegister(e) {
@@ -347,18 +356,25 @@ async function doRegister(e) {
body: JSON.stringify(body) });
const d = await r.json();
if (!r.ok) { showErr('reg-err', d.error); return; }
- if (d.pending) {
- document.getElementById('reg-ok').classList.add('show');
- document.getElementById('reg-btn').disabled = true;
- document.getElementById('reg-btn').textContent = 'Registriert';
- } else {
- window.location.href = '/';
- }
+ document.getElementById('reg-ok').classList.add('show');
+ document.getElementById('reg-btn').disabled = true;
+ document.getElementById('reg-btn').textContent = 'Registriert';
+}
+
+async function resendVerify() {
+ const username = document.getElementById('l-user').value;
+ if (!username) return;
+ const email = username.toLowerCase() + '@ifb-schulen.com';
+ const btn = document.getElementById('resend-btn');
+ btn.disabled = true; btn.textContent = 'Wird gesendet...';
+ const r = await fetch('/api/resend-verify', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ email }) });
+ if (r.ok) btn.textContent = 'Mail gesendet ✓';
+ else { const d = await r.json().catch(()=>({})); btn.textContent = d.error || 'Fehler'; btn.disabled = false; }
}
function checkStrength(pw) {
let score = 0;
- if (pw.length >= 6) score++;
+ if (pw.length >= 8) score++;
if (pw.length >= 10) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
@@ -379,8 +395,9 @@ function checkStrength(pw) {
const p = new URLSearchParams(location.search);
if (p.get('tab') === 'register') switchTab('register');
+if (p.get('verified') === '1') document.getElementById('login-verified-ok').classList.add('show');
-fetch('/api/me').then(r => { if (r.ok) window.location.href = '/'; });
+fetch('/api/me').then(r => { if (r.ok) window.location.href = '/app'; });
lucide.createIcons();
diff --git a/src/db.js b/src/db.js
index 4fcbfe4..78f91f7 100644
--- a/src/db.js
+++ b/src/db.js
@@ -220,5 +220,8 @@ try { db.exec(`ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'pendin
try { db.exec(`ALTER TABLE users ADD COLUMN subject TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN totp_secret TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0`); } catch {}
+try { db.exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0`); } catch {}
+try { db.exec(`ALTER TABLE users ADD COLUMN verify_token TEXT`); } catch {}
+try { db.exec(`ALTER TABLE users ADD COLUMN verify_expires TEXT`); } catch {}
module.exports = db;
diff --git a/src/mailer.js b/src/mailer.js
new file mode 100644
index 0000000..91ad50e
--- /dev/null
+++ b/src/mailer.js
@@ -0,0 +1,114 @@
+const { Resend } = require('resend');
+
+const apiKey = process.env.RESEND_API_KEY;
+const MAIL_FROM = process.env.MAIL_FROM || 'noreply@info1.simon0x.xyz';
+const MAIL_FROM_NAME = process.env.MAIL_FROM_NAME || 'INFO1 Portal';
+const APP_URL = process.env.APP_URL || 'http://127.0.0.1:3010';
+
+if (!apiKey) {
+ console.warn('WARN: RESEND_API_KEY not set. Verification emails will fail.');
+}
+
+const resend = apiKey ? new Resend(apiKey) : null;
+
+function renderVerifyHtml(link) {
+ return `
+
+
+
+
+E-Mail bestätigen
+
+
+
+
+
+
+
+
+
+
+ |
+ i1
+ |
+
+ Klassenportal
+ INFO1
+ |
+
+
+ |
+
+
+
+ E-Mail bestätigen
+
+ Willkommen beim INFO1-Klassenportal. Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiviert werden kann.
+
+
+ Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
+ ${link}
+
+
+ Dieser Link ist 24 Stunden gültig. Falls du diese Registrierung nicht angefordert hast, ignoriere bitte diese Nachricht.
+
+
+ |
+
+
+ |
+
+ Diese Nachricht wurde automatisch erzeugt. Antworten werden nicht gelesen.
+
+
+ INFO1 ist ein privates Klassenportal von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.
+
+ |
+
+
+ INFO1 Klassenportal
+ |
+
+
+
+`;
+}
+
+function renderVerifyText(link) {
+ return [
+ 'INFO1 Klassenportal',
+ '',
+ 'E-Mail bestätigen',
+ '',
+ 'Willkommen beim INFO1-Klassenportal. Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiviert werden kann.',
+ '',
+ 'Zum Bestätigen diesen Link öffnen:',
+ link,
+ '',
+ 'Der Link ist 24 Stunden gültig. Falls du diese Registrierung nicht angefordert hast, ignoriere bitte diese Nachricht.',
+ '',
+ 'INFO1 ist ein privates Klassenportal von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.',
+ ].join('\n');
+}
+
+async function sendVerifyMail(email, token) {
+ if (!resend) throw new Error('Mailer not configured');
+ const link = `${APP_URL}/api/verify?token=${encodeURIComponent(token)}`;
+ return resend.emails.send({
+ from: `${MAIL_FROM_NAME} <${MAIL_FROM}>`,
+ to: email,
+ subject: 'INFO1: E-Mail bestätigen',
+ html: renderVerifyHtml(link),
+ text: renderVerifyText(link),
+ });
+}
+
+module.exports = { sendVerifyMail };
diff --git a/src/routes.js b/src/routes.js
index bd27eec..744fb9c 100644
--- a/src/routes.js
+++ b/src/routes.js
@@ -1,4 +1,5 @@
const express = require('express');
+const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const { generateSecret, generateURI, verifySync } = require('otplib');
@@ -6,6 +7,9 @@ const QRCode = require('qrcode');
const db = require('./db');
const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth');
const { deleteUserFiles } = require('./files');
+const { sendVerifyMail } = require('./mailer');
+
+const VERIFY_TTL_MS = 24 * 60 * 60 * 1000;
const router = express.Router();
@@ -35,11 +39,19 @@ const passwordChangeLimiter = rateLimit({
validate: { keyGeneratorIpFallback: false },
});
+const resendVerifyLimiter = rateLimit({
+ windowMs: 60 * 60 * 1000,
+ max: 3,
+ message: { error: 'Zu viele Verifizierungsmails. Bitte 1 Stunde warten.' },
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
// --- AUTH ---
const IFB_EMAIL_RE = /^[a-z]\.[a-z]{2,}@ifb-schulen\.com$/i;
const VALID_SUBJECTS = ['Deutsch','Mathematik','Englisch','Informatik','Wirtschaft','Buchführung','BWL','VWL','Recht','Rechnungswesen','Sport','Religion','Geschichte','Gemeinschaftskunde','Physik','Chemie','Biologie','Sozialkunde','Ethik','Sonstiges'];
-router.post('/register', registerLimiter, (req, res) => {
+router.post('/register', registerLimiter, async (req, res) => {
const { email, password, role, subject } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Alle Felder erforderlich' });
if (!IFB_EMAIL_RE.test(email)) return res.status(403).json({ error: 'Ungültige E-Mail-Adresse' });
@@ -57,22 +69,33 @@ router.post('/register', registerLimiter, (req, res) => {
}
const hash = bcrypt.hashSync(password, 12);
+ const verifyToken = crypto.randomBytes(32).toString('hex');
+ const verifyExpires = new Date(Date.now() + VERIFY_TTL_MS).toISOString();
+
+ let userId;
try {
- const result = db.prepare('INSERT INTO users (username, email, password_hash, role, status, subject) VALUES (?, ?, ?, ?, ?, ?)').run(username, email.toLowerCase(), hash, safeRole, initialStatus, safeSubject);
- if (safeRole === 'teacher') {
- return res.json({ ok: true, pending: true });
- }
- const user = { id: result.lastInsertRowid, username, role: safeRole, subject: null };
- const token = signToken(user);
- res.cookie('token', token, COOKIE_OPTIONS);
- res.json({ ok: true, pending: false });
+ const result = db.prepare(`
+ INSERT INTO users (username, email, password_hash, role, status, subject, email_verified, verify_token, verify_expires)
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
+ `).run(username, email.toLowerCase(), hash, safeRole, initialStatus, safeSubject, verifyToken, verifyExpires);
+ userId = result.lastInsertRowid;
} catch (e) {
if (e.message.includes('UNIQUE')) {
if (e.message.includes('email')) return res.status(409).json({ error: 'Diese E-Mail-Adresse ist bereits registriert' });
return res.status(409).json({ error: 'Benutzername bereits vergeben' });
}
- res.status(500).json({ error: 'Serverfehler' });
+ return res.status(500).json({ error: 'Serverfehler' });
}
+
+ try {
+ await sendVerifyMail(email.toLowerCase(), verifyToken);
+ } catch (e) {
+ console.error('sendVerifyMail failed:', e);
+ db.prepare('DELETE FROM users WHERE id = ?').run(userId);
+ return res.status(500).json({ error: 'Verifizierungsmail konnte nicht gesendet werden. Bitte später erneut versuchen.' });
+ }
+
+ res.json({ ok: true, verify: true, pending: safeRole === 'teacher' });
});
router.post('/login', loginLimiter, (req, res) => {
@@ -81,6 +104,9 @@ router.post('/login', loginLimiter, (req, res) => {
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Falscher Benutzername oder Passwort' });
}
+ if (!user.email_verified) {
+ return res.status(403).json({ error: 'E-Mail-Adresse nicht bestätigt. Bitte prüfe dein Postfach.', needVerify: true });
+ }
if (user.status === 'pending') {
return res.status(403).json({ error: 'Dein Konto wartet noch auf Freischaltung durch einen Administrator.' });
}
@@ -108,6 +134,47 @@ router.post('/logout', (req, res) => {
res.json({ ok: true });
});
+router.get('/verify', (req, res) => {
+ const { token } = req.query;
+ const fail = (msg) => res.status(400).send(`
+ Verifizierung
+
+
Verifizierung fehlgeschlagen
+
${msg}
+
Zum Login
+
+ `);
+ if (!token || typeof token !== 'string') return fail('Ungültiger Link.');
+ const user = db.prepare('SELECT id, verify_expires, email_verified FROM users WHERE verify_token = ?').get(token);
+ if (!user) return fail('Token unbekannt oder bereits verwendet.');
+ if (user.email_verified) {
+ return res.redirect('/login?verified=1');
+ }
+ if (!user.verify_expires || new Date(user.verify_expires) < new Date()) {
+ return fail('Link abgelaufen. Bitte neue Verifizierungsmail anfordern.');
+ }
+ db.prepare('UPDATE users SET email_verified = 1, verify_token = NULL, verify_expires = NULL WHERE id = ?').run(user.id);
+ res.redirect('/login?verified=1');
+});
+
+router.post('/resend-verify', resendVerifyLimiter, async (req, res) => {
+ const { email } = req.body;
+ if (!email || !IFB_EMAIL_RE.test(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse' });
+ const user = db.prepare('SELECT id, email_verified FROM users WHERE email = ?').get(email.toLowerCase());
+ // Keine Info leaken: gleiche Antwort auch wenn User nicht existiert
+ if (!user || user.email_verified) return res.json({ ok: true });
+ const newToken = crypto.randomBytes(32).toString('hex');
+ const newExpires = new Date(Date.now() + VERIFY_TTL_MS).toISOString();
+ db.prepare('UPDATE users SET verify_token = ?, verify_expires = ? WHERE id = ?').run(newToken, newExpires, user.id);
+ try {
+ await sendVerifyMail(email.toLowerCase(), newToken);
+ } catch (e) {
+ console.error('sendVerifyMail failed:', e);
+ return res.status(500).json({ error: 'Mail konnte nicht gesendet werden' });
+ }
+ res.json({ ok: true });
+});
+
router.get('/me', requireAuth, (req, res) => {
const user = db.prepare('SELECT id, username, email, role, status, subject FROM users WHERE id = ?').get(req.user.id);
if (!user || user.status !== 'active') return res.status(403).json({ error: 'Konto nicht aktiv' });
@@ -154,7 +221,7 @@ function logAdmin(adminId, action, targetId = null, details = null) {
router.get('/admin/users', requireAuth, requireAdmin, (req, res) => {
const { role, status } = req.query;
- let sql = 'SELECT id, username, email, role, status, created_at FROM users';
+ let sql = 'SELECT id, username, email, role, status, email_verified, created_at FROM users';
const params = [];
const conditions = [];
if (role) { conditions.push('role = ?'); params.push(role); }
@@ -183,19 +250,28 @@ router.post('/admin/teachers/:id/reject', requireAuth, requireAdmin, (req, res)
});
router.patch('/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
- const { role, status } = req.body;
+ const { role, status, email_verified } = req.body;
const allowed_roles = ['student', 'teacher', 'admin'];
const allowed_status = ['active', 'pending', 'banned', 'rejected'];
if (role && !allowed_roles.includes(role)) return res.status(400).json({ error: 'Ungültige Rolle' });
if (status && !allowed_status.includes(status)) return res.status(400).json({ error: 'Ungültiger Status' });
- if (!role && !status) return res.status(400).json({ error: 'Keine Änderung angegeben' });
+ const hasEmailVerified = email_verified === 0 || email_verified === 1;
+ if (email_verified !== undefined && !hasEmailVerified) return res.status(400).json({ error: 'Ungültiger email_verified-Wert' });
+ if (!role && !status && !hasEmailVerified) return res.status(400).json({ error: 'Keine Änderung angegeben' });
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (role) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id);
if (status) db.prepare('UPDATE users SET status = ? WHERE id = ?').run(status, req.params.id);
- logAdmin(req.user.id, 'user_update', Number(req.params.id), { role, status });
+ if (hasEmailVerified) {
+ if (email_verified === 1) {
+ db.prepare('UPDATE users SET email_verified = 1, verify_token = NULL, verify_expires = NULL WHERE id = ?').run(req.params.id);
+ } else {
+ db.prepare('UPDATE users SET email_verified = 0 WHERE id = ?').run(req.params.id);
+ }
+ }
+ logAdmin(req.user.id, 'user_update', Number(req.params.id), { role, status, email_verified });
res.json({ ok: true });
});