Files
ifb-schulapp/public/login.html
T
Simon 396148aea2 feat: email verification via Resend + admin user management
- Add email verification flow: register sends verify link, login blocks unverified accounts, 24h token expiry, resend endpoint (3/h rate limit).
- Add mailer module using Resend with branded HTML + plaintext template.
- Extend admin dashboard: verified-status column, toggle verify/unverify buttons, promote/demote admin role, delete any non-self user.
- Migrate users table: email_verified, verify_token, verify_expires columns.
- Load env via dotenv; add .env to gitignore.
2026-04-18 01:33:45 +02:00

406 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>INFO1 · Anmelden</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f4f6f9;
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
color: #111827;
}
.page {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 14px;
box-shadow: 0 4px 16px rgba(0,0,0,.06), 0 1px 4px rgba(0,0,0,.04);
padding: 36px 32px 32px;
width: 100%;
max-width: 400px;
}
/* Brand */
.brand-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 9px;
text-decoration: none;
}
.brand-mark {
width: 36px; height: 36px;
background: #2563eb;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 800; color: #fff;
letter-spacing: -.5px; flex-shrink: 0;
}
.brand-text { display: flex; flex-direction: column; line-height: 1.2; }
.brand-sub { font-size: 10px; color: #9ca3af; font-weight: 500; letter-spacing: .2px; }
.brand-name { font-size: 16px; font-weight: 700; color: #111827; letter-spacing: -.3px; }
.back-link {
font-size: 12px;
color: #6b7280;
text-decoration: none;
display: flex; align-items: center; gap: 4px;
transition: color .12s;
padding: 4px 0;
}
.back-link:hover { color: #2563eb; }
/* Tabs */
.tabs {
display: flex;
background: #f3f4f6;
border-radius: 8px;
padding: 3px;
margin-bottom: 24px;
gap: 2px;
}
.tab {
flex: 1;
padding: 7px;
text-align: center;
font-size: 13px; font-weight: 600;
color: #6b7280;
border-radius: 6px;
cursor: pointer;
transition: background .12s, color .12s;
user-select: none;
}
.tab.active {
background: #fff;
color: #111827;
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04);
}
/* Forms */
.form { display: none; flex-direction: column; gap: 14px; }
.form.active { display: flex; }
.field { display: flex; flex-direction: column; gap: 5px; }
.field label { font-size: 12px; font-weight: 600; color: #374151; letter-spacing: .1px; }
.field input {
padding: 9px 12px;
border: 1px solid #e5e7eb;
border-radius: 7px;
font-size: 14px; font-family: inherit;
color: #111827;
background: #fff;
outline: none;
transition: border-color .12s, box-shadow .12s;
}
.field input:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
/* Role toggle */
.role-toggle {
display: flex;
background: #f3f4f6;
border-radius: 8px;
padding: 3px;
gap: 2px;
}
.role-opt {
flex: 1; padding: 7px 10px;
text-align: center;
font-size: 13px; font-weight: 600;
color: #6b7280;
border-radius: 6px;
cursor: pointer;
transition: background .12s, color .12s;
user-select: none;
}
.role-opt.active { background: #fff; color: #111827; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.role-opt.active.teacher { background: #fefce8; color: #854d0e; }
/* Notices */
.notice {
font-size: 12px;
border-radius: 7px;
padding: 9px 12px;
line-height: 1.5;
display: none;
}
.notice.show { display: block; }
.notice-amber { color: #92400e; background: #fffbeb; border: 1px solid #fde68a; }
.notice-red { color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; }
.notice-green { color: #15803d; background: #f0fdf4; border: 1px solid #bbf7d0; }
/* Password strength */
.strength-wrap { margin-top: 4px; }
.strength-bar { height: 3px; border-radius: 99px; background: #e5e7eb; overflow: hidden; }
.strength-fill { height: 100%; border-radius: 99px; transition: width .25s, background .25s; width: 0; }
.strength-label { font-size: 11px; color: #9ca3af; margin-top: 3px; }
/* Submit button */
.btn-submit {
padding: 10px;
background: #2563eb; color: #fff;
border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; font-family: inherit;
cursor: pointer;
transition: background .12s, transform .08s;
margin-top: 2px;
}
.btn-submit:hover { background: #1d4ed8; }
.btn-submit:active { transform: scale(.99); }
.btn-submit:disabled { background: #93c5fd; cursor: default; }
/* Footer */
footer {
text-align: center;
padding: 18px;
font-size: 12px;
color: #9ca3af;
}
footer a { color: #6b7280; text-decoration: none; transition: color .12s; }
footer a:hover { color: #2563eb; }
/* Icons */
.lucide { display: inline-block; vertical-align: -0.125em; flex-shrink: 0; width: 1em; height: 1em; stroke-width: 2; }
.notice .lucide { width: 14px; height: 14px; margin-right: 3px; }
</style>
</head>
<body>
<div class="page">
<div class="card">
<div class="brand-row">
<a class="brand" href="/">
<div class="brand-mark">i1</div>
<div class="brand-text">
<span class="brand-sub">Klassenportal</span>
<span class="brand-name">INFO1</span>
</div>
</a>
<a class="back-link" href="/">← Dashboard</a>
</div>
<div class="tabs">
<div class="tab active" id="tab-login" onclick="switchTab('login')">Anmelden</div>
<div class="tab" id="tab-reg" onclick="switchTab('register')">Registrieren</div>
</div>
<form class="form active" id="form-login" onsubmit="doLogin(event)">
<div id="login-step-1">
<div class="field" style="margin-bottom:14px">
<label for="l-user">Benutzername</label>
<input type="text" id="l-user" autocomplete="username" placeholder="dein.name" required>
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Der Teil deiner Schul-E-Mail vor dem @</span>
</div>
<div class="field">
<label for="l-pass">Passwort</label>
<input type="password" id="l-pass" autocomplete="current-password" placeholder="••••••" required>
</div>
</div>
<div id="login-step-2" style="display:none">
<div class="field">
<label for="l-totp">2FA-Code</label>
<input type="text" id="l-totp" autocomplete="one-time-code" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric">
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Code aus deiner Authenticator-App eingeben</span>
</div>
<button type="button" style="font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;padding:0;margin-top:4px" onclick="backToStep1()">← Zurück</button>
</div>
<div class="notice notice-green" id="login-verified-ok">E-Mail erfolgreich bestätigt. Du kannst dich jetzt anmelden.</div>
<div class="notice notice-red" id="login-err"></div>
<div class="notice notice-amber" id="login-resend" style="text-align:center">
<div style="margin-bottom:6px">E-Mail noch nicht bestätigt?</div>
<button type="button" id="resend-btn" onclick="resendVerify()" style="font-size:12px;color:#2563eb;background:none;border:none;cursor:pointer;padding:0;font-weight:600">Verifizierungsmail erneut senden</button>
</div>
<button class="btn-submit" type="submit" id="login-btn">Anmelden</button>
</form>
<form class="form" id="form-reg" onsubmit="doRegister(event)">
<div class="field">
<label>Ich bin</label>
<div class="role-toggle">
<div class="role-opt active" id="role-student" onclick="selectRole('student')">Schüler/in</div>
<div class="role-opt" id="role-teacher" onclick="selectRole('teacher')">Lehrer/in</div>
</div>
</div>
<div class="notice notice-amber" id="teacher-notice">
<i data-lucide="info" aria-hidden="true"></i> Lehrerkonten werden nach der Registrierung von einem Administrator geprüft und freigeschaltet.
</div>
<div class="field" id="subject-field" style="display:none">
<label for="r-subject">Lehrfach</label>
<select id="r-subject" style="padding:9px 12px;border:1px solid #e5e7eb;border-radius:7px;font-size:14px;font-family:inherit;color:#111827;background:#fff;outline:none;transition:border-color .12s">
<option value=""> Fach auswählen </option>
<option>Deutsch</option><option>Mathematik</option><option>Englisch</option>
<option>Informatik</option><option>Wirtschaft</option><option>Buchführung</option>
<option>BWL</option><option>VWL</option><option>Recht</option>
<option>Rechnungswesen</option><option>Sport</option><option>Religion</option>
<option>Geschichte</option><option>Gemeinschaftskunde</option><option>Physik</option>
<option>Chemie</option><option>Biologie</option><option>Sozialkunde</option>
<option>Ethik</option><option>Sonstiges</option>
</select>
</div>
<div class="field">
<label for="r-email">Schul E-Mail</label>
<input type="email" id="r-email" autocomplete="email" placeholder="Schul E-Mail-Adresse" required>
</div>
<div class="field">
<label for="r-pass">Passwort</label>
<input type="password" id="r-pass" autocomplete="new-password" placeholder="Mindestens 8 Zeichen" required oninput="checkStrength(this.value)">
<div class="strength-wrap">
<div class="strength-bar"><div class="strength-fill" id="strength-fill"></div></div>
<div class="strength-label" id="strength-label">Passwort eingeben</div>
</div>
</div>
<div class="notice notice-red" id="reg-err"></div>
<div class="notice notice-green" id="reg-ok">Registrierung erfolgreich! Wir haben dir eine Bestätigungs-Mail geschickt. Bitte prüfe dein Postfach und klicke auf den Link.</div>
<button class="btn-submit" type="submit" id="reg-btn">Account erstellen</button>
</form>
</div>
</div>
<footer>
<a href="/datenschutz">Datenschutzerklärung</a>
</footer>
<script>
let selectedRole = 'student';
function selectRole(r) {
selectedRole = r;
document.getElementById('role-student').classList.toggle('active', r === 'student');
document.getElementById('role-teacher').classList.toggle('active', r === 'teacher');
document.getElementById('role-teacher').classList.toggle('teacher', r === 'teacher');
document.getElementById('teacher-notice').classList.toggle('show', r === 'teacher');
document.getElementById('subject-field').style.display = r === 'teacher' ? '' : 'none';
document.getElementById('reg-btn').textContent = r === 'teacher' ? 'Als Lehrer/in registrieren' : 'Account erstellen';
}
function switchTab(t) {
document.getElementById('tab-login').classList.toggle('active', t==='login');
document.getElementById('tab-reg').classList.toggle('active', t==='register');
document.getElementById('form-login').classList.toggle('active', t==='login');
document.getElementById('form-reg').classList.toggle('active', t==='register');
}
function showErr(id, msg) {
const el = document.getElementById(id);
el.textContent = msg; el.classList.add('show');
}
function clearErr(id) { document.getElementById(id).classList.remove('show'); }
let totpPending = false;
function backToStep1() {
totpPending = false;
document.getElementById('login-step-1').style.display = '';
document.getElementById('login-step-2').style.display = 'none';
document.getElementById('login-btn').textContent = 'Anmelden';
clearErr('login-err');
}
async function doLogin(e) {
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);
if (d.needVerify) document.getElementById('login-resend').classList.add('show');
return;
}
if (d.requireTotp) {
totpPending = true;
document.getElementById('login-step-1').style.display = 'none';
document.getElementById('login-step-2').style.display = '';
document.getElementById('login-btn').textContent = 'Bestätigen';
document.getElementById('l-totp').value = '';
document.getElementById('l-totp').focus();
return;
}
window.location.href = '/app';
}
async function doRegister(e) {
e.preventDefault(); clearErr('reg-err');
const body = {
email: document.getElementById('r-email').value,
password: document.getElementById('r-pass').value,
role: selectedRole,
};
if (selectedRole === 'teacher') body.subject = document.getElementById('r-subject').value;
const r = await fetch('/api/register', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(body) });
const d = await r.json();
if (!r.ok) { showErr('reg-err', d.error); return; }
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 >= 8) score++;
if (pw.length >= 10) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
const levels = [
{ w: '0%', bg: '#e5e7eb', label: 'Passwort eingeben' },
{ w: '25%', bg: '#ef4444', label: 'Sehr schwach' },
{ w: '50%', bg: '#f97316', label: 'Schwach' },
{ w: '75%', bg: '#eab308', label: 'Mittel' },
{ w: '90%', bg: '#22c55e', label: 'Stark' },
{ w: '100%', bg: '#16a34a', label: 'Sehr stark' },
];
const l = levels[Math.min(score, 5)];
const fill = document.getElementById('strength-fill');
fill.style.width = l.w; fill.style.background = l.bg;
document.getElementById('strength-label').textContent = l.label;
}
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 = '/app'; });
lucide.createIcons();
</script>
</body>
</html>