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="card-h">📦 Lizenzen ausgeben (Vertrieb)</div>
|
||||||
<div class="frm">
|
<div class="frm">
|
||||||
<div>
|
<div>
|
||||||
<label>Anzahl</label>
|
<label>Anzahl (bestellt)</label>
|
||||||
<input type="number" id="iss-count" value="12" min="1" max="5000">
|
<input type="number" id="iss-count" value="12" min="1" max="5000" oninput="updatePreview()">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Typ</label>
|
<label>Typ</label>
|
||||||
@@ -168,6 +168,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<datalist id="cust-hints"></datalist>
|
<datalist id="cust-hints"></datalist>
|
||||||
<datalist id="order-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">
|
<div style="margin-top:.7rem" class="row-act">
|
||||||
<button class="btn" onclick="issueBatch()">Codes erzeugen & ausgeben</button>
|
<button class="btn" onclick="issueBatch()">Codes erzeugen & ausgeben</button>
|
||||||
<span id="iss-msg" class="mini"></span>
|
<span id="iss-msg" class="mini"></span>
|
||||||
@@ -192,6 +198,41 @@
|
|||||||
<div id="batch-table" style="overflow-x:auto"></div>
|
<div id="batch-table" style="overflow-x:auto"></div>
|
||||||
</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">
|
||||||
<div class="card-h">Neue Lizenzen generieren</div>
|
<div class="card-h">Neue Lizenzen generieren</div>
|
||||||
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap">
|
<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 name = document.getElementById('iss-name').value.trim();
|
||||||
var vf = document.getElementById('iss-from').value;
|
var vf = document.getElementById('iss-from').value;
|
||||||
var vu = document.getElementById('iss-until').value;
|
var vu = document.getElementById('iss-until').value;
|
||||||
|
var bonus = document.getElementById('iss-bonus').checked;
|
||||||
var msg = document.getElementById('iss-msg');
|
var msg = document.getElementById('iss-msg');
|
||||||
if(count<1||count>5000){ msg.textContent='Anzahl 1–5000.'; return; }
|
if(count<1||count>5000){ msg.textContent='Anzahl 1–5000.'; return; }
|
||||||
|
var delivered = calcDelivered(count, bonus);
|
||||||
var who = name||email||custno||'(kein Kunde)';
|
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 …';
|
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});
|
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; }
|
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>';
|
msg.innerHTML='<span style="color:#5a8a5e">✓ '+r.count+' Codes erzeugt (Bestellung #'+r.internal_no+', bestellt '+r.ordered+').</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};
|
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();
|
renderIssueResult();
|
||||||
loadBatches();
|
loadBatches();
|
||||||
loadLicenses();
|
loadLicenses();
|
||||||
@@ -429,17 +472,15 @@ function renderIssueResult(){
|
|||||||
var codesText = m.codes.join('\n');
|
var codesText = m.codes.join('\n');
|
||||||
var mailText = buildMailText(m);
|
var mailText = buildMailText(m);
|
||||||
var h = '<div class="out">'
|
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>'
|
+ '<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>'
|
+ '<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">'
|
+ '<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="copyEl(\'codes-ta\')">📋 Kopieren</button>'
|
||||||
+ '<button class="btn btn-outline" onclick="downloadCodes(\'txt\')">⬇ .txt</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>'
|
+ '</div>'
|
||||||
+ '<h4 style="margin-top:.9rem">2 · Sammel-Einlöse-Link (alle auf einmal)</h4>'
|
+ '<h4 style="margin-top:.9rem">2 · Per Mail senden</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>'
|
|
||||||
+ '<div class="frm" style="grid-template-columns:1fr 2fr">'
|
+ '<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>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>'
|
+ '<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) : '';
|
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'
|
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'
|
+ codesPart + '\n\n'
|
||||||
+ 'Einlösen: Als Lehrperson unter '+absUrl('/')+' anmelden und die Codes unter „Lizenzen" eingeben.\n'
|
+ 'Einlösen: Als Lehrperson unter '+PUBLIC_URL+' anmelden und die Codes unter „Lizenzen" eingeben.\n\n'
|
||||||
+ 'Oder alle auf einmal über diesen Link: '+absUrl('/einloesen?charge='+m.token)+'\n\n'
|
|
||||||
+ 'Freundliche Grüße\nGeoGraSim';
|
+ 'Freundliche Grüße\nGeoGraSim';
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyEl(id){ var el=document.getElementById(id); el.select(); navigator.clipboard.writeText(el.value).catch(function(){document.execCommand('copy');}); }
|
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){
|
function downloadCodes(fmt){
|
||||||
var m = lastIssue; if(!m) return;
|
var m = lastIssue; if(!m) return;
|
||||||
var content, mime;
|
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'; }
|
if(fmt==='csv'){
|
||||||
else { content=m.codes.join('\r\n')+'\r\n'; mime='text/plain'; }
|
var head='Bestellung;#'+(m.internal_no||'')+'\nExterne Bestellnummer;'+(m.order_no||'')+'\nGültig bis;'+fmtDate(m.valid_until)+'\nAnzahl;'+m.count+'\n\n';
|
||||||
var base = m.order_no ? m.order_no.replace(/[^a-zA-Z0-9_\-]/g,'_') : (new Date().toISOString().slice(0,16).replace(/[:T]/g,'-'));
|
content=head+m.codes.join('\r\n')+'\r\n'; mime='text/csv';
|
||||||
var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([content],{type:mime})); a.download=base+'.'+fmt; a.click();
|
} 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){
|
async function sendMail(batchId){
|
||||||
@@ -542,8 +628,6 @@ async function openBatch(id){
|
|||||||
+ ' · gültig '+fmtDate(b.default_valid_from)+'–'+fmtDate(b.default_valid_until)
|
+ ' · gültig '+fmtDate(b.default_valid_from)+'–'+fmtDate(b.default_valid_until)
|
||||||
+ (b.canceled_at?' · <span class="badge b-canceled">Charge storniert</span>':'')+'</div>';
|
+ (b.canceled_at?' · <span class="badge b-canceled">Charge storniert</span>':'')+'</div>';
|
||||||
h+='<div class="row-act" style="margin-bottom:.6rem">'
|
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="extendBatch('+b.id+',\''+esc(b.default_valid_until||'')+'\')">📅 Charge verlängern</button>'
|
||||||
+ '<button class="btn-sm" onclick="cancelBatch('+b.id+')">✖ Charge stornieren</button></div>';
|
+ '<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>';
|
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();
|
loadLicenses();
|
||||||
prefillDefaults();
|
prefillDefaults();
|
||||||
loadBatches();
|
loadBatches();
|
||||||
|
loadMailConfig();
|
||||||
|
updatePreview();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</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]);
|
[$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 ===
|
// === GET ===
|
||||||
if ($method === 'GET') {
|
if ($method === 'GET') {
|
||||||
// Super-Admin: alle Lizenzen
|
// 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
|
// Kunden-Autocomplete
|
||||||
if (isset($_GET['customers'])) {
|
if (isset($_GET['customers'])) {
|
||||||
$like = '%' . trim($_GET['customers']) . '%';
|
$like = '%' . trim($_GET['customers']) . '%';
|
||||||
@@ -214,9 +232,10 @@ if ($method === 'GET') {
|
|||||||
[$classId]
|
[$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(
|
$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]
|
[$teacherId]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -348,8 +367,11 @@ if ($method === 'POST') {
|
|||||||
$adminId = Session::adminId();
|
$adminId = Session::adminId();
|
||||||
if (!$adminId) Response::error('Nur Admin', 403);
|
if (!$adminId) Response::error('Nur Admin', 403);
|
||||||
|
|
||||||
$count = (int)($body['count'] ?? 0);
|
$ordered = (int)($body['count'] ?? 0);
|
||||||
if ($count < 1 || $count > 5000) Response::error('Anzahl muss zwischen 1 und 5000 liegen');
|
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';
|
$type = in_array($body['sales_type'] ?? '', LIC_TYPES, true) ? $body['sales_type'] : 'privat';
|
||||||
$orderNo = trim((string)($body['order_no'] ?? '')) ?: null;
|
$orderNo = trim((string)($body['order_no'] ?? '')) ?: null;
|
||||||
$note = trim((string)($body['note'] ?? '')) ?: null;
|
$note = trim((string)($body['note'] ?? '')) ?: null;
|
||||||
@@ -366,9 +388,9 @@ if ($method === 'POST') {
|
|||||||
$db->begin();
|
$db->begin();
|
||||||
try {
|
try {
|
||||||
$db->execute(
|
$db->execute(
|
||||||
"INSERT INTO license_batches (customer_id, order_no, sales_type, qty, default_valid_from, default_valid_until, note, redeem_token, created_by)
|
"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 (?,?,?,?,?,?,?,?,?)",
|
VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||||
[$customerId, $orderNo, $type, $count, $validFrom, $validUntil, $note, $token, $adminId]);
|
[$customerId, $orderNo, $type, $count, $ordered, $validFrom, $validUntil, $note, $token, $adminId]);
|
||||||
$batchId = (int)$db->lastInsertId();
|
$batchId = (int)$db->lastInsertId();
|
||||||
|
|
||||||
$codes = []; $made = 0; $tries = 0; $maxTries = $count * 8 + 50;
|
$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 */ }
|
} catch (\Throwable $e) { /* Code-Kollision (extrem selten) → neuer Versuch */ }
|
||||||
}
|
}
|
||||||
if ($made < $count) throw new \RuntimeException("Nur $made von $count Codes erzeugt");
|
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();
|
$db->commit();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$db->rollBack();
|
$db->rollBack();
|
||||||
Response::error('Fehler beim Erzeugen: ' . $e->getMessage(), 500);
|
Response::error('Fehler beim Erzeugen: ' . $e->getMessage(), 500);
|
||||||
}
|
}
|
||||||
Response::ok([
|
Response::ok([
|
||||||
'batch_id' => $batchId, 'redeem_token' => $token,
|
'batch_id' => $batchId, 'internal_no' => $batchId, 'redeem_token' => $token,
|
||||||
'valid_from' => $validFrom, 'valid_until' => $validUntil,
|
'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') {
|
if ($action === 'send_batch_mail') {
|
||||||
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
|
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
|
||||||
$bid = (int)($body['batch_id'] ?? 0); if (!$bid) Response::error('batch_id erforderlich');
|
$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'] ?? ''));
|
$to = trim((string)($body['to_email'] ?? ''));
|
||||||
$subject = trim((string)($body['subject'] ?? '')) ?: 'Ihre GeoGraSim-Lizenzcodes';
|
$subject = trim((string)($body['subject'] ?? '')) ?: 'Ihre GeoGraSim-Lizenzcodes';
|
||||||
$textBody = (string)($body['body_text'] ?? '');
|
$textBody = (string)($body['body_text'] ?? '');
|
||||||
@@ -459,7 +483,7 @@ if ($method === 'POST') {
|
|||||||
}
|
}
|
||||||
$db->execute(
|
$db->execute(
|
||||||
"INSERT INTO license_mails (batch_id, to_email, subject, body_text, body_html, provider, status, created_by) VALUES (?,?,?,?,?,?,?,?)",
|
"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();
|
$mailId = (int)$db->lastInsertId();
|
||||||
try {
|
try {
|
||||||
Mailer::sendWithAttachments($to, $subject, $html, $textBody, $attach);
|
Mailer::sendWithAttachments($to, $subject, $html, $textBody, $attach);
|
||||||
@@ -489,6 +513,36 @@ if ($method === 'POST') {
|
|||||||
Response::ok(['redeemed' => $n]);
|
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');
|
Response::error('Unbekannte Aktion');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-4
@@ -16,13 +16,30 @@ class Mailer {
|
|||||||
$mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
|
$mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
|
||||||
$mail->Port = defined('SMTP_PORT') ? (int)SMTP_PORT : 587;
|
$mail->Port = defined('SMTP_PORT') ? (int)SMTP_PORT : 587;
|
||||||
$mail->CharSet = 'UTF-8';
|
$mail->CharSet = 'UTF-8';
|
||||||
$mail->setFrom(
|
[$fromEmail, $fromName] = self::configuredFrom();
|
||||||
defined('SMTP_FROM') ? SMTP_FROM : 'support@geograsim.at',
|
$mail->setFrom($fromEmail, $fromName);
|
||||||
defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'GeoGraSim'
|
|
||||||
);
|
|
||||||
return $mail;
|
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 {
|
public static function send(string $to, string $subject, string $htmlBody, string $textBody = ''): bool {
|
||||||
try {
|
try {
|
||||||
$mail = self::create();
|
$mail = self::create();
|
||||||
|
|||||||
+35
-2
@@ -431,8 +431,12 @@ schueler01,Max M.,,,,,</pre>
|
|||||||
<div class="modal hidden" id="modal-license">
|
<div class="modal hidden" id="modal-license">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<h3>🔑 Lizenzen einlösen</h3>
|
<h3>🔑 Lizenzen einlösen</h3>
|
||||||
<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">Ein oder mehrere Codes (einer pro Zeile):</p>
|
<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-2026"></textarea>
|
<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">
|
<div style="display:flex;gap:.4rem;margin-top:.5rem">
|
||||||
<button class="btn" onclick="redeemLicenses()">Einlösen</button>
|
<button class="btn" onclick="redeemLicenses()">Einlösen</button>
|
||||||
<button class="btn btn-outline" onclick="hideModal('modal-license')">Abbrechen</button>
|
<button class="btn btn-outline" onclick="hideModal('modal-license')">Abbrechen</button>
|
||||||
@@ -5512,6 +5516,35 @@ async function assignLicense(studentId) {
|
|||||||
loadLicenses();
|
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() {
|
async function redeemLicenses() {
|
||||||
var text = document.getElementById('ml-codes').value.trim();
|
var text = document.getElementById('ml-codes').value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user