Lizenz-Gate: Schueler ohne Lizenz gesperrt + Lehrer-Cockpit-Warnung

Teil 1 — Schueler-Lizenz-Gate (gilt fuer alle Klassen-Schueler ohne gueltige
Lizenz = zugewiesen, nicht storniert, nicht abgelaufen):
- Server-Gate zentral im Front-Controller (index.php + php/lib/license_gate.php):
  eingeloggte Klassen-Schueler ohne Lizenz sehen bei JEDER Sim-Route statt der
  Simulation eine Hinweis-Seite "Deine Lehrperson muss dir noch eine gueltige
  Lizenz zuweisen". Schnell-Ausstieg fuer Nicht-Schueler/Nicht-Sim (kein Cookie
  -> keine DB-Abfrage). Autodidakt ohne Klasse: nicht lizenzpflichtig.
- modules.php?student=1 liefert 'licensed'; Schueler-Cockpit (schueler.html)
  zeigt oben die Meldung und sperrt die Start-Buttons.

Teil 2 — Lehrer-Cockpit:
- Rote Warnleiste ganz oben, wenn Schueler:innen ohne gueltige Lizenz in der
  Klasse sind (+ Link "Jetzt zuweisen"). licenses?class_id=X liefert dazu die
  Liste 'unlicensed'.
- Lizenzen-Lasche: neue Sektion "Ohne gueltige Lizenz" mit Einzel- und
  Sammel-Zuweisung (auto_assign) — sonst fuehrt die Warnung ins Leere.

Kunden-Vereinfachung (Feedback): keine Auto-Erkennung/Dedup mehr (Ansprech-
partner koennen wechseln). Kunde = Freitext "Name/Verlag/Schule" + Mailadresse,
je Bestellung gespeichert. Editierbares Kundennummer-Feld + Autocomplete entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 16:15:16 +02:00
parent eea5688c76
commit ff889865e1
7 changed files with 197 additions and 29 deletions
+6 -12
View File
@@ -145,16 +145,12 @@
<div class="hint-s">Geht in die Mail (für die Rechnung des Kunden).</div>
</div>
<div>
<label>Kunde — E-Mail</label>
<input type="email" id="iss-email" placeholder="kunde@schule.at" list="cust-hints" oninput="custSuggest(this.value)">
<label>Kunde — Name / Verlag / Schule</label>
<input type="text" id="iss-name" placeholder="z. B. Verlag XY · VS Musterstadt">
</div>
<div>
<label>Kundennummer (intern)</label>
<input type="text" id="iss-custno" placeholder="optional">
</div>
<div>
<label>Name / Verlag / Schule</label>
<input type="text" id="iss-name" placeholder="optional">
<label>E-Mail</label>
<input type="email" id="iss-email" placeholder="kunde@schule.at">
</div>
<div>
<label>Gültig von</label>
@@ -166,7 +162,6 @@
<div class="hint-s">Standard: 1. Okt. Folgejahr (merkt sich die letzte Wahl).</div>
</div>
</div>
<datalist id="cust-hints"></datalist>
<datalist id="order-hints"></datalist>
<div style="margin-top:.6rem;display:flex;align-items:center;gap:.7rem;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:.4rem;font-size:.75rem;font-weight:600;color:#4a4a4a">
@@ -446,7 +441,6 @@ async function issueBatch(){
var type = document.getElementById('iss-type').value;
var order = document.getElementById('iss-order').value.trim();
var email = document.getElementById('iss-email').value.trim();
var custno = document.getElementById('iss-custno').value.trim();
var name = document.getElementById('iss-name').value.trim();
var vf = document.getElementById('iss-from').value;
var vu = document.getElementById('iss-until').value;
@@ -454,11 +448,11 @@ async function issueBatch(){
var msg = document.getElementById('iss-msg');
if(count<1||count>5000){ msg.textContent='Anzahl 15000.'; return; }
var delivered = calcDelivered(count, bonus);
var who = name||email||custno||'(kein Kunde)';
var who = name||email||'(kein Kunde)';
if(count>=100 && !confirm(count+' bestellt → '+delivered+' Codes im Paket für „'+who+'"'+(order?' (Best.-Nr. '+order+')':'')+', gültig bis '+fmtDate(vu)+', Typ '+type+'.\n\nWirklich erzeugen?')) return;
msg.textContent = 'Erzeuge …';
var r = await api('licenses', {action:'issue_batch', count:count, bonus:bonus, sales_type:type, order_no:order,
customer:{email:email, customer_no:custno, name:name}, valid_from:vf, valid_until:vu});
customer:{email:email, name:name}, valid_from:vf, valid_until:vu});
if(r.error){ msg.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
msg.innerHTML='<span style="color:#5a8a5e">✓ '+r.count+' Codes erzeugt (Bestellung #'+r.internal_no+', bestellt '+r.ordered+').</span>';
lastIssue = {batch_id:r.batch_id, internal_no:r.internal_no, ordered:r.ordered, codes:r.codes, count:r.count, valid_until:r.valid_until, token:r.redeem_token, order_no:order, email:email};
+6
View File
@@ -43,6 +43,12 @@ if (strpos($path, 'api/') === 0) {
$page = preg_replace('/[^a-z0-9\-]/', '', $path);
$pageFile = __DIR__ . '/pages/' . $page . '.php';
// Lizenz-Gate: eingeloggte Schüler:innen (in einer Klasse) ohne gültige Lizenz
// sehen bei Sim-Routen die Hinweis-Seite statt der Simulation. Schnell-Ausstieg
// fuer Nicht-Schüler / Nicht-Sim-Routen (kein Cookie -> keine DB-Abfrage).
require_once __DIR__ . '/php/lib/license_gate.php';
ggs_license_gate($page);
if (file_exists($pageFile)) {
require $pageFile;
exit;
+20 -15
View File
@@ -43,21 +43,13 @@ function lic_default_valid_until(Database $db): string {
return ($start + 1) . '-10-01';
}
/** Kunde finden (nach Kundennummer, dann Email) oder anlegen. Ergaenzt fehlende Felder. */
function lic_find_or_create_customer(Database $db, ?string $email, ?string $customerNo, ?string $name): ?int {
/** Kunde je Bestellung als Freitext ablegen (Name/Verlag/Schule + Mail) —
* KEIN Abgleich/Dedup, da sich Ansprechpartner ändern können. */
function lic_create_customer(Database $db, ?string $email, ?string $name): ?int {
$email = $email ? trim($email) : null;
$customerNo = $customerNo ? trim($customerNo) : null;
$name = $name ? trim($name) : null;
if (!$email && !$customerNo && !$name) return null;
$found = null;
if ($customerNo) $found = $db->fetchOne("SELECT id FROM license_customers WHERE customer_no = ? LIMIT 1", [$customerNo]);
if (!$found && $email) $found = $db->fetchOne("SELECT id FROM license_customers WHERE email = ? LIMIT 1", [$email]);
if ($found) {
$db->execute("UPDATE license_customers SET email = COALESCE(email, ?), customer_no = COALESCE(customer_no, ?), name = COALESCE(name, ?) WHERE id = ?",
[$email, $customerNo, $name, $found['id']]);
return (int)$found['id'];
}
$db->execute("INSERT INTO license_customers (customer_no, email, name) VALUES (?,?,?)", [$customerNo, $email, $name]);
if (!$email && !$name) return null;
$db->execute("INSERT INTO license_customers (email, name) VALUES (?,?)", [$email, $name]);
return (int)$db->lastInsertId();
}
@@ -239,7 +231,20 @@ if ($method === 'GET') {
[$teacherId]
);
Response::ok(['assigned' => $licenses, 'free' => $freeLicenses]);
// Schüler:innen der Klasse OHNE gültige Lizenz (für die rote Cockpit-Warnung).
$unlicensed = $db->fetchAll(
"SELECT s.id, s.username, s.display_name
FROM students s
WHERE s.class_id = ? AND s.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM licenses l
WHERE l.student_id = s.id AND l.canceled_at IS NULL
AND (l.valid_until IS NULL OR l.valid_until >= CURDATE()))
ORDER BY s.username",
[$classId]
);
Response::ok(['assigned' => $licenses, 'free' => $freeLicenses, 'unlicensed' => $unlicensed]);
}
// === POST ===
@@ -381,7 +386,7 @@ if ($method === 'POST') {
if (strtotime($validUntil) < strtotime($validFrom)) Response::error('„Gültig bis" liegt vor „Gültig von"');
$cust = is_array($body['customer'] ?? null) ? $body['customer'] : [];
$customerId = lic_find_or_create_customer($db, $cust['email'] ?? null, $cust['customer_no'] ?? null, $cust['name'] ?? null);
$customerId = lic_create_customer($db, $cust['email'] ?? null, $cust['name'] ?? null);
$schoolYear = lic_school_year_from($validFrom);
$token = bin2hex(random_bytes(20));
+13
View File
@@ -50,6 +50,7 @@ if ($method === 'GET') {
'play_url' => $mod['play_url'],
'available_from' => $mod['available_from'],
'mode' => $mod['status'] === 'geplant' ? 'coming_soon' : 'free',
'licensed' => true, // Autodidakt ohne Klasse: nicht lizenzpflichtig
];
}
Response::ok($result);
@@ -83,6 +84,17 @@ if ($method === 'GET') {
foreach ($rows as $r) $overrides[$r['module_id']] = $r['mode'];
}
// Lizenz-Status des Schülers (fürs Cockpit-Gate). Gültig = zugewiesen,
// nicht storniert, nicht abgelaufen (valid_until >= heute; NULL = Alt-Code).
$licensed = false;
if ($studentRow && !empty($studentRow['id'])) {
$lic = $db->fetchOne(
"SELECT 1 AS ok FROM licenses WHERE student_id = ? AND canceled_at IS NULL
AND (valid_until IS NULL OR valid_until >= CURDATE()) LIMIT 1",
[$studentRow['id']]);
$licensed = (bool)$lic;
}
// Einzelner Modul-Filter
$filterId = $_GET['module_id'] ?? null;
@@ -117,6 +129,7 @@ if ($method === 'GET') {
'dueDate' => $cm['due_date'] ?? null,
'paused' => (bool)($cm['paused'] ?? 0),
'assignmentCompleted' => $assignmentCompleted,
'licensed' => $licensed,
];
}
// Wenn einzelnes Modul, liefere Objekt statt Array
+79
View File
@@ -0,0 +1,79 @@
<?php
/**
* Lizenz-Gate — eingeloggte Schüler:innen (in einer Klasse) ohne gültige Lizenz
* sehen bei Sim-Routen statt der Simulation eine Hinweis-Seite. Zentral im
* Front-Controller aufgerufen, damit alle Sim-Wrapper auf einmal geschützt sind.
*
* „Gültige Lizenz" = dem Kind zugewiesen, NICHT storniert und NICHT abgelaufen
* (valid_until >= heute; NULL = Alt-Code ohne Ablauf).
*/
function ggs_student_has_valid_license(PDO $pdo, int $studentId): bool {
if ($studentId <= 0) return false;
$st = $pdo->prepare(
"SELECT 1 FROM licenses
WHERE student_id = ? AND canceled_at IS NULL
AND (valid_until IS NULL OR valid_until >= CURDATE())
LIMIT 1"
);
$st->execute([$studentId]);
return (bool)$st->fetchColumn();
}
/** Ist die Route eine spielbare Sim (play_url ODER module_id, aktiv/beta)? */
function ggs_is_sim_route(PDO $pdo, string $route): bool {
if ($route === '') return false;
$st = $pdo->prepare(
"SELECT 1 FROM module_info
WHERE (play_url = ? OR module_id = ?) AND status IN ('aktiv','beta') LIMIT 1"
);
$st->execute([$route, $route]);
return (bool)$st->fetchColumn();
}
/**
* Front-Controller-Hook: blockt Sim-Zugriff eingeloggter Klassen-Schüler ohne
* gültige Lizenz. Schnell-Ausstieg fuer alle anderen (kein Cookie / keine Sim).
*/
function ggs_license_gate(string $route): void {
$sessionId = $_COOKIE['ggs_session'] ?? null;
if (!$sessionId) return; // nur eingeloggte Schüler:innen
$pdo = getDB();
$st = $pdo->prepare('SELECT student_id, class_id FROM student_sessions WHERE id = ?');
$st->execute([$sessionId]);
$sess = $st->fetch(PDO::FETCH_ASSOC);
if (!$sess || empty($sess['class_id'])) return; // Autodidakt ohne Klasse: nicht im Lizenzmodell
if (!ggs_is_sim_route($pdo, $route)) return; // keine Sim-Route
$studentId = (int)($sess['student_id'] ?? 0);
if (ggs_student_has_valid_license($pdo, $studentId)) return;
ggs_render_license_wall(); // beendet die Anfrage
}
function ggs_render_license_wall(): void {
http_response_code(403);
header('Content-Type: text/html; charset=utf-8');
$css = BASE_PATH . '/assets/css/design-system.css';
$cockpit = BASE_PATH . '/schueler';
echo <<<HTML
<!DOCTYPE html><html lang="de"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lizenz benötigt · GeoGraSim</title>
<link rel="stylesheet" href="{$css}">
<style>
body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;padding:40px 20px;max-width:620px;margin:auto;color:#2a2a2a}
h1{color:#1f4e5a;font-size:1.4rem;margin:0 0 .6rem}
.lic-card{background:#f2f8f9;border:1px solid #cfe3e8;border-left:5px solid #4a7c8a;padding:26px;border-radius:12px}
.lic-card p{line-height:1.6;margin:.5rem 0}
.lic-card a{color:#4a7c8a;font-weight:700}
.big{font-size:2.2rem;margin-bottom:.2rem}
</style></head><body>
<div class="lic-card">
<div class="big">🔑</div>
<h1>Noch keine Lizenz</h1>
<p><strong>Deine Lehrperson muss dir noch eine gültige Lizenz zuweisen.</strong></p>
<p>Sobald das erledigt ist, kannst du alle freigegebenen Simulationen nutzen.</p>
<p style="margin-top:1rem"><a href="{$cockpit}">← Zurück zum Cockpit</a></p>
</div></body></html>
HTML;
exit;
}
+12 -2
View File
@@ -230,6 +230,9 @@ async function renderModules() {
if (modList.error) modList = [];
} catch(e) {}
// Lizenz-Gate: ohne gültige Lizenz sind die Sims nicht startbar (Hinweis oben).
var licensed = !modList.length || modList[0].licensed !== false;
// Lehrer-gesteuerte Auftraege zuerst — erledigte Auftraege fallen automatisch
// in die regulaere Modul-Liste, damit der Auftrag aus dem Cockpit verschwindet,
// das Modul aber spielbar bleibt.
@@ -257,7 +260,7 @@ async function renderModules() {
s += '<h3 style="color:#c25e1e">'+(m.icon||'📚')+' '+m.name+(m.forcedLevel ? ' <span style="font-weight:600;color:#9a9a9a;font-size:.82rem">· Stufe '+m.forcedLevel+'</span>' : '')+'</h3>';
s += '<p>Auftrag von deiner Lehrperson'+(meta.length ? '<br><span style="color:#8a8a8a">'+meta.join(' · ')+'</span>' : '')+'</p>';
s += '<div class="mod-actions">';
s += '<a href="'+url+'" class="mod-btn primary" style="background:#e8833a;border-color:#e8833a">▶ Jetzt starten</a>';
s += licensed ? '<a href="'+url+'" class="mod-btn primary" style="background:#e8833a;border-color:#e8833a">▶ Jetzt starten</a>' : '<span class="mod-btn primary" style="background:#c9a99a;border-color:#c9a99a;opacity:.7;pointer-events:none">🔑 Lizenz nötig</span>';
s += '</div></div></div>';
return s;
}
@@ -293,7 +296,7 @@ async function renderModules() {
s += '<h3>'+(m.icon||'📚')+' '+m.name+'</h3>';
s += '<p>'+(m.desc||'')+'</p>';
s += '<div class="mod-actions">';
if (isSpielbar) s += '<a href="'+url+'" class="mod-btn primary">▶ Starten</a>';
if (isSpielbar) s += licensed ? '<a href="'+url+'" class="mod-btn primary">▶ Starten</a>' : '<span class="mod-btn primary" style="opacity:.5;pointer-events:none" title="Deine Lehrperson muss dir eine Lizenz zuweisen">🔑 Lizenz nötig</span>';
s += '<a href="'+infoHref+'" class="mod-btn" title="Mehr Infos">📖 Infos</a>';
if (!isComing) s += '<a href="'+glossarHref+'" class="mod-btn" title="Glossar zu diesem Modul">📚 Glossar</a>';
if (buchHref) s += '<a href="'+buchHref+'" class="mod-btn" title="Schulbuch-Kapitel zu diesem Modul">📘 Schulbuch</a>';
@@ -323,6 +326,13 @@ async function renderModules() {
html += '</div>';
}
if (!licensed) {
html = '<div style="background:#fdece9;border:1px solid #f3c9c1;border-left:5px solid #c0392b;border-radius:12px;padding:1rem 1.1rem;margin-bottom:1.2rem;color:#7a2318">'
+ '<div style="font-size:1.4rem;margin-bottom:.15rem">🔑</div>'
+ '<div style="font-weight:800;font-size:.98rem;margin-bottom:.25rem">Noch keine Lizenz</div>'
+ '<div style="font-size:.84rem;line-height:1.55">Deine Lehrperson muss dir noch eine gültige Lizenz zuweisen. Sobald das erledigt ist, kannst du die Simulationen starten.</div>'
+ '</div>' + html;
}
document.getElementById('tab-modules').innerHTML = html;
}
+61
View File
@@ -209,6 +209,9 @@
<button class="btn-sm" onclick="openTickets()">🎫 Tickets</button>
</div>
<!-- Lizenz-Warnung (Schüler:innen ohne gültige Lizenz) -->
<div id="license-warning" style="display:none"></div>
<!-- Tabs -->
<div class="tabs" id="tab-bar">
<div class="tab on" onclick="switchTab('overview')">Übersicht</div>
@@ -2801,6 +2804,7 @@ async function loadClasses() {
if (validTabs[initialTab]) currentTab = initialTab;
loadTab(currentTab);
if (validTabs[initialTab]) switchTab(initialTab);
checkLicenseWarning();
window.addEventListener('hashchange', function() {
var t = (location.hash || '').replace('#','');
if (validTabs[t]) switchTab(t);
@@ -2815,6 +2819,26 @@ function onClassChange() {
document.getElementById('class-code').textContent = currentClass?.join_code || '—';
loadTab(currentTab);
startLiveStatusPoll();
checkLicenseWarning();
}
// Rote Cockpit-Warnung: Schüler:innen der Klasse ohne gültige Lizenz.
async function checkLicenseWarning() {
var el = document.getElementById('license-warning');
if (!el) return;
if (!currentClassId) { el.style.display='none'; return; }
try {
var r = await api('licenses?class_id=' + currentClassId);
var n = (r && r.unlicensed) ? r.unlicensed.length : 0;
if (n > 0) {
el.innerHTML = '<div style="background:#fdece9;border-bottom:2px solid #c0392b;color:#7a2318;padding:.6rem 1rem;font-size:.8rem;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap">'
+ '<span style="font-size:1.1rem">🔑</span>'
+ '<span><strong>'+n+' '+(n===1?'Schüler:in hat':'Schüler:innen haben')+' noch keine gültige Lizenz</strong> — ohne Lizenz können sie keine Simulationen nutzen.</span>'
+ '<button class="btn-sm" style="margin-left:auto" onclick="switchTab(\'licenses\')">Jetzt zuweisen →</button>'
+ '</div>';
el.style.display = 'block';
} else { el.style.display='none'; el.innerHTML=''; }
} catch(e) { el.style.display='none'; }
}
async function saveClass() {
@@ -5428,6 +5452,25 @@ async function loadLicenses() {
html += '<button class="btn" onclick="showModal(\'modal-license\')" style="margin-bottom:.8rem">🔑 Lizenzen einlösen</button>';
html += '</div>';
// Ohne gültige Lizenz — direkt zuweisbar
var unlic = r.unlicensed || [];
html += '<div class="card"><div class="card-h">Ohne gültige Lizenz ('+unlic.length+')</div>';
if (unlic.length > 0) {
var freeN = r.free ? r.free.length : 0;
html += '<p style="font-size:.72rem;color:#7a2318;margin-bottom:.5rem">Diese Schüler:innen können noch keine Simulationen nutzen.'+(freeN < unlic.length ? ' <b>Nur '+freeN+' freie Lizenz(en) verfügbar.</b>' : '')+'</p>';
if (freeN > 0) html += '<button class="btn" style="margin-bottom:.6rem" onclick=\'assignAllUnlicensed('+JSON.stringify(unlic.map(function(s){return s.id;}))+')\'>Allen eine freie Lizenz zuweisen</button>';
html += '<table class="students"><tr><th>Schüler*in</th><th></th></tr>';
unlic.forEach(function(s) {
html += '<tr><td>'+esc(s.display_name||s.username)+'</td><td>'
+ (freeN>0 ? '<button class="btn-sm" onclick="assignLicenseToStudent('+s.id+')">Lizenz zuweisen</button>' : '<span style="color:#8a8a8a;font-size:.68rem">keine freie Lizenz</span>')
+ '</td></tr>';
});
html += '</table>';
} else {
html += '<p style="font-size:.72rem;color:#5a8a5e">Alle Schüler:innen haben eine gültige Lizenz. ✓</p>';
}
html += '</div>';
// Zugewiesene Lizenzen
html += '<div class="card"><div class="card-h">Zugewiesene Lizenzen</div>';
if (r.assigned && r.assigned.length > 0) {
@@ -5458,6 +5501,24 @@ async function loadLicenses() {
html += '</div>';
document.getElementById('tab-licenses').innerHTML = html;
checkLicenseWarning();
}
async function assignLicenseToStudent(studentId) {
var r = await api('licenses', {action:'auto_assign', studentId:studentId});
if (r.error) { alert(r.error); return; }
loadLicenses();
}
async function assignAllUnlicensed(ids) {
if (!ids || !ids.length) return;
if (!confirm(ids.length + ' Schüler:innen je eine freie Lizenz zuweisen?')) return;
var failed = null;
for (var i = 0; i < ids.length; i++) {
var r = await api('licenses', {action:'auto_assign', studentId:ids[i]});
if (r.error) { failed = r.error; break; }
}
if (failed) alert('Nicht alle konnten zugewiesen werden: ' + failed);
loadLicenses();
}
// === BULK SELECTION ===