feat(admin): implement full admin panel with user management, role switcher and platform settings

- admin.html: Executive Dashboard mit KPI-Metriken (User, Rollen, gelöste Aufgaben, DB-Größe)
- Userverwaltung: Interaktive Tabelle mit Sofortsuche, Rollen-Filter, Rollenwechsel (Admin/Lehrer/Schüler/Solo), Passwort-Reset & Benutzer löschen/anlegen
- Plattform-Einstellungen: Globales Announcement-Banner, Registrierungs-Toggle & Wartungsmodus
- Backend-API (server.py): Geschützte Admin-Endpunkte (/api/admin/stats, /api/admin/users, /api/admin/settings etc.) mit rollenbasierter Autorisierung (RBAC)
- CLI-Tool make_admin.py zur Vergabe von Administratorrechten im Terminal
This commit is contained in:
Alex Mueller 2026-09-12 14:00:53 +02:00
parent 24e78444cf
commit e49c685f25
5 changed files with 960 additions and 1 deletions

762
admin.html Normal file
View file

@ -0,0 +1,762 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel | IT-Praxisportal</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="assets/style.css">
<style>
body {
background: #090d16;
color: #f8fafc;
font-family: var(--font-sans);
margin: 0;
padding: 0;
min-height: 100vh;
}
.admin-container {
max-width: 1280px;
margin: 0 auto;
padding: 24px 20px 80px 20px;
}
.admin-header {
background: #0d1322;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(8px);
}
.admin-brand {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
color: white;
}
.admin-brand-logo {
width: 36px;
height: 36px;
border-radius: 8px;
background: rgba(56, 189, 248, 0.15);
border: 1px solid rgba(56, 189, 248, 0.4);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
}
.stat-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin: 24px 0;
}
.admin-stat-card {
background: #0d1322;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.admin-stat-val {
font-size: 1.8rem;
font-weight: 800;
color: #38bdf8;
line-height: 1.2;
}
.admin-stat-title {
font-size: 0.85rem;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.admin-stat-desc {
font-size: 0.78rem;
color: #64748b;
}
.admin-tabs {
display: flex;
gap: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 24px;
overflow-x: auto;
}
.admin-tab-btn {
background: transparent;
border: none;
color: #94a3b8;
padding: 12px 18px;
font-size: 0.9rem;
font-weight: 700;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s ease;
white-space: nowrap;
}
.admin-tab-btn:hover {
color: #f8fafc;
}
.admin-tab-btn.active {
color: #38bdf8;
border-bottom-color: #38bdf8;
}
.admin-panel-card {
background: #0d1322;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.86rem;
margin-top: 16px;
}
.admin-table th {
text-align: left;
padding: 10px 14px;
color: #64748b;
font-weight: 800;
text-transform: uppercase;
font-size: 0.72rem;
letter-spacing: 0.6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.admin-table td {
padding: 12px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
color: #cbd5e1;
vertical-align: middle;
}
.admin-table tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
.role-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 6px;
font-size: 0.74rem;
font-weight: 800;
text-transform: uppercase;
}
.role-admin { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
.role-teacher { background: rgba(168, 85, 247, 0.15); color: #c084fc; border: 1px solid rgba(168, 85, 247, 0.3); }
.role-student { background: rgba(56, 189, 248, 0.15); color: #38bdf8; border: 1px solid rgba(56, 189, 248, 0.3); }
.role-solo { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
.admin-btn {
padding: 6px 12px;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 700;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.05);
color: #f8fafc;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 4px;
}
.admin-btn:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.3);
}
.admin-btn-primary {
background: #0284c7;
border-color: #0284c7;
color: white;
}
.admin-btn-primary:hover { background: #0369a1; }
.admin-btn-danger {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.4);
color: #f87171;
}
.admin-btn-danger:hover { background: rgba(239, 68, 68, 0.3); }
.modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(6px);
z-index: 9999;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-box {
background: #0d1322;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 14px;
max-width: 480px;
width: 100%;
padding: 24px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.8);
}
</style>
</head>
<body>
<!-- ADMIN TOPBAR -->
<header class="admin-header">
<a href="index.html" class="admin-brand">
<div class="admin-brand-logo">🛡️</div>
<div>
<div style="font-weight: 800; font-size: 1rem; color: #f8fafc;">IT-Praxisportal</div>
<div style="font-size: 0.7rem; color: #38bdf8; font-weight: 700; letter-spacing: 0.5px;">ADMIN CONTROL PANEL</div>
</div>
</a>
<div style="display: flex; align-items: center; gap: 12px;">
<a href="index.html" class="admin-btn">🏠 Startseite</a>
<a href="teacher.html" class="admin-btn">👨‍🏫 Dozenten-Dashboard</a>
<span id="admin-user-display" style="font-size: 0.82rem; font-weight: 700; color: #f87171; background: rgba(239, 68, 68, 0.1); padding: 5px 12px; border-radius: 99px; border: 1px solid rgba(239, 68, 68, 0.25);">🛡️ Admin</span>
<button type="button" class="admin-btn admin-btn-danger" onclick="window.AUTH.logout()">Abmelden</button>
</div>
</header>
<!-- ACCESS CHECK BARRIER -->
<div id="admin-auth-guard" style="display: none; max-width: 500px; margin: 80px auto; text-align: center; background: #0d1322; border: 1px solid rgba(239,68,68,0.3); border-radius: 16px; padding: 36px 24px;">
<div style="font-size: 3rem; margin-bottom: 12px;">⛔</div>
<h2 style="margin: 0 0 10px 0; color: #f87171; font-size: 1.4rem;">Zugriff verweigert</h2>
<p style="color: #94a3b8; font-size: 0.92rem; margin-bottom: 24px; line-height: 1.5;">
Dieses Control Panel ist ausschließlich für System-Administratoren freigegeben. Bitte melde dich mit einem Administrator-Konto an.
</p>
<div style="display: flex; gap: 10px; justify-content: center;">
<a href="index.html" class="admin-btn">Zur Startseite</a>
<button type="button" class="admin-btn admin-btn-primary" onclick="window.openAuthModal('login', { allowClose: true })">Als Admin anmelden</button>
</div>
</div>
<!-- MAIN ADMIN CONTENT -->
<div class="admin-container" id="admin-main-content">
<!-- KPI STATS STRIP -->
<div class="stat-card-grid">
<div class="admin-stat-card">
<div class="admin-stat-title">👥 Gesamte Benutzer</div>
<div class="admin-stat-val" id="stat-total-users">-</div>
<div class="admin-stat-desc" id="stat-recent-users">- Neuregistrierungen (7 Tage)</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-title">🎓 Rollen-Verteilung</div>
<div class="admin-stat-val" style="font-size: 1.1rem; color: #f8fafc;" id="stat-roles-breakdown">-</div>
<div class="admin-stat-desc">Admins • Dozenten • Schüler • Solos</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-title">🚀 Gelöste Module</div>
<div class="admin-stat-val" style="color: #10b981;" id="stat-total-solved">-</div>
<div class="admin-stat-desc">Absolvierte Lerneinheiten &amp; Tests</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-title">💾 SQLite Datenbank</div>
<div class="admin-stat-val" style="color: #c084fc;" id="stat-db-size">-</div>
<div class="admin-stat-desc">platform_data.db Speichergröße</div>
</div>
</div>
<!-- TABS -->
<div class="admin-tabs">
<button type="button" class="admin-tab-btn active" onclick="switchAdminTab('users')">👥 Benutzerverwaltung</button>
<button type="button" class="admin-tab-btn" onclick="switchAdminTab('settings')">⚙️ Plattform-Einstellungen</button>
</div>
<!-- TAB 1: BENUTZERVERWALTUNG -->
<div id="tab-content-users" class="admin-panel-card">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 14px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; gap: 12px; flex: 1; max-width: 600px;">
<input type="text" id="admin-search-input" placeholder="🔍 Benutzer nach Name, E-Mail oder ID filtern..." style="flex: 1; background: #080c14; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; padding: 9px 14px; color: white; font-size: 0.88rem; outline: none;">
<select id="admin-filter-role" style="background: #080c14; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; padding: 9px 12px; color: white; font-size: 0.84rem; outline: none; cursor: pointer;">
<option value="all">Alle Rollen</option>
<option value="admin">Nur Admins</option>
<option value="teacher">Nur Dozenten</option>
<option value="student">Nur Schüler</option>
<option value="solo">Nur Solo-Lerner</option>
</select>
</div>
<button type="button" class="admin-btn admin-btn-primary" onclick="openCreateUserModal()">
<span>➕ Neuen Benutzer anlegen</span>
</button>
</div>
<div style="overflow-x: auto;">
<table class="admin-table">
<thead>
<tr>
<th style="width: 50px;">ID</th>
<th>Benutzer</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>XP / Level</th>
<th>Gelöst</th>
<th>Registriert</th>
<th style="text-align: right;">Aktionen</th>
</tr>
</thead>
<tbody id="admin-users-tbody">
<tr><td colspan="8" style="text-align: center; color: #64748b; padding: 30px;">Lade Benutzerdaten...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- TAB 2: EINSTELLUNGEN -->
<div id="tab-content-settings" class="admin-panel-card" style="display: none;">
<h3 style="margin: 0 0 16px 0; font-size: 1.2rem; color: #f8fafc;">⚙️ Globale Plattform-Konfiguration</h3>
<p style="color: #94a3b8; font-size: 0.88rem; margin-bottom: 24px;">Steuere Registrierungen, globale System-Hinweise und Server-Verhalten.</p>
<form id="admin-settings-form" style="display: flex; flex-direction: column; gap: 20px; max-width: 600px;">
<div>
<label style="display: block; font-size: 0.85rem; font-weight: 700; color: #cbd5e1; margin-bottom: 6px;">📢 Globales Announcement Banner (Startseite)</label>
<input type="text" id="setting-announcement" placeholder="z.B. Herzlich willkommen im Alpha-Testlauf des IT-Praxisportals!" style="width: 100%; background: #080c14; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; padding: 10px 14px; color: white; font-size: 0.9rem;">
<span style="font-size: 0.75rem; color: #64748b;">Freilassen, wenn kein Banner angezeigt werden soll.</span>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="setting-allow-reg" checked style="width: 18px; height: 18px; cursor: pointer;">
<label for="setting-allow-reg" style="font-size: 0.9rem; font-weight: 700; color: #cbd5e1; cursor: pointer;">
Öffentliche Neuregistrierungen zulassen
</label>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="setting-maintenance" style="width: 18px; height: 18px; cursor: pointer;">
<label for="setting-maintenance" style="font-size: 0.9rem; font-weight: 700; color: #f87171; cursor: pointer;">
Wartungsmodus aktivieren (Nur Admins haben Zugriff)
</label>
</div>
<button type="submit" class="admin-btn admin-btn-primary" style="align-self: flex-start; padding: 10px 20px; font-size: 0.9rem; margin-top: 10px;">
Einstellungen speichern
</button>
</form>
</div>
</div>
<!-- MODAL: BENUTZER ANLEGEN -->
<div class="modal-backdrop" id="modal-create-user">
<div class="modal-box">
<h3 style="margin: 0 0 16px 0; font-size: 1.2rem; color: #f8fafc;">➕ Neuen Benutzer anlegen</h3>
<form id="form-create-user" style="display: flex; flex-direction: column; gap: 14px;">
<div>
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: #94a3b8; margin-bottom: 4px;">Vollständiger Name:</label>
<input type="text" id="create-name" required style="width: 100%; background: #080c14; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; padding: 8px 12px; color: white;">
</div>
<div>
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: #94a3b8; margin-bottom: 4px;">E-Mail-Adresse:</label>
<input type="email" id="create-email" required style="width: 100%; background: #080c14; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; padding: 8px 12px; color: white;">
</div>
<div>
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: #94a3b8; margin-bottom: 4px;">Initiales Passwort:</label>
<input type="password" id="create-password" required minlength="6" style="width: 100%; background: #080c14; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; padding: 8px 12px; color: white;" placeholder="Mindestens 6 Zeichen">
</div>
<div>
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: #94a3b8; margin-bottom: 4px;">Systemrolle:</label>
<select id="create-role" style="width: 100%; background: #080c14; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; padding: 8px 12px; color: white;">
<option value="student">Schüler (Klassen)</option>
<option value="solo">Solo-Selbstlerner</option>
<option value="teacher">Lehrkraft / Dozent</option>
<option value="admin">Administrator (Vollzugriff)</option>
</select>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 10px;">
<button type="button" class="admin-btn" onclick="closeModal('modal-create-user')">Abbrechen</button>
<button type="submit" class="admin-btn admin-btn-primary">Benutzer erstellen</button>
</div>
</form>
</div>
</div>
<!-- MODAL: PASSWORT ZURÜCKSETZEN -->
<div class="modal-backdrop" id="modal-reset-password">
<div class="modal-box">
<h3 style="margin: 0 0 10px 0; font-size: 1.2rem; color: #f8fafc;">🔑 Passwort zurücksetzen</h3>
<p style="font-size: 0.86rem; color: #94a3b8; margin-bottom: 16px;">
Neues Passwort für <strong id="reset-user-name" style="color: #38bdf8;">-</strong> vergeben:
</p>
<form id="form-reset-password" style="display: flex; flex-direction: column; gap: 14px;">
<input type="hidden" id="reset-user-id">
<div>
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: #94a3b8; margin-bottom: 4px;">Neues Passwort:</label>
<input type="password" id="reset-new-password" required minlength="6" placeholder="Mindestens 6 Zeichen..." style="width: 100%; background: #080c14; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; padding: 8px 12px; color: white;">
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 8px;">
<button type="button" class="admin-btn" onclick="closeModal('modal-reset-password')">Abbrechen</button>
<button type="submit" class="admin-btn admin-btn-primary">Passwort speichern</button>
</div>
</form>
</div>
</div>
<script src="assets/app_auth.js"></script>
<script>
let ALL_USERS = [];
function switchAdminTab(tabId) {
document.querySelectorAll('.admin-tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.admin-panel-card').forEach(p => p.style.display = 'none');
const activeBtn = event.target;
const content = document.getElementById('tab-content-' + tabId);
if (activeBtn) activeBtn.classList.add('active');
if (content) content.style.display = 'block';
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
function openCreateUserModal() {
document.getElementById('modal-create-user').style.display = 'flex';
}
async function loadAdminData() {
const token = localStorage.getItem('auth_token');
if (!token) {
showAuthGuard();
return;
}
// Check current user role
try {
const meRes = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${token}` }
});
const meData = await meRes.json();
if (!meRes.ok || !meData.user || meData.user.role !== 'admin') {
showAuthGuard();
return;
}
document.getElementById('admin-user-display').innerText = `🛡️ ${meData.user.name} (Admin)`;
document.getElementById('admin-auth-guard').style.display = 'none';
document.getElementById('admin-main-content').style.display = 'block';
} catch (err) {
showAuthGuard();
return;
}
// Load Stats
loadStats(token);
// Load Users
loadUsers(token);
// Load Settings
loadSettings(token);
}
function showAuthGuard() {
document.getElementById('admin-auth-guard').style.display = 'block';
document.getElementById('admin-main-content').style.display = 'none';
}
async function loadStats(token) {
try {
const res = await fetch('/api/admin/stats', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
document.getElementById('stat-total-users').innerText = data.total_users;
document.getElementById('stat-recent-users').innerText = `+${data.recent_users} neu in den letzten 7 Tagen`;
document.getElementById('stat-total-solved').innerText = `${data.total_solved} Aufgaben`;
document.getElementById('stat-db-size').innerText = `${data.db_size_kb} KB`;
const r = data.roles || {};
document.getElementById('stat-roles-breakdown').innerHTML = `
<span style="color:#f87171;">${r.admin || 0} Admins</span> &bull;
<span style="color:#c084fc;">${r.teacher || 0} Lehrer</span> &bull;
<span style="color:#38bdf8;">${r.student || 0} Schüler</span> &bull;
<span style="color:#34d399;">${r.solo || 0} Solo</span>
`;
}
} catch (e) {
console.error("Konnte Admin-Stats nicht laden:", e);
}
}
async function loadUsers(token) {
try {
const res = await fetch('/api/admin/users', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
ALL_USERS = data.users || [];
renderUsersTable(ALL_USERS);
}
} catch (e) {
console.error("Konnte Userliste nicht laden:", e);
}
}
function renderUsersTable(users) {
const tbody = document.getElementById('admin-users-tbody');
if (!users.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; color: #64748b; padding: 24px;">Keine Benutzer gefunden.</td></tr>';
return;
}
tbody.innerHTML = users.map(u => {
const roleClass = `role-${u.role || 'student'}`;
const createdDate = (u.created_at || '').substring(0, 10);
return `
<tr>
<td style="font-family: var(--font-mono); color: #64748b;">#${u.id}</td>
<td>
<strong style="color: #f8fafc;">${escapeHtml(u.name)}</strong>
</td>
<td style="color: #94a3b8; font-family: var(--font-mono);">${escapeHtml(u.email)}</td>
<td>
<select onchange="changeUserRole(${u.id}, this.value)" style="background: #080c14; border: 1px solid rgba(255,255,255,0.15); color: white; padding: 3px 6px; border-radius: 4px; font-size: 0.76rem; cursor: pointer;">
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
<option value="teacher" ${u.role === 'teacher' ? 'selected' : ''}>Lehrkraft</option>
<option value="student" ${u.role === 'student' ? 'selected' : ''}>Schüler</option>
<option value="solo" ${u.role === 'solo' ? 'selected' : ''}>Solo-Lerner</option>
</select>
</td>
<td>
<span style="color: #fbbf24; font-weight: 700;">⭐ Lvl ${u.level || 1}</span>
<span style="color: #64748b; font-size: 0.78rem;">(${u.xp || 0} XP)</span>
</td>
<td>
<span style="color: #10b981; font-weight: 700;">✓ ${u.solved_count || 0}</span>
</td>
<td style="color: #64748b; font-size: 0.8rem;">${createdDate}</td>
<td style="text-align: right;">
<button type="button" class="admin-btn" onclick="openResetPasswordModal(${u.id}, '${escapeHtml(u.name)}')" title="Passwort zurücksetzen">🔑</button>
<button type="button" class="admin-btn admin-btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.name)}')" title="Benutzer löschen">🗑️</button>
</td>
</tr>
`;
}).join('');
}
function escapeHtml(t) {
if (!t) return '';
return String(t).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// Filter Users
const searchInput = document.getElementById('admin-search-input');
const roleFilter = document.getElementById('admin-filter-role');
function applyFilters() {
const q = (searchInput.value || '').toLowerCase().trim();
const role = roleFilter.value;
const filtered = ALL_USERS.filter(u => {
const matchesQ = !q || u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q) || String(u.id) === q;
const matchesRole = (role === 'all' || u.role === role);
return matchesQ && matchesRole;
});
renderUsersTable(filtered);
}
searchInput.addEventListener('input', applyFilters);
roleFilter.addEventListener('change', applyFilters);
// Change Role
async function changeUserRole(userId, newRole) {
const token = localStorage.getItem('auth_token');
try {
const res = await fetch('/api/admin/users/role', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ user_id: userId, role: newRole })
});
const d = await res.json();
if (res.ok) {
alert(`Rolle erfolgreich geändert: ${d.message}`);
loadUsers(token);
loadStats(token);
} else {
alert(`Fehler: ${d.error}`);
}
} catch (err) {
alert("Netzwerkfehler: " + err.message);
}
}
// Delete User
async function deleteUser(userId, name) {
if (!confirm(`Soll der Benutzer '${name}' (ID: ${userId}) wirklich unwiderruflich gelöscht werden?`)) return;
const token = localStorage.getItem('auth_token');
try {
const res = await fetch('/api/admin/users/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ user_id: userId })
});
const d = await res.json();
if (res.ok) {
alert("Benutzer gelöscht!");
loadUsers(token);
loadStats(token);
} else {
alert("Fehler: " + d.error);
}
} catch (err) {
alert("Netzwerkfehler: " + err.message);
}
}
// Open Reset Password
function openResetPasswordModal(userId, name) {
document.getElementById('reset-user-id').value = userId;
document.getElementById('reset-user-name').innerText = name;
document.getElementById('reset-new-password').value = '';
document.getElementById('modal-reset-password').style.display = 'flex';
}
// Form Reset Password
document.getElementById('form-reset-password').onsubmit = async (e) => {
e.preventDefault();
const userId = document.getElementById('reset-user-id').value;
const newPassword = document.getElementById('reset-new-password').value;
const token = localStorage.getItem('auth_token');
try {
const res = await fetch('/api/admin/users/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ user_id: userId, new_password: newPassword })
});
const d = await res.json();
if (res.ok) {
alert("Passwort erfolgreich geändert!");
closeModal('modal-reset-password');
} else {
alert("Fehler: " + d.error);
}
} catch (err) {
alert("Netzwerkfehler: " + err.message);
}
};
// Form Create User
document.getElementById('form-create-user').onsubmit = async (e) => {
e.preventDefault();
const name = document.getElementById('create-name').value.trim();
const email = document.getElementById('create-email').value.trim();
const password = document.getElementById('create-password').value;
const role = document.getElementById('create-role').value;
const token = localStorage.getItem('auth_token');
try {
const res = await fetch('/api/admin/users/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ name, email, password, role })
});
const d = await res.json();
if (res.ok) {
alert(`Benutzer '${name}' erfolgreich angelegt!`);
closeModal('modal-create-user');
document.getElementById('form-create-user').reset();
loadUsers(token);
loadStats(token);
} else {
alert("Fehler: " + d.error);
}
} catch (err) {
alert("Netzwerkfehler: " + err.message);
}
};
// Load & Save Settings
async function loadSettings(token) {
try {
const res = await fetch('/api/admin/settings', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const d = await res.json();
const s = d.settings || {};
document.getElementById('setting-announcement').value = s.announcement_banner || '';
document.getElementById('setting-allow-reg').checked = (s.allow_registrations !== '0');
document.getElementById('setting-maintenance').checked = (s.maintenance_mode === '1');
}
} catch (e) { }
}
document.getElementById('admin-settings-form').onsubmit = async (e) => {
e.preventDefault();
const token = localStorage.getItem('auth_token');
const settings = {
announcement_banner: document.getElementById('setting-announcement').value.trim(),
allow_registrations: document.getElementById('setting-allow-reg').checked ? '1' : '0',
maintenance_mode: document.getElementById('setting-maintenance').checked ? '1' : '0'
};
try {
const res = await fetch('/api/admin/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ settings })
});
const d = await res.json();
if (res.ok) {
alert("Einstellungen erfolgreich gespeichert!");
} else {
alert("Fehler: " + d.error);
}
} catch (err) {
alert("Netzwerkfehler: " + err.message);
}
};
document.addEventListener('DOMContentLoaded', loadAdminData);
</script>
</body>
</html>

View file

@ -181,7 +181,11 @@
}; };
} }
if (teacherLink) { if (teacherLink) {
teacherLink.style.display = this.user.role === 'teacher' ? 'inline-flex' : 'none'; teacherLink.style.display = (this.user.role === 'teacher' || this.user.role === 'admin') ? 'inline-flex' : 'none';
}
const adminLink = document.getElementById('nav-admin-link');
if (adminLink) {
adminLink.style.display = this.user.role === 'admin' ? 'inline-flex' : 'none';
} }
} else { } else {
if (userSpan) userSpan.style.display = 'none'; if (userSpan) userSpan.style.display = 'none';

View file

@ -93,6 +93,7 @@
<a href="#features-ide" class="portal-nav-link">💻 Web-IDE</a> <a href="#features-ide" class="portal-nav-link">💻 Web-IDE</a>
<a href="#features" class="portal-nav-link">💎 Vorteile</a> <a href="#features" class="portal-nav-link">💎 Vorteile</a>
<a href="teacher.html" class="portal-nav-link" id="nav-teacher-link" style="display: none;">👨‍🏫 Dozenten</a> <a href="teacher.html" class="portal-nav-link" id="nav-teacher-link" style="display: none;">👨‍🏫 Dozenten</a>
<a href="admin.html" class="portal-nav-link" id="nav-admin-link" style="display: none; color: #f87171; font-weight: 800;">🛡️ Admin</a>
</nav> </nav>
<div style="display: flex; align-items: center; gap: 10px;"> <div style="display: flex; align-items: center; gap: 10px;">

42
make_admin.py Executable file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""
🛡️ CLI HELPER: ADMIN STATUS VERGEBEN 🛡️
==========================================
Verwendung:
python3 make_admin.py <email>
"""
import sys
import sqlite3
from pathlib import Path
DB_FILE = Path(__file__).parent / "platform_data.db"
def make_admin(email: str):
if not DB_FILE.exists():
print(f"❌ Datenbank nicht gefunden: {DB_FILE}")
sys.exit(1)
conn = sqlite3.connect(str(DB_FILE))
cur = conn.cursor()
cur.execute("SELECT id, name, role FROM users WHERE email = ?", (email,))
row = cur.fetchone()
if not row:
print(f"❌ Kein Benutzer mit E-Mail '{email}' gefunden.")
print("Registrierte Benutzer:")
for r in cur.execute("SELECT id, name, email, role FROM users").fetchall():
print(f" - [{r[0]}] {r[1]} ({r[2]}) -> Rolle: {r[3]}")
conn.close()
sys.exit(1)
cur.execute("UPDATE users SET role = 'admin' WHERE id = ?", (row[0],))
conn.commit()
conn.close()
print(f"🎉 Erfolgreich: '{row[1]}' ({email}) ist nun vollwertiger System-Administrator (Rolle: admin)!")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Verwendung: python3 make_admin.py <email>")
sys.exit(1)
make_admin(sys.argv[1].strip().lower())

150
server.py
View file

@ -151,6 +151,13 @@ def init_db():
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)""") )""")
c.execute("""
CREATE TABLE IF NOT EXISTS platform_settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
conn.commit() conn.commit()
conn.close() conn.close()
@ -241,6 +248,17 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
return verify_token(token) return verify_token(token)
return None return None
def get_admin_user(self) -> dict | None:
user = self.get_auth_user()
if not user:
return None
conn = get_db()
row = conn.execute("SELECT id, email, name, role FROM users WHERE id = ?", (user["uid"],)).fetchone()
conn.close()
if row and row["role"] == "admin":
return dict(row)
return None
def read_json_body(self) -> dict: def read_json_body(self) -> dict:
content_length = int(self.headers.get("Content-Length", 0)) content_length = int(self.headers.get("Content-Length", 0))
if content_length == 0 or content_length > 1_000_000: if content_length == 0 or content_length > 1_000_000:
@ -413,6 +431,55 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
conn.close() conn.close()
return self.send_json({"leaderboard": [dict(r) for r in rows]}) return self.send_json({"leaderboard": [dict(r) for r in rows]})
# 7. ADMIN: SYSTEMSTATISTIKEN
elif path == "/api/admin/stats":
admin = self.get_admin_user()
if not admin:
return self.send_json({"error": "Admin-Rechte erforderlich"}, 403)
conn = get_db()
total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
roles_count = dict(conn.execute("SELECT role, COUNT(*) FROM users GROUP BY role").fetchall())
total_solved = conn.execute("SELECT COUNT(*) FROM chapter_progress WHERE is_solved = 1").fetchone()[0]
total_classrooms = conn.execute("SELECT COUNT(*) FROM classrooms").fetchone()[0]
recent_users = conn.execute("SELECT COUNT(*) FROM users WHERE created_at >= datetime('now', '-7 days')").fetchone()[0]
conn.close()
db_size = DB_FILE.stat().st_size if DB_FILE.exists() else 0
return self.send_json({
"total_users": total_users,
"roles": roles_count,
"total_solved": total_solved,
"total_classrooms": total_classrooms,
"recent_users": recent_users,
"db_size_kb": round(db_size / 1024, 1)
})
# 8. ADMIN: BENUTZERLISTE
elif path == "/api/admin/users":
admin = self.get_admin_user()
if not admin:
return self.send_json({"error": "Admin-Rechte erforderlich"}, 403)
conn = get_db()
rows = conn.execute("""
SELECT u.id, u.email, u.name, u.role, u.xp, u.level, u.streak_days, u.created_at,
COUNT(cp.id) as solved_count
FROM users u
LEFT JOIN chapter_progress cp ON u.id = cp.user_id AND cp.is_solved = 1
GROUP BY u.id
ORDER BY u.id DESC
""").fetchall()
conn.close()
return self.send_json({"users": [dict(r) for r in rows]})
# 9. ADMIN: EINSTELLUNGEN
elif path == "/api/admin/settings":
admin = self.get_admin_user()
if not admin:
return self.send_json({"error": "Admin-Rechte erforderlich"}, 403)
conn = get_db()
rows = conn.execute("SELECT key, value FROM platform_settings").fetchall()
conn.close()
return self.send_json({"settings": dict(rows)})
return super().do_GET() return super().do_GET()
def do_POST(self): def do_POST(self):
@ -641,6 +708,89 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
conn.close() conn.close()
return self.send_json({"uuid": cert_uuid, "track_id": track_id, "student_name": student_name}) return self.send_json({"uuid": cert_uuid, "track_id": track_id, "student_name": student_name})
# 9. ADMIN: ROLLE ÄNDERN
elif path == "/api/admin/users/role":
admin = self.get_admin_user()
if not admin:
return self.send_json({"error": "Admin-Rechte erforderlich"}, 403)
target_uid = int(body.get("user_id", 0))
new_role = str(body.get("role", "")).strip().lower()
if new_role not in ["admin", "teacher", "student", "solo"]:
return self.send_json({"error": "Ungültige Rolle"}, 400)
conn = get_db()
conn.execute("UPDATE users SET role = ? WHERE id = ?", (new_role, target_uid))
conn.commit()
conn.close()
return self.send_json({"success": True, "message": f"Rolle auf '{new_role}' geändert"})
# 10. ADMIN: PASSWORT ZURÜCKSETZEN
elif path == "/api/admin/users/reset-password":
admin = self.get_admin_user()
if not admin:
return self.send_json({"error": "Admin-Rechte erforderlich"}, 403)
target_uid = int(body.get("user_id", 0))
new_pw = str(body.get("new_password", "")).strip()
if len(new_pw) < 6:
return self.send_json({"error": "Passwort muss mindestens 6 Zeichen lang sein"}, 400)
salt = secrets.token_hex(16)
pw_hash, _ = hash_password(new_pw, salt)
conn = get_db()
conn.execute("UPDATE users SET password_hash = ?, salt = ? WHERE id = ?", (pw_hash, salt, target_uid))
conn.commit()
conn.close()
return self.send_json({"success": True, "message": "Passwort erfolgreich aktualisiert"})
# 11. ADMIN: BENUTZER LÖSCHEN
elif path == "/api/admin/users/delete":
admin = self.get_admin_user()
if not admin:
return self.send_json({"error": "Admin-Rechte erforderlich"}, 403)
target_uid = int(body.get("user_id", 0))
if target_uid == admin["id"]:
return self.send_json({"error": "Der eigene Admin-Account kann nicht gelöscht werden"}, 400)
conn = get_db()
conn.execute("DELETE FROM users WHERE id = ?", (target_uid,))
conn.commit()
conn.close()
return self.send_json({"success": True, "message": "Benutzer erfolgreich gelöscht"})
# 12. ADMIN: BENUTZER MANUELL ANLEGEN
elif path == "/api/admin/users/create":
admin = self.get_admin_user()
if not admin:
return self.send_json({"error": "Admin-Rechte erforderlich"}, 403)
name = body.get("name", "").strip()
email = body.get("email", "").strip().lower()
password = body.get("password", "")
role = body.get("role", "student")
if not name or not email or len(password) < 6 or role not in ["admin", "teacher", "student", "solo"]:
return self.send_json({"error": "Ungültige Eingaben"}, 400)
salt = secrets.token_hex(16)
pw_hash, _ = hash_password(password, salt)
try:
conn = get_db()
cur = conn.execute("INSERT INTO users (name, email, password_hash, salt, role) VALUES (?, ?, ?, ?, ?)",
(name, email, pw_hash, salt, role))
new_id = cur.lastrowid
conn.commit()
conn.close()
return self.send_json({"success": True, "user_id": new_id, "message": f"Benutzer {name} erstellt"})
except sqlite3.IntegrityError:
return self.send_json({"error": "E-Mail-Adresse existiert bereits"}, 409)
# 13. ADMIN: GLOBALE EINSTELLUNGEN SPEICHERN
elif path == "/api/admin/settings":
admin = self.get_admin_user()
if not admin:
return self.send_json({"error": "Admin-Rechte erforderlich"}, 403)
settings = body.get("settings", {})
conn = get_db()
for k, v in settings.items():
conn.execute("INSERT INTO platform_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP", (str(k), str(v)))
conn.commit()
conn.close()
return self.send_json({"success": True, "message": "Einstellungen gespeichert"})
return self.send_json({"error": "Endpunkt nicht gefunden"}, 404) return self.send_json({"error": "Endpunkt nicht gefunden"}, 404)
def run_server(): def run_server():