Admin-Accounts-Editor (CRUD inkl. 2FA-Mail) + Regel: Lehrer-Username = E-Mail (Maske + register), Schueler frei
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Admin-Accounts</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96"><link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',sans-serif;background:#f7f6f3;min-height:100vh}
|
||||
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06);flex-wrap:wrap}
|
||||
.topbar h1{font-size:.95rem;font-weight:800;color:#4a7c8a;flex:1;display:flex;align-items:center;gap:.5rem}
|
||||
.topbar h1 img{height:28px}
|
||||
.btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none}
|
||||
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
|
||||
.btn-sm.active{background:#4a7c8a;color:#fff;border-color:#4a7c8a}
|
||||
.wrap{max-width:900px;margin:1rem auto;padding:0 1rem}
|
||||
.card{background:#fff;border-radius:10px;padding:1rem;box-shadow:0 1px 4px rgba(0,0,0,.03);border:1px solid rgba(0,0,0,.04);margin-bottom:1rem}
|
||||
.card-h{font-size:.62rem;font-weight:700;color:#4a7c8a;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.6rem}
|
||||
label{display:block;font-size:.65rem;font-weight:600;color:#4a4a4a;margin-bottom:.15rem}
|
||||
input{width:100%;padding:.45rem .6rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.8rem;font-family:inherit;outline:none;transition:border .15s}
|
||||
input:focus{border-color:#4a7c8a}
|
||||
.btn{padding:.45rem .9rem;border:none;border-radius:6px;background:#4a7c8a;color:#fff;font-size:.75rem;font-weight:700;cursor:pointer}
|
||||
.btn:hover{background:#3a6470}
|
||||
.btn-outline{background:transparent;color:#4a7c8a;border:1.5px solid #4a7c8a}
|
||||
.btn-danger{background:transparent;color:#c07a6b;border:1.5px solid #e0b5ac}
|
||||
.btn-danger:hover{background:#f2ddd8}
|
||||
.btn-xs{padding:.28rem .5rem;font-size:.66rem;border-radius:5px;font-weight:700;cursor:pointer}
|
||||
table{width:100%;border-collapse:collapse;font-size:.74rem}
|
||||
th{background:#f7f6f3;padding:.4rem .5rem;text-align:left;font-weight:600;font-size:.6rem;color:#8a8a8a;text-transform:uppercase;letter-spacing:.04em}
|
||||
td{padding:.45rem .5rem;border-bottom:1px solid rgba(0,0,0,.04);vertical-align:middle}
|
||||
td input{padding:.35rem .5rem;font-size:.74rem}
|
||||
.uname{font-weight:800;color:#1f4e5a}
|
||||
.self-badge{display:inline-block;margin-left:.35rem;font-size:.58rem;font-weight:700;color:#4a7c8a;background:#dae8ec;border-radius:99px;padding:.05rem .4rem;vertical-align:middle}
|
||||
.row-actions{display:flex;gap:.35rem;flex-wrap:wrap;justify-content:flex-end}
|
||||
.grid-form{display:grid;grid-template-columns:1fr 1.4fr 1fr auto;gap:.6rem;align-items:end}
|
||||
@media(max-width:640px){.grid-form{grid-template-columns:1fr}}
|
||||
.muted{color:#8a8a8a;font-size:.68rem}
|
||||
.msg{font-size:.72rem;padding:.45rem .6rem;border-radius:6px;margin-top:.6rem;display:none}
|
||||
.msg.ok{display:block;background:#dceadd;color:#5a8a5e}
|
||||
.msg.err{display:block;background:#f2ddd8;color:#c07a6b}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<h1><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"> Admin-Accounts</h1>
|
||||
<a href="admin-licenses.html" class="btn-sm">🎫 Lizenzen</a>
|
||||
<a href="admin-modules.html" class="btn-sm">🧩 Module</a>
|
||||
<a href="admin-levels.html" class="btn-sm">🎮 Level</a>
|
||||
<a href="admin-styleguide.html" class="btn-sm">🎨 Styleguide</a>
|
||||
<a href="admin-accounts.html" class="btn-sm active">👤 Admins</a>
|
||||
<a href="admin.html" class="btn-sm" onclick="doLogout(event)">Logout</a>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">Neuen Admin-Account anlegen</div>
|
||||
<div class="grid-form">
|
||||
<div>
|
||||
<label>Benutzername</label>
|
||||
<input type="text" id="c-user" autocomplete="off" placeholder="z. B. maria">
|
||||
</div>
|
||||
<div>
|
||||
<label>2FA-E-Mail (PIN-Empfang)</label>
|
||||
<input type="email" id="c-email" autocomplete="off" placeholder="name@schule.at">
|
||||
</div>
|
||||
<div>
|
||||
<label>Passwort (min. 10 Zeichen)</label>
|
||||
<input type="text" id="c-pass" autocomplete="new-password" placeholder="Passwort">
|
||||
</div>
|
||||
<button class="btn" onclick="createAdmin()">Anlegen</button>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.5rem">Der 4-stellige Login-PIN wird bei jeder Anmeldung an die hinterlegte 2FA-E-Mail geschickt.</div>
|
||||
<div class="msg" id="create-msg"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">Bestehende Admins</div>
|
||||
<div id="admin-table"></div>
|
||||
<div class="msg" id="list-msg"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var BASE = (window.__GGS__?.baseUrl) || '';
|
||||
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
|
||||
|
||||
async function api(endpoint, data) {
|
||||
var r = await fetch(BASE + '/api/' + endpoint, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
|
||||
function showMsg(id, text, type){ var m=document.getElementById(id); m.textContent=text; m.className='msg '+type; }
|
||||
|
||||
async function loadAdmins() {
|
||||
var r = await api('admin-accounts', {action:'list'});
|
||||
if (r.error) { showMsg('list-msg', r.error, 'err'); return; }
|
||||
var rows = r.admins || [];
|
||||
var html = '<table><tr><th>Benutzername</th><th>2FA-E-Mail</th><th>Angelegt</th><th style="text-align:right">Aktionen</th></tr>';
|
||||
rows.forEach(function(a){
|
||||
var created = (a.created_at || '').replace('T',' ').substring(0,16);
|
||||
html += '<tr data-id="'+a.id+'">';
|
||||
html += '<td><span class="uname">'+esc(a.username)+'</span>'+(a.is_self?'<span class="self-badge">du</span>':'')+'</td>';
|
||||
html += '<td><input type="email" class="edit-email" value="'+esc(a.email_2fa)+'"></td>';
|
||||
html += '<td class="muted">'+esc(created)+'</td>';
|
||||
html += '<td><div class="row-actions">'
|
||||
+ '<button class="btn-xs btn-outline" onclick="saveEmail('+a.id+')">💾 Mail</button>'
|
||||
+ '<button class="btn-xs btn-outline" onclick="setPassword('+a.id+',\''+esc(a.username)+'\')">🔑 Passwort</button>'
|
||||
+ '<button class="btn-xs btn-danger" '+(a.is_self?'disabled title="Eigener Account"':'onclick="delAdmin('+a.id+',\''+esc(a.username)+'\')"')+'>🗑</button>'
|
||||
+ '</div></td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</table>';
|
||||
document.getElementById('admin-table').innerHTML = html;
|
||||
}
|
||||
|
||||
async function createAdmin() {
|
||||
var username = document.getElementById('c-user').value.trim();
|
||||
var email = document.getElementById('c-email').value.trim();
|
||||
var password = document.getElementById('c-pass').value;
|
||||
if (!username || !email || !password) { showMsg('create-msg','Bitte alle Felder ausfüllen.','err'); return; }
|
||||
var r = await api('admin-accounts', {action:'create', username:username, email_2fa:email, password:password});
|
||||
if (r.error) { showMsg('create-msg', r.error, 'err'); return; }
|
||||
showMsg('create-msg', r.message || 'Angelegt.', 'ok');
|
||||
document.getElementById('c-user').value=''; document.getElementById('c-email').value=''; document.getElementById('c-pass').value='';
|
||||
loadAdmins();
|
||||
}
|
||||
|
||||
async function saveEmail(id) {
|
||||
var row = document.querySelector('tr[data-id="'+id+'"]');
|
||||
var email = row.querySelector('.edit-email').value.trim();
|
||||
var r = await api('admin-accounts', {action:'update', id:id, email_2fa:email});
|
||||
showMsg('list-msg', r.error || (r.message||'Gespeichert.'), r.error?'err':'ok');
|
||||
if (!r.error) loadAdmins();
|
||||
}
|
||||
|
||||
async function setPassword(id, username) {
|
||||
var pw = prompt('Neues Passwort für „'+username+'" (min. 10 Zeichen):');
|
||||
if (pw === null) return;
|
||||
if (pw.length < 10) { showMsg('list-msg','Passwort muss mindestens 10 Zeichen haben.','err'); return; }
|
||||
var r = await api('admin-accounts', {action:'update', id:id, password:pw});
|
||||
showMsg('list-msg', r.error || (r.message||'Passwort gesetzt.'), r.error?'err':'ok');
|
||||
}
|
||||
|
||||
async function delAdmin(id, username) {
|
||||
if (!confirm('Admin-Account „'+username+'" wirklich löschen?')) return;
|
||||
var r = await api('admin-accounts', {action:'delete', id:id});
|
||||
showMsg('list-msg', r.error || (r.message||'Gelöscht.'), r.error?'err':'ok');
|
||||
if (!r.error) loadAdmins();
|
||||
}
|
||||
|
||||
async function doLogout(e) {
|
||||
e.preventDefault();
|
||||
await fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify({action:'logout'})});
|
||||
location.href = 'admin.html';
|
||||
}
|
||||
|
||||
// Admin-Auth prüfen (2FA erforderlich)
|
||||
fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify({action:'status'})})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(r){ if (!r.authenticated) { location.href = 'admin.html'; return; } loadAdmins(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -86,6 +86,7 @@
|
||||
<a href="admin-modules.html" class="nav-btn">🧩 Module</a>
|
||||
<a href="admin-licenses.html" class="nav-btn">🔑 Lizenzen</a>
|
||||
<a href="admin-styleguide.html" class="nav-btn">🎨 Styleguide</a>
|
||||
<a href="admin-accounts.html" class="nav-btn">👤 Admins</a>
|
||||
<a href="admin.html" class="nav-btn">Logout</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<a href="admin-modules.html" class="btn-sm">🧩 Module</a>
|
||||
<a href="admin-levels.html" class="btn-sm">🎮 Level</a>
|
||||
<a href="admin-styleguide.html" class="btn-sm">🎨 Styleguide</a>
|
||||
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
|
||||
<a href="admin.html" class="btn-sm">Logout</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
<a href="admin-licenses.html" class="btn-sm">🎟️ Lizenzen</a>
|
||||
<a href="admin-levels.html" class="btn-sm">🎮 Level</a>
|
||||
<a href="admin-styleguide.html" class="btn-sm">🎨 Styleguide</a>
|
||||
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
|
||||
<a href="admin.html" class="btn-sm">Logout</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
<a href="admin-modules.html" class="btn-sm">Module</a>
|
||||
<a href="admin-levels.html" class="btn-sm">Level</a>
|
||||
<a href="admin-licenses.html" class="btn-sm">Lizenzen</a>
|
||||
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
|
||||
<a href="index.html" class="btn-sm">Startseite</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
<a href="admin-licenses.html" class="btn-sm">🔑 Lizenzen</a>
|
||||
<a href="admin-modules.html" class="btn-sm">🧩 Module</a>
|
||||
<a href="admin-levels.html" class="btn-sm">🎮 Level</a>
|
||||
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
|
||||
<a href="admin.html" class="btn-sm">Logout</a>
|
||||
</div>
|
||||
|
||||
|
||||
+3
-1
@@ -107,6 +107,7 @@ $activeMain = null;
|
||||
<h2>Einloggen</h2>
|
||||
<label>E-Mail oder Benutzername</label>
|
||||
<input type="text" id="l-user" placeholder="name@schule.at oder schueler01">
|
||||
<p style="font-size:.66rem;color:#8a8a8a;margin:.15rem 0 .3rem">Lehrpersonen melden sich mit ihrer <strong>E-Mail-Adresse</strong> an, Schüler*innen mit ihrem <strong>Benutzernamen</strong>.</p>
|
||||
<label>Passwort</label>
|
||||
<div class="pw-wrap"><input type="password" id="l-pass"><span class="pw-eye" onclick="togglePw(this)">👁</span></div>
|
||||
<button class="btn" onclick="doLogin()">Einloggen</button>
|
||||
@@ -123,8 +124,9 @@ $activeMain = null;
|
||||
</div>
|
||||
|
||||
<div id="reg-teacher">
|
||||
<label>E-Mail</label>
|
||||
<label>E-Mail-Adresse</label>
|
||||
<input type="email" id="r-email" placeholder="name@schule.at">
|
||||
<p style="font-size:.66rem;color:#8a8a8a;margin:.15rem 0 .4rem">Ihre E-Mail-Adresse ist zugleich Ihr <strong>Benutzername</strong> für die Anmeldung.</p>
|
||||
<label>Passwort (mind. 8 Zeichen)</label>
|
||||
<div class="pw-wrap"><input type="password" id="r-pass" minlength="8"><span class="pw-eye" onclick="togglePw(this)">👁</span></div>
|
||||
<label>Passwort bestätigen</label>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin API: Verwaltung der Super-Admin-Accounts (inkl. 2FA-Mailadresse)
|
||||
* POST /api/admin-accounts {action}
|
||||
* - list → alle Admins (ohne Passwort-Hash)
|
||||
* - create {username, email_2fa, password} → neuen Admin anlegen
|
||||
* - update {id, email_2fa?, password?} → 2FA-Mail und/oder Passwort ändern
|
||||
* - delete {id} → Admin löschen (nicht sich selbst, nicht den letzten)
|
||||
*
|
||||
* Auth: eingeloggter Super-Admin ($_SESSION['admin_id']). Nur Admins verwalten Admins.
|
||||
*/
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
|
||||
if (empty($_SESSION['admin_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Nicht eingeloggt']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$meId = (int)$_SESSION['admin_id'];
|
||||
$db = Database::get();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
Response::error('Nur POST erlaubt', 405);
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$action = $body['action'] ?? '';
|
||||
|
||||
// === LISTE ===
|
||||
if ($action === 'list') {
|
||||
$rows = $db->fetchAll('SELECT id, username, email_2fa, created_at FROM admin_users ORDER BY id');
|
||||
foreach ($rows as &$r) {
|
||||
$r['id'] = (int)$r['id'];
|
||||
$r['is_self'] = ($r['id'] === $meId);
|
||||
}
|
||||
unset($r);
|
||||
Response::ok(['admins' => $rows]);
|
||||
}
|
||||
|
||||
// === ANLEGEN ===
|
||||
if ($action === 'create') {
|
||||
$username = trim($body['username'] ?? '');
|
||||
$email = trim($body['email_2fa'] ?? '');
|
||||
$password = (string)($body['password'] ?? '');
|
||||
|
||||
if ($username === '' || $email === '' || $password === '') {
|
||||
Response::error('Benutzername, 2FA-E-Mail und Passwort sind erforderlich.');
|
||||
}
|
||||
if (!preg_match('/^[A-Za-z0-9._\-]{3,64}$/', $username)) {
|
||||
Response::error('Benutzername: 3–64 Zeichen, nur Buchstaben, Ziffern, Punkt, Unterstrich, Bindestrich.');
|
||||
}
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
Response::error('Ungültige 2FA-E-Mail-Adresse.');
|
||||
}
|
||||
if (strlen($password) < 10) {
|
||||
Response::error('Passwort muss mindestens 10 Zeichen haben.');
|
||||
}
|
||||
if ($db->fetchOne('SELECT id FROM admin_users WHERE username = ?', [$username])) {
|
||||
Response::error('Dieser Benutzername ist bereits vergeben.');
|
||||
}
|
||||
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$db->execute(
|
||||
'INSERT INTO admin_users (username, password, email_2fa) VALUES (?, ?, ?)',
|
||||
[$username, $hash, $email]
|
||||
);
|
||||
Response::ok(['message' => 'Admin-Account angelegt.']);
|
||||
}
|
||||
|
||||
// === AKTUALISIEREN (2FA-Mail und/oder Passwort) ===
|
||||
if ($action === 'update') {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) Response::error('ID fehlt.');
|
||||
if (!$db->fetchOne('SELECT id FROM admin_users WHERE id = ?', [$id])) {
|
||||
Response::error('Admin nicht gefunden.', 404);
|
||||
}
|
||||
|
||||
$didSomething = false;
|
||||
|
||||
if (array_key_exists('email_2fa', $body)) {
|
||||
$email = trim((string)$body['email_2fa']);
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
Response::error('Ungültige 2FA-E-Mail-Adresse.');
|
||||
}
|
||||
$db->execute('UPDATE admin_users SET email_2fa = ? WHERE id = ?', [$email, $id]);
|
||||
$didSomething = true;
|
||||
}
|
||||
|
||||
if (array_key_exists('password', $body) && (string)$body['password'] !== '') {
|
||||
$password = (string)$body['password'];
|
||||
if (strlen($password) < 10) {
|
||||
Response::error('Passwort muss mindestens 10 Zeichen haben.');
|
||||
}
|
||||
$db->execute(
|
||||
'UPDATE admin_users SET password = ? WHERE id = ?',
|
||||
[password_hash($password, PASSWORD_DEFAULT), $id]
|
||||
);
|
||||
$didSomething = true;
|
||||
}
|
||||
|
||||
if (!$didSomething) Response::error('Nichts zu ändern.');
|
||||
Response::ok(['message' => 'Admin-Account aktualisiert.']);
|
||||
}
|
||||
|
||||
// === LÖSCHEN ===
|
||||
if ($action === 'delete') {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) Response::error('ID fehlt.');
|
||||
if ($id === $meId) Response::error('Du kannst deinen eigenen Account nicht löschen.');
|
||||
|
||||
$row = $db->fetchOne('SELECT COUNT(*) AS c FROM admin_users');
|
||||
if ((int)($row['c'] ?? 0) <= 1) {
|
||||
Response::error('Der letzte Admin-Account kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
$db->execute('DELETE FROM admin_users WHERE id = ?', [$id]);
|
||||
$db->execute('DELETE FROM admin_pins WHERE admin_id = ?', [$id]); // verwaiste PINs aufräumen
|
||||
Response::ok(['message' => 'Admin-Account gelöscht.']);
|
||||
}
|
||||
|
||||
Response::error('Unbekannte Aktion.');
|
||||
@@ -27,7 +27,9 @@ if ($action === 'register') {
|
||||
$existing = $db->fetchOne('SELECT id FROM teachers WHERE email = ?', [$email]);
|
||||
if ($existing) Response::error('Diese E-Mail-Adresse ist bereits registriert');
|
||||
|
||||
$username = strtolower(explode('@', $email)[0]) . '_' . rand(100, 999);
|
||||
// Regel (ab 2026-08): Der Benutzername einer Lehrperson IST ihre E-Mail-Adresse.
|
||||
// Bestehende, abweichende Benutzernamen bleiben gültig (Login akzeptiert E-Mail ODER Benutzername).
|
||||
$username = strtolower($email);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
$db->execute(
|
||||
|
||||
Reference in New Issue
Block a user