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>
1458 lines
57 KiB
Python
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>
|
|
"""
|