Lizenz-Vertrieb Feinschliff: Zugabe +10%, sichere Datei-Einloesung, Mail-Config
Umsetzung des Thomas-Feedbacks: - Reihenfolge: freie Lizenzen beim Lehrer jetzt ORDER BY id (= Reihenfolge der reinkopierten Liste); stornierte ausgeblendet. - Gratis-Zugabe: Checkbox (Default an) "+10 % aufgerundet" -> geliefert = ceil(bestellt*1.1) (14 -> 16). Batch speichert qty_ordered (verrechnet) und qty (geliefert); Live-Vorschau im Formular. - Einloeselink/QR ENTFERNT (Sicherheitsrisiko: falscher eingeloggter Account). einloesen.html + pages/einloesen.php geloescht, Sammellink aus UI/Mail raus. - CSV: Gueltig-bis + Metadaten nur in der KOPFZEILE, danach reine Code-Zeilen (per Copy-Paste einloesbar). Dateiname: <ExtBestellnr|Datum>_geograsim_<intNr>. - Interne Bestellnummer = batch.id (laufend, nicht editierbar, storno-stabil). - Lehrer-Einloesen: sichere .txt/.csv-Ablage (mehrstufig: Endung+MIME, Groessen- limit, nur Text lesen, Whitelist-Regex extrahiert nur Code-Tokens, Server validiert) — kein Upload, keine Ausfuehrung/Rendering. - Mailsystem: zentrale Config in neuer app_settings (Provider server|aws, Absender-Name/-Adresse, oeffentliche URL), pflegbar in Admin + Test-Versand. Mailer liest Absender aus Config (SMTP-Zugang bleibt in .env). Mailtext nutzt kanonische geograsim.at (nicht v3/localhost). Migrationen: -2 (qty_ordered), -3 (app_settings). Beide additiv, auf v3 angewandt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
-- =====================================================================
|
||||
-- Lizenz-Vertrieb — Nachtrag 2 (2026-08-26)
|
||||
-- * bestellte vs. gelieferte Menge (Zugabe +10 %)
|
||||
-- Additiv & non-breaking. (Mail-Config: siehe Nachtrag 3, app_settings.)
|
||||
-- =====================================================================
|
||||
|
||||
ALTER TABLE license_batches
|
||||
ADD COLUMN qty_ordered INT UNSIGNED NULL AFTER qty; -- bestellt (verrechnet); qty = geliefert inkl. Zugabe
|
||||
@@ -0,0 +1,19 @@
|
||||
-- =====================================================================
|
||||
-- Lizenz-Vertrieb — Nachtrag 3 (2026-08-26): portable App-Settings
|
||||
-- Eigene Key/Value-Tabelle fuer die Mail-Konfiguration (Provider/Absender/URL).
|
||||
-- Ersetzt die Nutzung der lokalen Orphan-Tabelle platform_config_v2 (existiert
|
||||
-- auf v3/Prod nicht). Additiv & non-breaking. MariaDB + MySQL 8 kompatibel.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
skey VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
sval TEXT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
updated_by INT UNSIGNED NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO app_settings (skey, sval) VALUES
|
||||
('mail_provider', 'server'),
|
||||
('mail_from_email', 'support@geograsim.at'),
|
||||
('mail_from_name', 'GeoGraSim'),
|
||||
('mail_public_url', 'https://geograsim.at');
|
||||
+105
-19
@@ -128,8 +128,8 @@
|
||||
<div class="card-h">📦 Lizenzen ausgeben (Vertrieb)</div>
|
||||
<div class="frm">
|
||||
<div>
|
||||
<label>Anzahl</label>
|
||||
<input type="number" id="iss-count" value="12" min="1" max="5000">
|
||||
<label>Anzahl (bestellt)</label>
|
||||
<input type="number" id="iss-count" value="12" min="1" max="5000" oninput="updatePreview()">
|
||||
</div>
|
||||
<div>
|
||||
<label>Typ</label>
|
||||
@@ -168,6 +168,12 @@
|
||||
</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">
|
||||
<input type="checkbox" id="iss-bonus" checked onchange="updatePreview()"> +10 % Gratis-Zugabe (aufgerundet)
|
||||
</label>
|
||||
<span id="iss-preview" class="mini"></span>
|
||||
</div>
|
||||
<div style="margin-top:.7rem" class="row-act">
|
||||
<button class="btn" onclick="issueBatch()">Codes erzeugen & ausgeben</button>
|
||||
<span id="iss-msg" class="mini"></span>
|
||||
@@ -192,6 +198,41 @@
|
||||
<div id="batch-table" style="overflow-x:auto"></div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Mail-Einstellungen ============ -->
|
||||
<div class="card">
|
||||
<div class="card-h">✉️ Mail-Einstellungen</div>
|
||||
<div class="frm">
|
||||
<div>
|
||||
<label>Versand-Treiber</label>
|
||||
<select id="mc-provider">
|
||||
<option value="server">Eigener Server (SMTP)</option>
|
||||
<option value="aws">Amazon SES (später)</option>
|
||||
</select>
|
||||
<div class="hint-s">AWS wird aktiv, sobald konfiguriert.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Absender-Name</label>
|
||||
<input type="text" id="mc-fromname" placeholder="GeoGraSim">
|
||||
</div>
|
||||
<div>
|
||||
<label>Absender-Adresse</label>
|
||||
<input type="email" id="mc-fromemail" placeholder="support@geograsim.at">
|
||||
<div class="hint-s">SMTP-Zugang bleibt geheim in der .env.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Öffentliche Basis-URL</label>
|
||||
<input type="text" id="mc-publicurl" placeholder="https://geograsim.at">
|
||||
<div class="hint-s">Für Links in Mails (nicht v3/localhost).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-act" style="margin-top:.6rem">
|
||||
<button class="btn" onclick="saveMailConfig()">Speichern</button>
|
||||
<input type="email" id="mc-testto" placeholder="Test an…" style="padding:.4rem .55rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem">
|
||||
<button class="btn btn-outline" onclick="testMail()">Test senden</button>
|
||||
<span id="mc-msg" class="mini"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">Neue Lizenzen generieren</div>
|
||||
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap">
|
||||
@@ -409,16 +450,18 @@ async function issueBatch(){
|
||||
var name = document.getElementById('iss-name').value.trim();
|
||||
var vf = document.getElementById('iss-from').value;
|
||||
var vu = document.getElementById('iss-until').value;
|
||||
var bonus = document.getElementById('iss-bonus').checked;
|
||||
var msg = document.getElementById('iss-msg');
|
||||
if(count<1||count>5000){ msg.textContent='Anzahl 1–5000.'; return; }
|
||||
var delivered = calcDelivered(count, bonus);
|
||||
var who = name||email||custno||'(kein Kunde)';
|
||||
if(count>=100 && !confirm(count+' Codes für „'+who+'"'+(order?' (Best.-Nr. '+order+')':'')+', gültig bis '+fmtDate(vu)+', Typ '+type+'.\n\nWirklich erzeugen?')) return;
|
||||
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, sales_type:type, order_no:order,
|
||||
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});
|
||||
if(r.error){ msg.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
|
||||
msg.innerHTML='<span style="color:#5a8a5e">✓ '+r.count+' Codes erzeugt (Charge #'+r.batch_id+').</span>';
|
||||
lastIssue = {batch_id:r.batch_id, codes:r.codes, count:r.count, valid_until:r.valid_until, token:r.redeem_token, order_no:order, email:email};
|
||||
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};
|
||||
renderIssueResult();
|
||||
loadBatches();
|
||||
loadLicenses();
|
||||
@@ -429,17 +472,15 @@ function renderIssueResult(){
|
||||
var codesText = m.codes.join('\n');
|
||||
var mailText = buildMailText(m);
|
||||
var h = '<div class="out">'
|
||||
+ '<div class="mini" style="margin-bottom:.5rem">Bestellung <b>#'+m.internal_no+'</b> · bestellt <b>'+m.ordered+'</b> · geliefert <b>'+m.count+'</b>'+(m.count>m.ordered?' <span style="color:#5a8a5e">(+'+(m.count-m.ordered)+' gratis)</span>':'')+' · gültig bis '+fmtDate(m.valid_until)+'</div>'
|
||||
+ '<h4>1 · Codes (' + m.count + ') — kopieren</h4>'
|
||||
+ '<textarea id="codes-ta" rows="'+Math.min(10,Math.max(3,m.codes.length))+'" readonly>'+esc(codesText)+'</textarea>'
|
||||
+ '<div class="row-act" style="margin-top:.4rem">'
|
||||
+ '<button class="btn btn-outline" onclick="copyEl(\'codes-ta\')">📋 Kopieren</button>'
|
||||
+ '<button class="btn btn-outline" onclick="downloadCodes(\'txt\')">⬇ .txt</button>'
|
||||
+ '<button class="btn btn-outline" onclick="downloadCodes(\'csv\')">⬇ .csv (mit Gültig-bis)</button>'
|
||||
+ '<button class="btn btn-outline" onclick="downloadCodes(\'csv\')">⬇ .csv</button>'
|
||||
+ '</div>'
|
||||
+ '<h4 style="margin-top:.9rem">2 · Sammel-Einlöse-Link (alle auf einmal)</h4>'
|
||||
+ '<div class="row-act"><input class="lnk" id="charge-link" readonly value="'+esc(absUrl('/einloesen?charge='+m.token))+'"><button class="btn btn-outline" onclick="copyEl(\'charge-link\')">📋</button></div>'
|
||||
+ '<div class="hint-s">Pro Code funktioniert auch: '+esc(absUrl('/einloesen?code=CODE'))+'</div>'
|
||||
+ '<h4 style="margin-top:.9rem">3 · Per Mail senden</h4>'
|
||||
+ '<h4 style="margin-top:.9rem">2 · Per Mail senden</h4>'
|
||||
+ '<div class="frm" style="grid-template-columns:1fr 2fr">'
|
||||
+ '<div><label>An</label><input type="email" id="mail-to" value="'+esc(m.email||'')+'"></div>'
|
||||
+ '<div><label>Betreff</label><input type="text" id="mail-subj" value="Ihre GeoGraSim-Lizenzcodes'+(m.order_no?' — Best.-Nr. '+esc(m.order_no):'')+'"></div>'
|
||||
@@ -457,20 +498,65 @@ function buildMailText(m){
|
||||
var order = m.order_no ? ('\nBestellnummer: '+m.order_no) : '';
|
||||
return 'Guten Tag,\n\nvielen Dank für Ihre Bestellung. Anbei Ihre '+m.count+' GeoGraSim-Lizenzcodes, gültig bis '+fmtDate(m.valid_until)+'.'+order+'\n\n'
|
||||
+ codesPart + '\n\n'
|
||||
+ 'Einlösen: Als Lehrperson unter '+absUrl('/')+' anmelden und die Codes unter „Lizenzen" eingeben.\n'
|
||||
+ 'Oder alle auf einmal über diesen Link: '+absUrl('/einloesen?charge='+m.token)+'\n\n'
|
||||
+ 'Einlösen: Als Lehrperson unter '+PUBLIC_URL+' anmelden und die Codes unter „Lizenzen" eingeben.\n\n'
|
||||
+ 'Freundliche Grüße\nGeoGraSim';
|
||||
}
|
||||
|
||||
function copyEl(id){ var el=document.getElementById(id); el.select(); navigator.clipboard.writeText(el.value).catch(function(){document.execCommand('copy');}); }
|
||||
|
||||
// CSV: Metadaten (inkl. Gültig-bis) in die KOPFZEILE, danach reine Code-Zeilen
|
||||
// -> der Code-Block bleibt per Copy-Paste einlösbar (kein Datum je Zeile).
|
||||
function downloadCodes(fmt){
|
||||
var m = lastIssue; if(!m) return;
|
||||
var content, mime;
|
||||
if(fmt==='csv'){ content='Code;Gueltig_bis\n'+m.codes.map(function(c){return c+';'+m.valid_until;}).join('\n')+'\n'; mime='text/csv'; }
|
||||
else { content=m.codes.join('\r\n')+'\r\n'; mime='text/plain'; }
|
||||
var base = m.order_no ? m.order_no.replace(/[^a-zA-Z0-9_\-]/g,'_') : (new Date().toISOString().slice(0,16).replace(/[:T]/g,'-'));
|
||||
var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([content],{type:mime})); a.download=base+'.'+fmt; a.click();
|
||||
if(fmt==='csv'){
|
||||
var head='Bestellung;#'+(m.internal_no||'')+'\nExterne Bestellnummer;'+(m.order_no||'')+'\nGültig bis;'+fmtDate(m.valid_until)+'\nAnzahl;'+m.count+'\n\n';
|
||||
content=head+m.codes.join('\r\n')+'\r\n'; mime='text/csv';
|
||||
} else { content=m.codes.join('\r\n')+'\r\n'; mime='text/plain'; }
|
||||
var ext = m.order_no ? m.order_no.replace(/[^a-zA-Z0-9_\-]/g,'_') : (new Date().toISOString().slice(0,16).replace(/[:T]/g,'-'));
|
||||
var fname = ext+'_geograsim_'+(m.internal_no||'')+'.'+fmt;
|
||||
var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([content],{type:mime})); a.download=fname; a.click();
|
||||
}
|
||||
|
||||
function calcDelivered(ordered, bonus){ return bonus ? Math.ceil(ordered*1.1) : ordered; }
|
||||
function updatePreview(){
|
||||
var o=parseInt(document.getElementById('iss-count').value)||0;
|
||||
var b=document.getElementById('iss-bonus').checked;
|
||||
var el=document.getElementById('iss-preview'); if(!el) return;
|
||||
if(o<1){ el.textContent=''; return; }
|
||||
var d=calcDelivered(o,b);
|
||||
el.innerHTML = b ? ('→ im Paket <b>'+d+'</b> Codes (+'+(d-o)+' gratis)') : ('→ <b>'+d+'</b> Codes (keine Zugabe)');
|
||||
}
|
||||
|
||||
// ── Mail-Einstellungen ──
|
||||
var PUBLIC_URL='https://geograsim.at';
|
||||
async function loadMailConfig(){
|
||||
var r=await api('licenses?admin=1&mail_config=1');
|
||||
if(!r || r.error) return;
|
||||
PUBLIC_URL=r.public_url||PUBLIC_URL;
|
||||
document.getElementById('mc-provider').value=r.provider||'server';
|
||||
document.getElementById('mc-fromname').value=r.from_name||'';
|
||||
document.getElementById('mc-fromemail').value=r.from_email||'';
|
||||
document.getElementById('mc-publicurl').value=r.public_url||'';
|
||||
}
|
||||
async function saveMailConfig(){
|
||||
var mm=document.getElementById('mc-msg'); mm.textContent='Speichere …';
|
||||
var r=await api('licenses',{action:'set_mail_config',
|
||||
provider:document.getElementById('mc-provider').value,
|
||||
from_name:document.getElementById('mc-fromname').value.trim(),
|
||||
from_email:document.getElementById('mc-fromemail').value.trim(),
|
||||
public_url:document.getElementById('mc-publicurl').value.trim()});
|
||||
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
|
||||
mm.innerHTML='<span style="color:#5a8a5e">✓ Gespeichert.</span>'; loadMailConfig();
|
||||
}
|
||||
async function testMail(){
|
||||
var to=document.getElementById('mc-testto').value.trim();
|
||||
var mm=document.getElementById('mc-msg');
|
||||
if(!to){ mm.innerHTML='<span style="color:#c07a6b">Test-Adresse fehlt.</span>'; return; }
|
||||
mm.textContent='Sende Test …';
|
||||
var r=await api('licenses',{action:'test_mail', to_email:to});
|
||||
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
|
||||
mm.innerHTML='<span style="color:#5a8a5e">✓ Test gesendet.</span>';
|
||||
}
|
||||
|
||||
async function sendMail(batchId){
|
||||
@@ -542,8 +628,6 @@ async function openBatch(id){
|
||||
+ ' · gültig '+fmtDate(b.default_valid_from)+'–'+fmtDate(b.default_valid_until)
|
||||
+ (b.canceled_at?' · <span class="badge b-canceled">Charge storniert</span>':'')+'</div>';
|
||||
h+='<div class="row-act" style="margin-bottom:.6rem">'
|
||||
+ '<input class="lnk" readonly value="'+esc(absUrl('/einloesen?charge='+b.redeem_token))+'">'
|
||||
+ '<button class="btn-sm" onclick="copyEl2(this)">📋 Sammellink</button>'
|
||||
+ '<button class="btn-sm" onclick="extendBatch('+b.id+',\''+esc(b.default_valid_until||'')+'\')">📅 Charge verlängern</button>'
|
||||
+ '<button class="btn-sm" onclick="cancelBatch('+b.id+')">✖ Charge stornieren</button></div>';
|
||||
h+='<div style="max-height:320px;overflow:auto"><table><tr><th>Code</th><th>Status</th><th>Gültig bis</th><th>Lehrperson</th><th></th></tr>';
|
||||
@@ -608,6 +692,8 @@ fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/
|
||||
loadLicenses();
|
||||
prefillDefaults();
|
||||
loadBatches();
|
||||
loadMailConfig();
|
||||
updatePreview();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Lizenz einlösen — GeoGraSim</title>
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:#f0eee6;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1.5rem;color:#2a2a2a}
|
||||
.card{background:#fff;border-radius:18px;max-width:480px;width:100%;padding:2.2rem 2rem;box-shadow:0 6px 30px rgba(31,78,90,.1);border:1px solid #e6e0d0;text-align:center}
|
||||
.wm{font-family:'Arial Black','Helvetica Neue',Arial,sans-serif;font-weight:900;font-size:26px;letter-spacing:-.02em;margin-bottom:.3rem}
|
||||
.wm .g{color:#143b4b}.wm .r{color:#1d707b}.wm .a{color:#f17f13}
|
||||
.sub{font-size:.62rem;font-weight:700;letter-spacing:.28em;color:#83a3a0;text-transform:uppercase;margin-bottom:1.6rem}
|
||||
h1{font-size:1.2rem;color:#1f4e5a;margin-bottom:.6rem}
|
||||
p{font-size:.9rem;color:#4a5a5f;line-height:1.6;margin-bottom:1rem}
|
||||
.big{font-size:2.6rem;font-weight:900;color:#5a8a5e;line-height:1;margin:.4rem 0}
|
||||
.btn{display:inline-block;padding:.7rem 1.6rem;background:#4a7c8a;color:#fff;text-decoration:none;border-radius:9px;font-weight:700;font-size:.9rem;border:none;cursor:pointer;margin-top:.4rem}
|
||||
.btn:hover{background:#3a6470}
|
||||
.spin{width:34px;height:34px;border:3px solid #dfe8ea;border-top-color:#4a7c8a;border-radius:50%;animation:sp 1s linear infinite;margin:1rem auto}
|
||||
@keyframes sp{to{transform:rotate(360deg)}}
|
||||
.err{color:#b0453a}
|
||||
.foot{font-size:.72rem;color:#9aa5a9;margin-top:1.4rem}
|
||||
.code{font-family:'Courier New',monospace;font-weight:700;color:#1f4e5a}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="wm"><span class="g">Geo</span><span class="r">Gra</span><span class="a">Sim</span></div>
|
||||
<div class="sub">Lizenz einlösen</div>
|
||||
<div id="content"><div class="spin"></div></div>
|
||||
<div class="foot">Bei Fragen: <a href="mailto:support@geograsim.at" style="color:#4a7c8a">support@geograsim.at</a></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var G = window.__GGS__ || {};
|
||||
var BASE = G.basePath || '';
|
||||
var content = document.getElementById('content');
|
||||
var params = new URLSearchParams(location.search);
|
||||
var charge = params.get('charge');
|
||||
var code = params.get('code');
|
||||
|
||||
function html(h){ content.innerHTML = h; }
|
||||
function loginPrompt(){
|
||||
html('<h1>Bitte anmelden</h1>'
|
||||
+ '<p>Melde dich als <b>Lehrperson</b> an, um '+(charge?'diese Lizenzen':'diesen Lizenzcode')+' einzulösen. Öffne danach diesen Link erneut.</p>'
|
||||
+ '<a class="btn" href="'+BASE+'/login">Als Lehrperson anmelden</a>');
|
||||
}
|
||||
|
||||
if (!charge && !code) { html('<h1>Ungültiger Link</h1><p>Es fehlt ein Lizenzcode oder eine Charge.</p>'); return; }
|
||||
if (!G.teacherId) { loginPrompt(); return; }
|
||||
|
||||
var body = charge ? {action:'redeem_charge', token:charge} : {action:'redeem', code:code};
|
||||
fetch(BASE + '/api/licenses', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify(body)})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(r){
|
||||
if (r.error) {
|
||||
html('<h1 class="err">Nicht eingelöst</h1><p class="err">'+String(r.error)+'</p>'
|
||||
+ '<a class="btn" href="'+BASE+'/teacher">Zum Dashboard</a>');
|
||||
return;
|
||||
}
|
||||
if (charge) {
|
||||
var n = r.redeemed || 0;
|
||||
html('<h1>Geschafft!</h1><div class="big">'+n+'</div>'
|
||||
+ '<p>'+(n===1?'Lizenz wurde':'Lizenzen wurden')+' deinem Konto gutgeschrieben. Du kannst sie jetzt im Dashboard deinen Schüler:innen zuweisen.</p>'
|
||||
+ '<a class="btn" href="'+BASE+'/teacher">Zum Dashboard</a>');
|
||||
} else {
|
||||
html('<h1>Code eingelöst ✓</h1>'
|
||||
+ '<p>Der Lizenzcode <span class="code">'+String(code)+'</span> wurde deinem Konto gutgeschrieben.</p>'
|
||||
+ '<a class="btn" href="'+BASE+'/teacher">Zum Dashboard</a>');
|
||||
}
|
||||
})
|
||||
.catch(function(){
|
||||
html('<h1 class="err">Fehler</h1><p>Verbindung fehlgeschlagen. Bitte später erneut versuchen.</p>');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
<?php renderPage('einloesen.html');
|
||||
+65
-11
@@ -87,6 +87,14 @@ function lic_attempt(Database $db, ?int $teacherId, string $code, bool $ok): voi
|
||||
[$ip !== '' ? $ip : null, $teacherId, substr($code, 0, 32), $ok ? 1 : 0]);
|
||||
}
|
||||
|
||||
/** App-Setting lesen (app_settings), robust mit Default. */
|
||||
function lic_cfg(Database $db, string $key, string $default = ''): string {
|
||||
try {
|
||||
$r = $db->fetchOne("SELECT sval FROM app_settings WHERE skey = ?", [$key]);
|
||||
return ($r && $r['sval'] !== null && $r['sval'] !== '') ? (string)$r['sval'] : $default;
|
||||
} catch (\Throwable $e) { return $default; }
|
||||
}
|
||||
|
||||
// === GET ===
|
||||
if ($method === 'GET') {
|
||||
// Super-Admin: alle Lizenzen
|
||||
@@ -105,6 +113,16 @@ if ($method === 'GET') {
|
||||
]);
|
||||
}
|
||||
|
||||
// Mail-System-Konfiguration
|
||||
if (isset($_GET['mail_config'])) {
|
||||
Response::ok([
|
||||
'provider' => lic_cfg($db, 'mail_provider', 'server'),
|
||||
'from_email' => lic_cfg($db, 'mail_from_email', 'support@geograsim.at'),
|
||||
'from_name' => lic_cfg($db, 'mail_from_name', 'GeoGraSim'),
|
||||
'public_url' => lic_cfg($db, 'mail_public_url', 'https://geograsim.at'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Kunden-Autocomplete
|
||||
if (isset($_GET['customers'])) {
|
||||
$like = '%' . trim($_GET['customers']) . '%';
|
||||
@@ -214,9 +232,10 @@ if ($method === 'GET') {
|
||||
[$classId]
|
||||
);
|
||||
|
||||
// Auch freie Lizenzen des Lehrers (noch nicht zugewiesen)
|
||||
// Auch freie Lizenzen des Lehrers (noch nicht zugewiesen), storniert ausgeschlossen.
|
||||
// Reihenfolge = id (= Reihenfolge, in der die Codes ausgegeben/reinkopiert wurden).
|
||||
$freeLicenses = $db->fetchAll(
|
||||
'SELECT id, code, school_year FROM licenses WHERE teacher_id = ? AND student_id IS NULL ORDER BY code',
|
||||
'SELECT id, code, school_year FROM licenses WHERE teacher_id = ? AND student_id IS NULL AND canceled_at IS NULL ORDER BY id',
|
||||
[$teacherId]
|
||||
);
|
||||
|
||||
@@ -348,8 +367,11 @@ if ($method === 'POST') {
|
||||
$adminId = Session::adminId();
|
||||
if (!$adminId) Response::error('Nur Admin', 403);
|
||||
|
||||
$count = (int)($body['count'] ?? 0);
|
||||
if ($count < 1 || $count > 5000) Response::error('Anzahl muss zwischen 1 und 5000 liegen');
|
||||
$ordered = (int)($body['count'] ?? 0);
|
||||
if ($ordered < 1 || $ordered > 5000) Response::error('Anzahl muss zwischen 1 und 5000 liegen');
|
||||
// Gratis-Zugabe: +10 %, aufgerundet (14 → 16). Standard aktiv.
|
||||
$bonus = !array_key_exists('bonus', $body) || !empty($body['bonus']);
|
||||
$count = $bonus ? (int)ceil($ordered * 1.1) : $ordered;
|
||||
$type = in_array($body['sales_type'] ?? '', LIC_TYPES, true) ? $body['sales_type'] : 'privat';
|
||||
$orderNo = trim((string)($body['order_no'] ?? '')) ?: null;
|
||||
$note = trim((string)($body['note'] ?? '')) ?: null;
|
||||
@@ -366,9 +388,9 @@ if ($method === 'POST') {
|
||||
$db->begin();
|
||||
try {
|
||||
$db->execute(
|
||||
"INSERT INTO license_batches (customer_id, order_no, sales_type, qty, default_valid_from, default_valid_until, note, redeem_token, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
[$customerId, $orderNo, $type, $count, $validFrom, $validUntil, $note, $token, $adminId]);
|
||||
"INSERT INTO license_batches (customer_id, order_no, sales_type, qty, qty_ordered, default_valid_from, default_valid_until, note, redeem_token, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
[$customerId, $orderNo, $type, $count, $ordered, $validFrom, $validUntil, $note, $token, $adminId]);
|
||||
$batchId = (int)$db->lastInsertId();
|
||||
|
||||
$codes = []; $made = 0; $tries = 0; $maxTries = $count * 8 + 50;
|
||||
@@ -383,16 +405,16 @@ if ($method === 'POST') {
|
||||
} catch (\Throwable $e) { /* Code-Kollision (extrem selten) → neuer Versuch */ }
|
||||
}
|
||||
if ($made < $count) throw new \RuntimeException("Nur $made von $count Codes erzeugt");
|
||||
lic_event($db, $batchId, null, 'issued', "$count Codes, gültig $validFrom…$validUntil, Typ $type" . ($orderNo ? ", Best.-Nr. $orderNo" : ''), $adminId);
|
||||
lic_event($db, $batchId, null, 'issued', "bestellt $ordered, geliefert $made (Zugabe " . ($bonus ? 'ja' : 'nein') . "), gültig $validFrom…$validUntil, Typ $type" . ($orderNo ? ", Best.-Nr. $orderNo" : ''), $adminId);
|
||||
$db->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->rollBack();
|
||||
Response::error('Fehler beim Erzeugen: ' . $e->getMessage(), 500);
|
||||
}
|
||||
Response::ok([
|
||||
'batch_id' => $batchId, 'redeem_token' => $token,
|
||||
'batch_id' => $batchId, 'internal_no' => $batchId, 'redeem_token' => $token,
|
||||
'valid_from' => $validFrom, 'valid_until' => $validUntil,
|
||||
'count' => $made, 'codes' => $codes,
|
||||
'ordered' => $ordered, 'count' => $made, 'codes' => $codes,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -444,6 +466,8 @@ if ($method === 'POST') {
|
||||
if ($action === 'send_batch_mail') {
|
||||
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
|
||||
$bid = (int)($body['batch_id'] ?? 0); if (!$bid) Response::error('batch_id erforderlich');
|
||||
$provider = lic_cfg($db, 'mail_provider', 'server');
|
||||
if ($provider !== 'server') Response::error('Aktiver Mail-Treiber „' . $provider . '" ist noch nicht verfügbar. In den Mail-Einstellungen auf „server" stellen.');
|
||||
$to = trim((string)($body['to_email'] ?? ''));
|
||||
$subject = trim((string)($body['subject'] ?? '')) ?: 'Ihre GeoGraSim-Lizenzcodes';
|
||||
$textBody = (string)($body['body_text'] ?? '');
|
||||
@@ -459,7 +483,7 @@ if ($method === 'POST') {
|
||||
}
|
||||
$db->execute(
|
||||
"INSERT INTO license_mails (batch_id, to_email, subject, body_text, body_html, provider, status, created_by) VALUES (?,?,?,?,?,?,?,?)",
|
||||
[$bid, $to, $subject, $textBody, $html, 'server', 'queued', $adminId]);
|
||||
[$bid, $to, $subject, $textBody, $html, $provider, 'queued', $adminId]);
|
||||
$mailId = (int)$db->lastInsertId();
|
||||
try {
|
||||
Mailer::sendWithAttachments($to, $subject, $html, $textBody, $attach);
|
||||
@@ -489,6 +513,36 @@ if ($method === 'POST') {
|
||||
Response::ok(['redeemed' => $n]);
|
||||
}
|
||||
|
||||
// ═══════════════ Mail-System-Konfiguration ═══════════════
|
||||
if ($action === 'set_mail_config') {
|
||||
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
|
||||
$prov = in_array($body['provider'] ?? '', ['server', 'aws'], true) ? $body['provider'] : 'server';
|
||||
$femail = trim((string)($body['from_email'] ?? ''));
|
||||
$fname = trim((string)($body['from_name'] ?? ''));
|
||||
$purl = rtrim(trim((string)($body['public_url'] ?? '')), '/');
|
||||
if ($femail !== '' && !filter_var($femail, FILTER_VALIDATE_EMAIL)) Response::error('Absender-Adresse ungültig');
|
||||
$set = ['mail_provider' => $prov, 'mail_from_email' => $femail, 'mail_from_name' => $fname, 'mail_public_url' => $purl];
|
||||
foreach ($set as $k => $v) {
|
||||
if ($v === '') continue;
|
||||
$db->execute("INSERT INTO app_settings (skey, sval, updated_by) VALUES (?,?,?) ON DUPLICATE KEY UPDATE sval = VALUES(sval), updated_by = VALUES(updated_by)", [$k, $v, $adminId]);
|
||||
}
|
||||
Response::ok();
|
||||
}
|
||||
|
||||
if ($action === 'test_mail') {
|
||||
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
|
||||
$to = trim((string)($body['to_email'] ?? ''));
|
||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) Response::error('Gültige Test-Adresse erforderlich');
|
||||
$provider = lic_cfg($db, 'mail_provider', 'server');
|
||||
if ($provider !== 'server') Response::error('Aktiver Treiber „' . $provider . '" ist noch nicht verfügbar. Auf „server" stellen.');
|
||||
try {
|
||||
Mailer::sendWithAttachments($to, 'GeoGraSim — Test-Mail',
|
||||
Mailer::renderPlainBody("Test-Mail aus der Lizenz-Verwaltung.\nAbsender und Versand funktionieren."),
|
||||
'Test-Mail — Versand funktioniert.');
|
||||
Response::ok(['status' => 'sent']);
|
||||
} catch (\Throwable $e) { Response::error('Versand fehlgeschlagen: ' . $e->getMessage(), 500); }
|
||||
}
|
||||
|
||||
Response::error('Unbekannte Aktion');
|
||||
}
|
||||
|
||||
|
||||
+21
-4
@@ -16,13 +16,30 @@ class Mailer {
|
||||
$mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->Port = defined('SMTP_PORT') ? (int)SMTP_PORT : 587;
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->setFrom(
|
||||
defined('SMTP_FROM') ? SMTP_FROM : 'support@geograsim.at',
|
||||
defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'GeoGraSim'
|
||||
);
|
||||
[$fromEmail, $fromName] = self::configuredFrom();
|
||||
$mail->setFrom($fromEmail, $fromName);
|
||||
return $mail;
|
||||
}
|
||||
|
||||
/** Absender aus Admin-Config (platform_config_v2), Fallback auf .env-Konstanten.
|
||||
* SMTP-Zugangsdaten bleiben bewusst in .env (geheim) — nur die sichtbare
|
||||
* Absenderadresse/-name ist zentral in der Admin-Oberflaeche pflegbar. */
|
||||
private static function configuredFrom(): array {
|
||||
$email = defined('SMTP_FROM') ? SMTP_FROM : 'support@geograsim.at';
|
||||
$name = defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'GeoGraSim';
|
||||
try {
|
||||
if (class_exists('Database')) {
|
||||
$rows = Database::get()->fetchAll(
|
||||
"SELECT skey, sval FROM app_settings WHERE skey IN ('mail_from_email','mail_from_name')");
|
||||
foreach ($rows as $row) {
|
||||
if ($row['skey'] === 'mail_from_email' && !empty($row['sval'])) $email = $row['sval'];
|
||||
if ($row['skey'] === 'mail_from_name' && !empty($row['sval'])) $name = $row['sval'];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) { /* Config nicht verfuegbar -> Defaults */ }
|
||||
return [$email, $name];
|
||||
}
|
||||
|
||||
public static function send(string $to, string $subject, string $htmlBody, string $textBody = ''): bool {
|
||||
try {
|
||||
$mail = self::create();
|
||||
|
||||
+35
-2
@@ -431,8 +431,12 @@ schueler01,Max M.,,,,,</pre>
|
||||
<div class="modal hidden" id="modal-license">
|
||||
<div class="modal-card">
|
||||
<h3>🔑 Lizenzen einlösen</h3>
|
||||
<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">Ein oder mehrere Codes (einer pro Zeile):</p>
|
||||
<textarea id="ml-codes" rows="4" style="font-family:'Courier New',monospace" placeholder="ABCD-EFGH-JKLM-2026"></textarea>
|
||||
<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">Ein oder mehrere Codes (einer pro Zeile) — oder eine .txt/.csv-Datei ablegen:</p>
|
||||
<textarea id="ml-codes" rows="4" style="font-family:'Courier New',monospace" placeholder="ABCD-EFGH-JKLM-NPQR-STUV"></textarea>
|
||||
<div id="ml-drop" style="margin-top:.4rem;border:1.5px dashed #cbd8dc;border-radius:8px;padding:.5rem;text-align:center;font-size:.66rem;color:#8a8a8a;cursor:pointer" ondragover="event.preventDefault();this.style.background='#eef6f8'" ondragleave="this.style.background=''" ondrop="licenseDrop(event)">
|
||||
📄 .txt/.csv hierher ziehen oder <label style="color:#4a7c8a;font-weight:700;cursor:pointer;text-decoration:underline">auswählen<input type="file" id="ml-file" accept=".txt,.csv,text/plain,text/csv" style="display:none" onchange="licenseFilePick(this)"></label>
|
||||
<div id="ml-fileinfo" style="margin-top:.2rem"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:.4rem;margin-top:.5rem">
|
||||
<button class="btn" onclick="redeemLicenses()">Einlösen</button>
|
||||
<button class="btn btn-outline" onclick="hideModal('modal-license')">Abbrechen</button>
|
||||
@@ -5512,6 +5516,35 @@ async function assignLicense(studentId) {
|
||||
loadLicenses();
|
||||
}
|
||||
|
||||
// Sicherer Datei-Import fuer Codes — mehrstufig:
|
||||
// (1) Endung+MIME .txt/.csv, (2) Groessenlimit, (3) NUR als Text lesen,
|
||||
// (4) Whitelist-Regex extrahiert ausschliesslich Code-Tokens (nichts wird
|
||||
// interpretiert/gerendert/ausgefuehrt), (5) Server validiert jeden Code.
|
||||
function extractCodes(text){
|
||||
var m = String(text).toUpperCase().match(/\b[A-Z0-9]{4}(?:-[A-Z0-9]{4}){2,4}\b/g) || [];
|
||||
var seen={}, out=[];
|
||||
for (var i=0;i<m.length;i++){ var c=m[i]; if(!seen[c]){ seen[c]=1; out.push(c); } if(out.length>=5000) break; }
|
||||
return out;
|
||||
}
|
||||
function ingestLicenseFile(file){
|
||||
var info=document.getElementById('ml-fileinfo'); if(!file) return;
|
||||
var name=(file.name||'').toLowerCase();
|
||||
if(!/\.(txt|csv)$/.test(name) || (file.type && !/^text\//.test(file.type))){
|
||||
info.innerHTML='<span style="color:#c07a6b">Nur .txt oder .csv erlaubt.</span>'; return; }
|
||||
if(file.size > 256*1024){ info.innerHTML='<span style="color:#c07a6b">Datei zu groß (max 256 KB).</span>'; return; }
|
||||
var reader=new FileReader();
|
||||
reader.onload=function(){
|
||||
var codes=extractCodes(reader.result);
|
||||
if(!codes.length){ info.innerHTML='<span style="color:#c07a6b">Keine gültigen Codes gefunden.</span>'; return; }
|
||||
document.getElementById('ml-codes').value=codes.join('\n');
|
||||
info.innerHTML='<span style="color:#5a8a5e">'+codes.length+' Codes aus Datei übernommen.</span>';
|
||||
};
|
||||
reader.onerror=function(){ info.innerHTML='<span style="color:#c07a6b">Datei konnte nicht gelesen werden.</span>'; };
|
||||
reader.readAsText(file);
|
||||
}
|
||||
function licenseFilePick(input){ if(input.files && input.files[0]) ingestLicenseFile(input.files[0]); input.value=''; }
|
||||
function licenseDrop(ev){ ev.preventDefault(); if(ev.currentTarget) ev.currentTarget.style.background=''; var f=ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0]; if(f) ingestLicenseFile(f); }
|
||||
|
||||
async function redeemLicenses() {
|
||||
var text = document.getElementById('ml-codes').value.trim();
|
||||
if (!text) return;
|
||||
|
||||
Reference in New Issue
Block a user