This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/frontend/auth.py
Benjamin Admin bfdaf63ba9 fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

1458 lines
57 KiB
Python

from fastapi import APIRouter
from fastapi.responses import HTMLResponse
router = APIRouter()
# Shared CSS and JS for auth pages
AUTH_STYLES = """
<style>
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap');
:root {
--bp-primary: #0f766e;
--bp-primary-soft: #ccfbf1;
--bp-bg: #020617;
--bp-surface: #0f172a;
--bp-surface-elevated: rgba(15,23,42,0.95);
--bp-border: #1f2937;
--bp-border-subtle: rgba(148,163,184,0.25);
--bp-accent: #22c55e;
--bp-accent-soft: rgba(34,197,94,0.2);
--bp-text: #e5e7eb;
--bp-text-muted: #9ca3af;
--bp-danger: #ef4444;
--bp-warning: #f59e0b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Manrope', system-ui, -apple-system, sans-serif;
background: radial-gradient(circle at top left, #1f2937 0, #020617 50%, #000 100%);
color: var(--bp-text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-container {
width: 100%;
max-width: 440px;
}
.auth-card {
background: var(--bp-surface-elevated);
border: 1px solid var(--bp-border);
border-radius: 16px;
padding: 40px;
backdrop-filter: blur(20px);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.auth-logo {
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid var(--bp-accent);
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 20px;
color: var(--bp-accent);
margin-bottom: 16px;
}
.auth-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
}
.auth-subtitle {
font-size: 14px;
color: var(--bp-text-muted);
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--bp-text-muted);
margin-bottom: 8px;
}
.form-input {
width: 100%;
padding: 12px 16px;
font-size: 15px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--bp-border);
border-radius: 10px;
color: var(--bp-text);
transition: all 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--bp-accent);
box-shadow: 0 0 0 3px var(--bp-accent-soft);
}
.form-input::placeholder {
color: var(--bp-text-muted);
opacity: 0.6;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.btn-primary {
width: 100%;
background: linear-gradient(135deg, var(--bp-primary) 0%, #15803d 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: rgba(255,255,255,0.1);
color: var(--bp-text);
border: 1px solid var(--bp-border);
}
.btn-secondary:hover {
background: rgba(255,255,255,0.15);
}
.btn-danger {
background: var(--bp-danger);
color: white;
}
.auth-footer {
text-align: center;
margin-top: 24px;
font-size: 14px;
color: var(--bp-text-muted);
}
.auth-footer a {
color: var(--bp-accent);
text-decoration: none;
}
.auth-footer a:hover {
text-decoration: underline;
}
.alert {
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 20px;
font-size: 14px;
display: none;
}
.alert-error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid var(--bp-danger);
color: #fca5a5;
}
.alert-success {
background: rgba(34, 197, 94, 0.15);
border: 1px solid var(--bp-accent);
color: #86efac;
}
.alert-warning {
background: rgba(245, 158, 11, 0.15);
border: 1px solid var(--bp-warning);
color: #fcd34d;
}
.divider {
display: flex;
align-items: center;
margin: 24px 0;
color: var(--bp-text-muted);
font-size: 13px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--bp-border);
}
.divider span {
padding: 0 16px;
}
/* 2FA specific styles */
.totp-input-group {
display: flex;
gap: 8px;
justify-content: center;
margin: 24px 0;
}
.totp-input {
width: 48px;
height: 56px;
text-align: center;
font-size: 24px;
font-weight: 700;
background: rgba(255,255,255,0.05);
border: 2px solid var(--bp-border);
border-radius: 12px;
color: var(--bp-text);
}
.totp-input:focus {
outline: none;
border-color: var(--bp-accent);
}
.qr-container {
text-align: center;
margin: 24px 0;
}
.qr-container img {
width: 200px;
height: 200px;
border-radius: 12px;
background: white;
padding: 8px;
}
.secret-code {
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
font-size: 16px;
letter-spacing: 2px;
padding: 12px 16px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--bp-border);
border-radius: 8px;
margin: 16px 0;
text-align: center;
user-select: all;
}
.recovery-codes {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin: 16px 0;
}
.recovery-code {
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
font-size: 14px;
padding: 8px 12px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--bp-border);
border-radius: 6px;
text-align: center;
}
.step-indicator {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 24px;
}
.step {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
background: var(--bp-border);
color: var(--bp-text-muted);
}
.step.active {
background: var(--bp-accent);
color: white;
}
.step.completed {
background: var(--bp-primary);
color: white;
}
.authenticator-apps {
display: flex;
gap: 12px;
justify-content: center;
margin: 16px 0;
flex-wrap: wrap;
}
.app-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--bp-border);
border-radius: 8px;
font-size: 13px;
color: var(--bp-text-muted);
}
.app-badge svg {
width: 20px;
height: 20px;
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.hidden { display: none !important; }
.info-box {
padding: 16px;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 10px;
margin: 16px 0;
font-size: 13px;
}
.warning-box {
padding: 16px;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 10px;
margin: 16px 0;
font-size: 13px;
}
.tabs {
display: flex;
gap: 4px;
background: rgba(255,255,255,0.05);
padding: 4px;
border-radius: 10px;
margin-bottom: 24px;
}
.tab {
flex: 1;
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
text-align: center;
border: none;
background: transparent;
color: var(--bp-text-muted);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.tab.active {
background: var(--bp-accent);
color: white;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.status-badge.enabled {
background: rgba(34, 197, 94, 0.15);
color: #86efac;
}
.status-badge.disabled {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
}
</style>
"""
@router.get("/login", response_class=HTMLResponse)
def login_page():
return f"""
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Login - BreakPilot</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{AUTH_STYLES}
</head>
<body>
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<div class="auth-logo">BP</div>
<h1 class="auth-title">Willkommen zurück</h1>
<p class="auth-subtitle">Melden Sie sich an, um fortzufahren</p>
</div>
<div id="alert" class="alert"></div>
<!-- Login Form -->
<form id="loginForm" onsubmit="handleLogin(event)">
<div class="form-group">
<label class="form-label">E-Mail-Adresse</label>
<input type="email" id="email" class="form-input" placeholder="ihre@email.de" required>
</div>
<div class="form-group">
<label class="form-label">Passwort</label>
<input type="password" id="password" class="form-input" placeholder="Ihr Passwort" required>
</div>
<button type="submit" id="loginBtn" class="btn btn-primary">
<span id="loginText">Anmelden</span>
<span id="loginSpinner" class="loading-spinner hidden"></span>
</button>
</form>
<!-- 2FA Form (initially hidden) -->
<form id="twoFaForm" class="hidden" onsubmit="handle2FA(event)">
<div class="auth-header">
<h2 class="auth-title">Zwei-Faktor-Authentifizierung</h2>
<p class="auth-subtitle">Geben Sie den Code aus Ihrer Authenticator-App ein</p>
</div>
<div class="tabs">
<button type="button" class="tab active" onclick="switchTab('totp')">Authenticator</button>
<button type="button" class="tab" onclick="switchTab('recovery')">Recovery Code</button>
</div>
<div id="totpTab">
<div class="totp-input-group">
<input type="text" class="totp-input" maxlength="1" data-index="0" inputmode="numeric" pattern="[0-9]">
<input type="text" class="totp-input" maxlength="1" data-index="1" inputmode="numeric" pattern="[0-9]">
<input type="text" class="totp-input" maxlength="1" data-index="2" inputmode="numeric" pattern="[0-9]">
<input type="text" class="totp-input" maxlength="1" data-index="3" inputmode="numeric" pattern="[0-9]">
<input type="text" class="totp-input" maxlength="1" data-index="4" inputmode="numeric" pattern="[0-9]">
<input type="text" class="totp-input" maxlength="1" data-index="5" inputmode="numeric" pattern="[0-9]">
</div>
<input type="hidden" id="totpCode">
</div>
<div id="recoveryTab" class="hidden">
<div class="form-group">
<label class="form-label">Recovery Code</label>
<input type="text" id="recoveryCode" class="form-input" placeholder="XXXXXXXX" maxlength="8" style="text-transform: uppercase; letter-spacing: 2px; text-align: center;">
</div>
</div>
<input type="hidden" id="challengeId">
<button type="submit" id="verifyBtn" class="btn btn-primary">
<span id="verifyText">Verifizieren</span>
<span id="verifySpinner" class="loading-spinner hidden"></span>
</button>
<button type="button" class="btn btn-secondary" style="width: 100%; margin-top: 12px;" onclick="cancelLogin()">Abbrechen</button>
</form>
<div class="auth-footer" id="loginFooter">
Noch kein Konto? <a href="/register">Jetzt registrieren</a>
</div>
</div>
</div>
<script>
const API_BASE = '/api/consent';
let currentTab = 'totp';
// TOTP Input handling
document.querySelectorAll('.totp-input').forEach((input, index, inputs) => {{
input.addEventListener('input', (e) => {{
const value = e.target.value.replace(/[^0-9]/g, '');
e.target.value = value;
if (value && index < inputs.length - 1) {{
inputs[index + 1].focus();
}}
updateTotpCode();
}});
input.addEventListener('keydown', (e) => {{
if (e.key === 'Backspace' && !e.target.value && index > 0) {{
inputs[index - 1].focus();
}}
}});
input.addEventListener('paste', (e) => {{
e.preventDefault();
const pastedData = e.clipboardData.getData('text').replace(/[^0-9]/g, '').slice(0, 6);
pastedData.split('').forEach((char, i) => {{
if (inputs[i]) inputs[i].value = char;
}});
updateTotpCode();
if (pastedData.length === 6) inputs[5].focus();
}});
}});
function updateTotpCode() {{
const code = Array.from(document.querySelectorAll('.totp-input')).map(i => i.value).join('');
document.getElementById('totpCode').value = code;
}}
function switchTab(tab) {{
currentTab = tab;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('totpTab').classList.toggle('hidden', tab !== 'totp');
document.getElementById('recoveryTab').classList.toggle('hidden', tab !== 'recovery');
}}
function showAlert(message, type) {{
const alert = document.getElementById('alert');
alert.className = 'alert alert-' + type;
alert.textContent = message;
alert.style.display = 'block';
}}
function hideAlert() {{
document.getElementById('alert').style.display = 'none';
}}
async function handleLogin(e) {{
e.preventDefault();
hideAlert();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const btn = document.getElementById('loginBtn');
const text = document.getElementById('loginText');
const spinner = document.getElementById('loginSpinner');
btn.disabled = true;
text.textContent = 'Wird angemeldet...';
spinner.classList.remove('hidden');
try {{
const response = await fetch(API_BASE + '/auth/login', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ email, password }})
}});
const data = await response.json();
if (!response.ok) {{
throw new Error(data.error || 'Login fehlgeschlagen');
}}
if (data.requires_2fa) {{
// Show 2FA form
document.getElementById('loginForm').classList.add('hidden');
document.getElementById('twoFaForm').classList.remove('hidden');
document.getElementById('loginFooter').classList.add('hidden');
document.getElementById('challengeId').value = data.challenge_id;
document.querySelector('.totp-input').focus();
}} else {{
// Login successful without 2FA
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
localStorage.setItem('user', JSON.stringify(data.user));
showAlert('Erfolgreich angemeldet!', 'success');
setTimeout(() => window.location.href = '/app', 1000);
}}
}} catch (error) {{
showAlert(error.message, 'error');
}} finally {{
btn.disabled = false;
text.textContent = 'Anmelden';
spinner.classList.add('hidden');
}}
}}
async function handle2FA(e) {{
e.preventDefault();
hideAlert();
const challengeId = document.getElementById('challengeId').value;
const btn = document.getElementById('verifyBtn');
const text = document.getElementById('verifyText');
const spinner = document.getElementById('verifySpinner');
let body = {{ challenge_id: challengeId }};
if (currentTab === 'totp') {{
body.code = document.getElementById('totpCode').value;
if (body.code.length !== 6) {{
showAlert('Bitte geben Sie einen 6-stelligen Code ein', 'error');
return;
}}
}} else {{
body.recovery_code = document.getElementById('recoveryCode').value.toUpperCase();
if (body.recovery_code.length !== 8) {{
showAlert('Bitte geben Sie einen 8-stelligen Recovery Code ein', 'error');
return;
}}
}}
btn.disabled = true;
text.textContent = 'Wird verifiziert...';
spinner.classList.remove('hidden');
try {{
const response = await fetch(API_BASE + '/auth/2fa/verify', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(body)
}});
const data = await response.json();
if (!response.ok) {{
throw new Error(data.error || 'Verifizierung fehlgeschlagen');
}}
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
localStorage.setItem('user', JSON.stringify(data.user));
showAlert('Erfolgreich angemeldet!', 'success');
setTimeout(() => window.location.href = '/app', 1000);
}} catch (error) {{
showAlert(error.message, 'error');
// Clear TOTP inputs on error
document.querySelectorAll('.totp-input').forEach(i => i.value = '');
document.querySelector('.totp-input').focus();
}} finally {{
btn.disabled = false;
text.textContent = 'Verifizieren';
spinner.classList.add('hidden');
}}
}}
function cancelLogin() {{
document.getElementById('loginForm').classList.remove('hidden');
document.getElementById('twoFaForm').classList.add('hidden');
document.getElementById('loginFooter').classList.remove('hidden');
document.querySelectorAll('.totp-input').forEach(i => i.value = '');
document.getElementById('recoveryCode').value = '';
}}
</script>
</body>
</html>
"""
@router.get("/register", response_class=HTMLResponse)
def register_page():
return f"""
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Registrieren - BreakPilot</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{AUTH_STYLES}
</head>
<body>
<div class="auth-container">
<div class="auth-card">
<!-- Step indicator -->
<div class="step-indicator">
<div class="step active" id="step1">1</div>
<div class="step" id="step2">2</div>
<div class="step" id="step3">3</div>
</div>
<div id="alert" class="alert"></div>
<!-- Step 1: Account Details -->
<div id="stepAccount">
<div class="auth-header">
<div class="auth-logo">BP</div>
<h1 class="auth-title">Konto erstellen</h1>
<p class="auth-subtitle">Geben Sie Ihre Daten ein</p>
</div>
<form id="registerForm" onsubmit="handleRegister(event)">
<div class="form-group">
<label class="form-label">Name</label>
<input type="text" id="name" class="form-input" placeholder="Ihr Name" required>
</div>
<div class="form-group">
<label class="form-label">E-Mail-Adresse</label>
<input type="email" id="email" class="form-input" placeholder="ihre@email.de" required>
</div>
<div class="form-group">
<label class="form-label">Passwort</label>
<input type="password" id="password" class="form-input" placeholder="Mindestens 8 Zeichen" minlength="8" required>
</div>
<div class="form-group">
<label class="form-label">Passwort bestätigen</label>
<input type="password" id="passwordConfirm" class="form-input" placeholder="Passwort wiederholen" required>
</div>
<button type="submit" id="registerBtn" class="btn btn-primary">
<span id="registerText">Weiter</span>
<span id="registerSpinner" class="loading-spinner hidden"></span>
</button>
</form>
<div class="auth-footer">
Bereits ein Konto? <a href="/login">Anmelden</a>
</div>
</div>
<!-- Step 2: 2FA Setup with QR Code -->
<div id="step2FA" class="hidden">
<div class="auth-header">
<h1 class="auth-title">Zwei-Faktor-Authentifizierung</h1>
<p class="auth-subtitle">Scannen Sie den QR-Code mit Ihrer Authenticator-App</p>
</div>
<div class="authenticator-apps">
<div class="app-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
Google Authenticator
</div>
<div class="app-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 15.5c-3.58 0-6.5-2.92-6.5-6.5S8.42 5.5 12 5.5s6.5 2.92 6.5 6.5-2.92 6.5-6.5 6.5z"/><circle cx="12" cy="12" r="3"/></svg>
Microsoft Authenticator
</div>
<div class="app-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
Authy
</div>
</div>
<div class="qr-container">
<img id="qrCode" src="" alt="QR Code">
</div>
<div class="info-box">
<strong>Manuelle Eingabe:</strong><br>
Falls Sie den QR-Code nicht scannen können, geben Sie diesen Code manuell ein:
</div>
<div class="secret-code" id="secretCode">XXXX XXXX XXXX XXXX</div>
<form id="verifySetupForm" onsubmit="handleVerifySetup(event)">
<div class="form-group">
<label class="form-label">Geben Sie den 6-stelligen Code aus Ihrer App ein:</label>
<div class="totp-input-group">
<input type="text" class="totp-input" maxlength="1" data-index="0" inputmode="numeric">
<input type="text" class="totp-input" maxlength="1" data-index="1" inputmode="numeric">
<input type="text" class="totp-input" maxlength="1" data-index="2" inputmode="numeric">
<input type="text" class="totp-input" maxlength="1" data-index="3" inputmode="numeric">
<input type="text" class="totp-input" maxlength="1" data-index="4" inputmode="numeric">
<input type="text" class="totp-input" maxlength="1" data-index="5" inputmode="numeric">
</div>
<input type="hidden" id="setupTotpCode">
</div>
<button type="submit" id="verifySetupBtn" class="btn btn-primary">
<span id="verifySetupText">Code verifizieren</span>
<span id="verifySetupSpinner" class="loading-spinner hidden"></span>
</button>
</form>
</div>
<!-- Step 3: Recovery Codes -->
<div id="stepRecovery" class="hidden">
<div class="auth-header">
<h1 class="auth-title">Recovery Codes</h1>
<p class="auth-subtitle">Speichern Sie diese Codes sicher ab</p>
</div>
<div class="warning-box">
<strong>Wichtig:</strong> Diese Codes werden nur einmal angezeigt! Speichern Sie sie an einem sicheren Ort. Sie können diese Codes verwenden, falls Sie Ihr Gerät verlieren.
</div>
<div class="recovery-codes" id="recoveryCodes">
<!-- Codes werden hier eingefügt -->
</div>
<button type="button" class="btn btn-secondary" style="width: 100%; margin-bottom: 12px;" onclick="downloadCodes()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Codes herunterladen
</button>
<label style="display: flex; align-items: center; gap: 8px; margin: 16px 0; cursor: pointer;">
<input type="checkbox" id="confirmSaved" style="width: 18px; height: 18px;">
<span style="font-size: 14px;">Ich habe die Recovery Codes gespeichert</span>
</label>
<button type="button" id="finishBtn" class="btn btn-primary" disabled onclick="finishRegistration()">
Registrierung abschließen
</button>
</div>
</div>
</div>
<script>
const API_BASE = '/api/consent';
let userId = null;
let accessToken = null;
let recoveryCodes = [];
// TOTP Input handling
function setupTotpInputs() {{
document.querySelectorAll('.totp-input').forEach((input, index, inputs) => {{
input.addEventListener('input', (e) => {{
const value = e.target.value.replace(/[^0-9]/g, '');
e.target.value = value;
if (value && index < inputs.length - 1) {{
inputs[index + 1].focus();
}}
updateSetupTotpCode();
}});
input.addEventListener('keydown', (e) => {{
if (e.key === 'Backspace' && !e.target.value && index > 0) {{
inputs[index - 1].focus();
}}
}});
input.addEventListener('paste', (e) => {{
e.preventDefault();
const pastedData = e.clipboardData.getData('text').replace(/[^0-9]/g, '').slice(0, 6);
pastedData.split('').forEach((char, i) => {{
if (inputs[i]) inputs[i].value = char;
}});
updateSetupTotpCode();
}});
}});
}}
function updateSetupTotpCode() {{
const code = Array.from(document.querySelectorAll('#step2FA .totp-input')).map(i => i.value).join('');
document.getElementById('setupTotpCode').value = code;
}}
setupTotpInputs();
document.getElementById('confirmSaved').addEventListener('change', (e) => {{
document.getElementById('finishBtn').disabled = !e.target.checked;
}});
function showAlert(message, type) {{
const alert = document.getElementById('alert');
alert.className = 'alert alert-' + type;
alert.textContent = message;
alert.style.display = 'block';
}}
function hideAlert() {{
document.getElementById('alert').style.display = 'none';
}}
function goToStep(step) {{
document.getElementById('step1').classList.toggle('active', step === 1);
document.getElementById('step1').classList.toggle('completed', step > 1);
document.getElementById('step2').classList.toggle('active', step === 2);
document.getElementById('step2').classList.toggle('completed', step > 2);
document.getElementById('step3').classList.toggle('active', step === 3);
document.getElementById('stepAccount').classList.toggle('hidden', step !== 1);
document.getElementById('step2FA').classList.toggle('hidden', step !== 2);
document.getElementById('stepRecovery').classList.toggle('hidden', step !== 3);
}}
async function handleRegister(e) {{
e.preventDefault();
hideAlert();
const name = document.getElementById('name').value;
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const passwordConfirm = document.getElementById('passwordConfirm').value;
if (password !== passwordConfirm) {{
showAlert('Passwörter stimmen nicht überein', 'error');
return;
}}
if (password.length < 8) {{
showAlert('Passwort muss mindestens 8 Zeichen lang sein', 'error');
return;
}}
const btn = document.getElementById('registerBtn');
const text = document.getElementById('registerText');
const spinner = document.getElementById('registerSpinner');
btn.disabled = true;
text.textContent = 'Wird registriert...';
spinner.classList.remove('hidden');
try {{
const response = await fetch(API_BASE + '/auth/register', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ name, email, password }})
}});
const data = await response.json();
if (!response.ok) {{
throw new Error(data.error || 'Registrierung fehlgeschlagen');
}}
userId = data.user_id;
if (data.two_factor_setup) {{
// Show QR code and secret
document.getElementById('qrCode').src = data.two_factor_setup.qr_code;
document.getElementById('secretCode').textContent = formatSecret(data.two_factor_setup.secret);
recoveryCodes = data.two_factor_setup.recovery_codes;
// Now login to get access token for 2FA verification
const loginResp = await fetch(API_BASE + '/auth/login', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ email, password }})
}});
const loginData = await loginResp.json();
if (loginData.requires_2fa) {{
// Expected - 2FA is set up but not verified
// We need to complete setup first
}} else if (loginData.access_token) {{
accessToken = loginData.access_token;
}}
goToStep(2);
document.querySelector('#step2FA .totp-input').focus();
}} else {{
showAlert('Registrierung erfolgreich! Sie werden weitergeleitet...', 'success');
setTimeout(() => window.location.href = '/login', 2000);
}}
}} catch (error) {{
showAlert(error.message, 'error');
}} finally {{
btn.disabled = false;
text.textContent = 'Weiter';
spinner.classList.add('hidden');
}}
}}
function formatSecret(secret) {{
return secret.match(/.{{1,4}}/g).join(' ');
}}
async function handleVerifySetup(e) {{
e.preventDefault();
hideAlert();
const code = document.getElementById('setupTotpCode').value;
if (code.length !== 6) {{
showAlert('Bitte geben Sie einen 6-stelligen Code ein', 'error');
return;
}}
const btn = document.getElementById('verifySetupBtn');
const text = document.getElementById('verifySetupText');
const spinner = document.getElementById('verifySetupSpinner');
btn.disabled = true;
text.textContent = 'Wird verifiziert...';
spinner.classList.remove('hidden');
try {{
// First login with email/password
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const loginResp = await fetch(API_BASE + '/auth/login', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ email, password }})
}});
const loginData = await loginResp.json();
if (loginData.requires_2fa) {{
// Now verify the 2FA challenge
const verifyResp = await fetch(API_BASE + '/auth/2fa/verify', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{
challenge_id: loginData.challenge_id,
code: code
}})
}});
const verifyData = await verifyResp.json();
if (!verifyResp.ok) {{
throw new Error(verifyData.error || 'Verifizierung fehlgeschlagen');
}}
accessToken = verifyData.access_token;
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', verifyData.refresh_token);
localStorage.setItem('user', JSON.stringify(verifyData.user));
}}
// Show recovery codes
const codesContainer = document.getElementById('recoveryCodes');
codesContainer.innerHTML = recoveryCodes.map(code =>
`<div class="recovery-code">${{code}}</div>`
).join('');
goToStep(3);
}} catch (error) {{
showAlert(error.message, 'error');
document.querySelectorAll('#step2FA .totp-input').forEach(i => i.value = '');
document.querySelector('#step2FA .totp-input').focus();
}} finally {{
btn.disabled = false;
text.textContent = 'Code verifizieren';
spinner.classList.add('hidden');
}}
}}
function downloadCodes() {{
const text = 'BreakPilot Recovery Codes\\n' +
'========================\\n\\n' +
'Generiert am: ' + new Date().toLocaleString('de-DE') + '\\n\\n' +
recoveryCodes.map((code, i) => `${{i + 1}}. ${{code}}`).join('\\n') +
'\\n\\nBewahren Sie diese Codes sicher auf!';
const blob = new Blob([text], {{ type: 'text/plain' }});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'breakpilot-recovery-codes.txt';
a.click();
URL.revokeObjectURL(url);
}}
function finishRegistration() {{
showAlert('Registrierung abgeschlossen!', 'success');
setTimeout(() => window.location.href = '/app', 1500);
}}
</script>
</body>
</html>
"""
@router.get("/account/security", response_class=HTMLResponse)
def security_settings_page():
return f"""
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Sicherheitseinstellungen - BreakPilot</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{AUTH_STYLES}
<style>
.auth-container {{ max-width: 560px; }}
.section {{
background: rgba(255,255,255,0.03);
border: 1px solid var(--bp-border);
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
}}
.section-header {{
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}}
.section-title {{
font-size: 16px;
font-weight: 600;
}}
.section-description {{
font-size: 14px;
color: var(--bp-text-muted);
margin-bottom: 16px;
}}
.back-link {{
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--bp-text-muted);
text-decoration: none;
font-size: 14px;
margin-bottom: 24px;
}}
.back-link:hover {{ color: var(--bp-text); }}
</style>
</head>
<body>
<div class="auth-container">
<a href="/app" class="back-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Zurück zur App
</a>
<div class="auth-card">
<div class="auth-header">
<h1 class="auth-title">Sicherheitseinstellungen</h1>
<p class="auth-subtitle">Verwalten Sie Ihre Zwei-Faktor-Authentifizierung</p>
</div>
<div id="alert" class="alert"></div>
<!-- 2FA Status Section -->
<div class="section">
<div class="section-header">
<span class="section-title">Zwei-Faktor-Authentifizierung</span>
<span class="status-badge" id="statusBadge">Laden...</span>
</div>
<p class="section-description">
Schützen Sie Ihr Konto mit einem zusätzlichen Sicherheitsfaktor. Nutzen Sie Google Authenticator, Microsoft Authenticator oder eine andere TOTP-kompatible App.
</p>
<div id="2faEnabled" class="hidden">
<p style="font-size: 14px; margin-bottom: 16px;">
<strong>Aktiviert seit:</strong> <span id="enabledAt">-</span><br>
<strong>Verbleibende Recovery Codes:</strong> <span id="recoveryCount">-</span>
</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="btn btn-secondary" onclick="showRegenerateCodes()">Neue Recovery Codes</button>
<button class="btn btn-danger" onclick="showDisable2FA()">2FA deaktivieren</button>
</div>
</div>
<div id="2faDisabled" class="hidden">
<button class="btn btn-primary" onclick="showSetup2FA()">2FA aktivieren</button>
</div>
</div>
<!-- 2FA Setup Modal -->
<div id="setupModal" class="hidden">
<div class="section">
<h3 style="margin-bottom: 16px;">2FA einrichten</h3>
<div class="authenticator-apps">
<div class="app-badge">Google Authenticator</div>
<div class="app-badge">Microsoft Authenticator</div>
<div class="app-badge">Authy</div>
</div>
<div class="qr-container">
<img id="qrCode" src="" alt="QR Code">
</div>
<div class="secret-code" id="secretCode">Wird geladen...</div>
<form onsubmit="handleSetupVerify(event)">
<div class="form-group">
<label class="form-label">Bestätigungscode eingeben:</label>
<input type="text" id="setupCode" class="form-input" placeholder="000000" maxlength="6" style="text-align: center; letter-spacing: 8px; font-size: 20px;">
</div>
<button type="submit" class="btn btn-primary">Aktivieren</button>
<button type="button" class="btn btn-secondary" style="margin-top: 12px; width: 100%;" onclick="hideSetup()">Abbrechen</button>
</form>
</div>
</div>
<!-- Regenerate Recovery Codes Modal -->
<div id="regenerateModal" class="hidden">
<div class="section">
<h3 style="margin-bottom: 16px;">Neue Recovery Codes generieren</h3>
<div class="warning-box">
Ihre alten Recovery Codes werden ungültig!
</div>
<form onsubmit="handleRegenerate(event)">
<div class="form-group">
<label class="form-label">Aktuellen 2FA-Code eingeben:</label>
<input type="text" id="regenerateCode" class="form-input" placeholder="000000" maxlength="6" style="text-align: center;">
</div>
<button type="submit" class="btn btn-primary">Codes generieren</button>
<button type="button" class="btn btn-secondary" style="margin-top: 12px; width: 100%;" onclick="hideRegenerate()">Abbrechen</button>
</form>
</div>
</div>
<!-- Show Recovery Codes Modal -->
<div id="showCodesModal" class="hidden">
<div class="section">
<h3 style="margin-bottom: 16px;">Neue Recovery Codes</h3>
<div class="warning-box">
Speichern Sie diese Codes jetzt! Sie werden nicht erneut angezeigt.
</div>
<div class="recovery-codes" id="newRecoveryCodes"></div>
<button class="btn btn-secondary" style="width: 100%; margin-bottom: 12px;" onclick="downloadNewCodes()">Codes herunterladen</button>
<button class="btn btn-primary" onclick="hideShowCodes()">Fertig</button>
</div>
</div>
<!-- Disable 2FA Modal -->
<div id="disableModal" class="hidden">
<div class="section">
<h3 style="margin-bottom: 16px;">2FA deaktivieren</h3>
<div class="warning-box">
Warnung: Ihr Konto ist ohne 2FA weniger sicher!
</div>
<form onsubmit="handleDisable(event)">
<div class="form-group">
<label class="form-label">Aktuellen 2FA-Code eingeben:</label>
<input type="text" id="disableCode" class="form-input" placeholder="000000" maxlength="6" style="text-align: center;">
</div>
<button type="submit" class="btn btn-danger">2FA deaktivieren</button>
<button type="button" class="btn btn-secondary" style="margin-top: 12px; width: 100%;" onclick="hideDisable()">Abbrechen</button>
</form>
</div>
</div>
</div>
</div>
<script>
const API_BASE = '/api/consent';
let newCodes = [];
function getAuthHeader() {{
const token = localStorage.getItem('access_token');
return token ? {{ 'Authorization': 'Bearer ' + token }} : {{}};
}}
function showAlert(message, type) {{
const alert = document.getElementById('alert');
alert.className = 'alert alert-' + type;
alert.textContent = message;
alert.style.display = 'block';
}}
async function loadStatus() {{
try {{
const response = await fetch(API_BASE + '/auth/2fa/status', {{
headers: {{ ...getAuthHeader(), 'Content-Type': 'application/json' }}
}});
if (response.status === 401) {{
window.location.href = '/login';
return;
}}
const data = await response.json();
const badge = document.getElementById('statusBadge');
if (data.enabled) {{
badge.className = 'status-badge enabled';
badge.textContent = 'Aktiviert';
document.getElementById('2faEnabled').classList.remove('hidden');
document.getElementById('2faDisabled').classList.add('hidden');
document.getElementById('enabledAt').textContent = new Date(data.enabled_at).toLocaleDateString('de-DE');
document.getElementById('recoveryCount').textContent = data.recovery_codes_count;
}} else {{
badge.className = 'status-badge disabled';
badge.textContent = 'Nicht aktiviert';
document.getElementById('2faEnabled').classList.add('hidden');
document.getElementById('2faDisabled').classList.remove('hidden');
}}
}} catch (error) {{
showAlert('Fehler beim Laden des Status', 'error');
}}
}}
function hideAllModals() {{
document.getElementById('setupModal').classList.add('hidden');
document.getElementById('regenerateModal').classList.add('hidden');
document.getElementById('showCodesModal').classList.add('hidden');
document.getElementById('disableModal').classList.add('hidden');
}}
async function showSetup2FA() {{
hideAllModals();
try {{
const response = await fetch(API_BASE + '/auth/2fa/setup', {{
method: 'POST',
headers: {{ ...getAuthHeader(), 'Content-Type': 'application/json' }}
}});
const data = await response.json();
if (!response.ok) {{
throw new Error(data.error || 'Setup fehlgeschlagen');
}}
document.getElementById('qrCode').src = data.qr_code;
document.getElementById('secretCode').textContent = data.secret.match(/.{{1,4}}/g).join(' ');
newCodes = data.recovery_codes;
document.getElementById('setupModal').classList.remove('hidden');
}} catch (error) {{
showAlert(error.message, 'error');
}}
}}
function hideSetup() {{ hideAllModals(); }}
async function handleSetupVerify(e) {{
e.preventDefault();
const code = document.getElementById('setupCode').value;
try {{
const response = await fetch(API_BASE + '/auth/2fa/verify-setup', {{
method: 'POST',
headers: {{ ...getAuthHeader(), 'Content-Type': 'application/json' }},
body: JSON.stringify({{ code }})
}});
const data = await response.json();
if (!response.ok) {{
throw new Error(data.error || 'Verifizierung fehlgeschlagen');
}}
hideAllModals();
document.getElementById('newRecoveryCodes').innerHTML = newCodes.map(c => `<div class="recovery-code">${{c}}</div>`).join('');
document.getElementById('showCodesModal').classList.remove('hidden');
}} catch (error) {{
showAlert(error.message, 'error');
}}
}}
function showRegenerateCodes() {{
hideAllModals();
document.getElementById('regenerateModal').classList.remove('hidden');
}}
function hideRegenerate() {{ hideAllModals(); }}
async function handleRegenerate(e) {{
e.preventDefault();
const code = document.getElementById('regenerateCode').value;
try {{
const response = await fetch(API_BASE + '/auth/2fa/recovery-codes', {{
method: 'POST',
headers: {{ ...getAuthHeader(), 'Content-Type': 'application/json' }},
body: JSON.stringify({{ code }})
}});
const data = await response.json();
if (!response.ok) {{
throw new Error(data.error || 'Generierung fehlgeschlagen');
}}
newCodes = data.recovery_codes;
hideAllModals();
document.getElementById('newRecoveryCodes').innerHTML = newCodes.map(c => `<div class="recovery-code">${{c}}</div>`).join('');
document.getElementById('showCodesModal').classList.remove('hidden');
}} catch (error) {{
showAlert(error.message, 'error');
}}
}}
function hideShowCodes() {{
hideAllModals();
loadStatus();
}}
function downloadNewCodes() {{
const text = 'BreakPilot Recovery Codes\\n========================\\n\\n' +
'Generiert am: ' + new Date().toLocaleString('de-DE') + '\\n\\n' +
newCodes.map((c, i) => `${{i + 1}}. ${{c}}`).join('\\n');
const blob = new Blob([text], {{ type: 'text/plain' }});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'breakpilot-recovery-codes.txt';
a.click();
URL.revokeObjectURL(url);
}}
function showDisable2FA() {{
hideAllModals();
document.getElementById('disableModal').classList.remove('hidden');
}}
function hideDisable() {{ hideAllModals(); }}
async function handleDisable(e) {{
e.preventDefault();
const code = document.getElementById('disableCode').value;
try {{
const response = await fetch(API_BASE + '/auth/2fa/disable', {{
method: 'POST',
headers: {{ ...getAuthHeader(), 'Content-Type': 'application/json' }},
body: JSON.stringify({{ code }})
}});
const data = await response.json();
if (!response.ok) {{
throw new Error(data.error || 'Deaktivierung fehlgeschlagen');
}}
hideAllModals();
showAlert('2FA wurde deaktiviert', 'success');
loadStatus();
}} catch (error) {{
showAlert(error.message, 'error');
}}
}}
// Check auth and load status
if (!localStorage.getItem('access_token')) {{
window.location.href = '/login';
}} else {{
loadStatus();
}}
</script>
</body>
</html>
"""