From 64acb84f501f75aabe64c47191fdbc51069940d0 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 12 Jul 2026 22:53:08 +0200 Subject: [PATCH] v0.2.03-beta: kopieerknop voor wachtwoorden + multireset klas/school MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kopieerknop (📋) bij elk getoond wachtwoord/koppelcode, zodat het niet meer verdwijnt voordat het overgetypt kan worden - Bugfix: getoond wachtwoord werd bijna direct overschreven doordat de lijst opnieuw werd opgebouwd vlak nadat het bericht getoond werd (race condition tussen msg() en reload()) - Nieuw: alle leerlingwachtwoorden van een klas of hele school in één keer resetten, alleen voor schoolbeheerder en overkoepelend beheerder - Resultatenpaneel na een multireset met "kopieer alles"-knop (blijft zichtbaar tot de beheerder het zelf sluit) Co-Authored-By: Claude Sonnet 5 --- public/css/teach.css | 22 +++++++- public/js/admin.js | 131 ++++++++++++++++++++++++++++++++++++++----- public/js/core.js | 10 ++++ src/api.js | 41 ++++++++++++++ 4 files changed, 189 insertions(+), 15 deletions(-) diff --git a/public/css/teach.css b/public/css/teach.css index 1abe25f..bbdf9e1 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -964,7 +964,10 @@ .am-head{display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;} .am-head h2{margin:0; font-size:20px;} .am-h3{margin:14px 0 6px; font-size:14px; color:var(--muted); text-transform:uppercase; letter-spacing:.5px;} -.am-msg{min-height:20px; font-size:13.5px; font-weight:800; color:var(--green); margin:4px 0;} +.am-msg{ + min-height:20px; font-size:13.5px; font-weight:800; color:var(--green); margin:4px 0; + display:flex; align-items:center; gap:8px; flex-wrap:wrap; +} .am-add{display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0;} .am-inp,.am-sel{ border:2px solid #cdd9e8; border-radius:10px; padding:8px 10px; font:inherit; @@ -1001,4 +1004,21 @@ font-weight:800; font-size:13px; } .am-chipx{border:none; background:transparent; color:var(--red); font-weight:900; cursor:pointer; padding:0; font-size:11px;} +.am-copy{ + border:none; background:#e8eef6; border-radius:8px; padding:5px 8px; + font:inherit; font-size:13px; cursor:pointer; color:var(--ink); line-height:1; +} +.am-copy:hover{background:#dbe5f1;} +.am-reset{ + background:#f7fafd; border:2px solid #cdd9e8; border-radius:12px; + padding:10px 12px; margin:6px 0; display:flex; flex-direction:column; gap:8px; +} +.am-reset-head{display:flex; align-items:center; justify-content:space-between; gap:8px;} +.am-reset-list{display:flex; flex-direction:column; gap:5px; max-height:260px; overflow:auto;} +.am-reset-row{ + display:flex; gap:8px; align-items:center; background:#fff; + border-radius:8px; padding:5px 8px; flex-wrap:wrap; +} +.am-reset-pw{font-weight:800; font-family:monospace; font-size:13px; color:var(--green);} body.hc .am-row,body.hc .am-chip{border:1.5px solid #000; background:#fff;} +body.hc .am-reset,body.hc .am-reset-row{border:1.5px solid #000; background:#fff;} diff --git a/public/js/admin.js b/public/js/admin.js index 23f3d7a..a253058 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -6,6 +6,10 @@ const modal = document.getElementById("adminModal"); const btn = document.getElementById("btnAdmin"); let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null; + /* resultaat van een laatste multireset (klas/school): blijft zichtbaar + totdat de beheerder het venster zelf sluit, zodat wachtwoorden niet + meteen weer verdwijnen na een reload() */ + let resetResults = null; const esc = s => String(s ?? "").replace(/&/g,"&").replace(/{ @@ -14,7 +18,36 @@ if(text != null) el.textContent = text; return el; }; - const msg = t => { const m = modal.querySelector(".am-msg"); if(m) m.textContent = t; }; + /* wachtwoorden/koppelcodes zijn maar eenmalig zichtbaar - een kopieerknop + zorgt dat ze veilig bewaard kunnen worden voordat het scherm ververst */ + async function copyText(btn, text){ + try{ await navigator.clipboard.writeText(text); } + catch(e){ + const ta = document.createElement("textarea"); + ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; + document.body.appendChild(ta); ta.select(); + try{ document.execCommand("copy"); }catch(_){ /* niets meer te doen */ } + document.body.removeChild(ta); + } + const orig = btn.textContent; + btn.textContent = "✅"; + setTimeout(()=>{ btn.textContent = orig; }, 1200); + } + function copyBtn(text, cls){ + const b = h("button", cls || "am-copy", "📋"); + b.type = "button"; + b.title = T("amCopy"); + b.addEventListener("click", e=>{ e.preventDefault(); if(text) copyText(b, text); }); + return b; + } + const msg = (t, copyValue) => { + const m = modal.querySelector(".am-msg"); + if(!m) return; + m.innerHTML = ""; + if(!t) return; + m.appendChild(document.createTextNode(t)); + if(copyValue) m.appendChild(copyBtn(copyValue)); + }; btn.addEventListener("click", async ()=>{ if(!currentUser || currentUser.role==="pupil") return; @@ -23,7 +56,7 @@ wrap.classList.add("open"); }); wrap.addEventListener("click", e=>{ if(e.target===wrap) wrap.classList.remove("open"); }); - document.addEventListener("userchange", ()=>{ if(!currentUser) wrap.classList.remove("open"); }); + document.addEventListener("userchange", ()=>{ if(!currentUser){ wrap.classList.remove("open"); resetResults = null; } }); async function reload(){ try{ @@ -69,6 +102,11 @@ catch(e){ msg(e.message); } }); row.appendChild(pw); + const pwCopy = h("button","am-copy","📋"); + pwCopy.type = "button"; + pwCopy.title = T("amCopy"); + pwCopy.addEventListener("click", e=>{ e.preventDefault(); if(pw.value) copyText(pwCopy, pw.value); }); + row.appendChild(pwCopy); if(u.everLoggedIn){ const gen = h("button","am-btn","🎲"); gen.title = T("amGenerate"); @@ -76,8 +114,8 @@ try{ const { password } = await api("/admin/genpw?role=pupil"); await api("/admin/users/"+u.id, {method:"PATCH", body:{password}}); - msg(`${T("amPwSet")} ${password}`); - reload(); + await reload(); + msg(`${T("amPwSet")} ${password}`, password); }catch(e){ msg(e.message); } }); row.appendChild(gen); @@ -93,8 +131,8 @@ nc.addEventListener("click", async ()=>{ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}}); - msg(`${T("amCreated")} ${r.linkCode}`); - reload(); + await reload(); + msg(`${T("amCreated")} ${r.linkCode}`, r.linkCode); }catch(e){ msg(e.message); } }); row.appendChild(nc); @@ -103,8 +141,8 @@ np.addEventListener("click", async ()=>{ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}}); - msg(`${T("amPwSet")} ${r.password}`); - reload(); + await reload(); + msg(`${T("amPwSet")} ${r.password}`, r.password); }catch(e){ msg(e.message); } }); row.appendChild(np); @@ -161,20 +199,54 @@ if(currentUser.role==="super" && role!=="super") body.school = +selSchool; try{ const r = await api("/admin/users", { body }); + name.value = ""; + if(pwInp) pwInp.value = ""; + await reload(); msg(role==="pupil" ? `${T("amPupilMade")} ${r.user.password}` : r.user.password ? `${T("amPwSet")} ${r.user.password}` - : `${T("amCreated")} ${r.user.linkCode}`); - name.value = ""; - if(pwInp) pwInp.value = ""; - reload(); + : `${T("amCreated")} ${r.user.linkCode}`, + r.user.password || r.user.linkCode); }catch(e){ msg(e.message); } }); f.appendChild(go); return f; } + /* paneel met het resultaat van een multireset (klas/school): een lijst + nieuwe leerlingwachtwoorden die stuk voor stuk of allemaal ineens + gekopieerd kunnen worden. Blijft staan tot de beheerder ✕ klikt. */ + function resetPanel(){ + if(!resetResults) return null; + const box = h("div","am-reset"); + const head = h("div","am-reset-head"); + head.appendChild(h("strong",null, resetResults.title)); + const close = h("button","wclose","✕"); + close.addEventListener("click", ()=>{ resetResults = null; render(); }); + head.appendChild(close); + box.appendChild(head); + if(!resetResults.entries.length){ + box.appendChild(h("div","am-msg", T("amResetNone"))); + return box; + } + const all = resetResults.entries.map(en=>`${en.displayName}\t${en.password}`).join("\n"); + const copyAll = h("button","am-btn","📋 "+T("amCopyAll")); + copyAll.type = "button"; + copyAll.addEventListener("click", e=>{ e.preventDefault(); copyText(copyAll, all); }); + box.appendChild(copyAll); + const list = h("div","am-reset-list"); + resetResults.entries.forEach(en=>{ + const row = h("div","am-reset-row"); + row.appendChild(h("span","am-name", en.displayName)); + row.appendChild(h("span","am-reset-pw", en.password)); + row.appendChild(copyBtn(`${en.displayName}: ${en.password}`)); + list.appendChild(row); + }); + box.appendChild(list); + return box; + } + function render(){ modal.innerHTML = ""; const head = h("div","am-head"); @@ -211,15 +283,30 @@ } modal.appendChild(h("div","am-msg")); + const rp = resetPanel(); + if(rp) modal.appendChild(rp); /* klassen (admin/super binnen een school) */ const schoolChosen = currentUser.role!=="super" || !!selSchool; + const canResetPw = ["super","admin"].includes(currentUser.role); if(schoolChosen){ modal.appendChild(h("h3","am-h3", T("amClasses"))); const cwrapEl = h("div","am-classes"); CLASSES.forEach(c=>{ const chip = h("span","am-chip", c.name); - if(["super","admin"].includes(currentUser.role)){ + if(canResetPw){ + const rs = h("button","am-chipx","🔄"); + rs.title = T("amResetClass"); + rs.addEventListener("click", async ()=>{ + if(!confirm(`${T("amConfirmReset")} ${c.name}?`)) return; + try{ + const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"}); + await reload(); + resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users }; + render(); + }catch(e){ msg(e.message); } + }); + chip.appendChild(rs); const x = h("button","am-chipx","✕"); x.addEventListener("click", async ()=>{ try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); } @@ -230,7 +317,7 @@ cwrapEl.appendChild(chip); }); modal.appendChild(cwrapEl); - if(["super","admin"].includes(currentUser.role)){ + if(canResetPw){ const f = h("div","am-add"); const inp = h("input","am-inp"); inp.placeholder = T("amNamePh"); @@ -246,6 +333,22 @@ }); f.appendChild(go); modal.appendChild(f); + + /* multireset: alle leerlingwachtwoorden van de hele school in één keer */ + const rsAll = h("button","am-btn","🔄 "+T("amResetSchool")); + rsAll.type = "button"; + rsAll.addEventListener("click", async ()=>{ + const schoolId = currentUser.role==="super" ? selSchool : currentUser.schoolId; + if(!schoolId) return; + if(!confirm(T("amConfirmResetSchool"))) return; + try{ + const r = await api("/admin/schools/"+schoolId+"/reset-passwords", {method:"POST"}); + await reload(); + resetResults = { title: T("amResetSchool"), entries: r.users }; + render(); + }catch(e){ msg(e.message); } + }); + modal.appendChild(rsAll); } } diff --git a/public/js/core.js b/public/js/core.js index 106398f..1d16fc4 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -133,6 +133,11 @@ const I18N = { amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:", amPwOptional:"wachtwoord (leeg = koppelcode)", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", + amCopy:"kopieer", amCopyAll:"kopieer alles", + amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden", + amConfirmReset:"Alle leerlingwachtwoorden van deze klas vernieuwen?", + amConfirmResetSchool:"Alle leerlingwachtwoorden van deze hele school vernieuwen? Dit kan niet ongedaan worden gemaakt.", + amResetNone:"Geen leerlingen om te resetten.", errExists:"Deze naam bestaat al.", errName:"Naam: 2–20 letters of cijfers.", errPass:"Wachtwoord: minimaal 4 tekens.", @@ -251,6 +256,11 @@ const I18N = { amNewPw:"new password", amPwSet:"Password set:", amPwOptional:"password (empty = link code)", amConfirmDel:"Are you sure you want to delete this user?", + amCopy:"copy", amCopyAll:"copy all", + amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords", + amConfirmReset:"Renew all pupil passwords for this class?", + amConfirmResetSchool:"Renew all pupil passwords for this whole school? This cannot be undone.", + amResetNone:"No pupils to reset.", errExists:"This name already exists.", errName:"Name: 2–20 letters or digits.", errPass:"Password: at least 4 characters.", diff --git a/src/api.js b/src/api.js index 4c693d0..bb35391 100644 --- a/src/api.js +++ b/src/api.js @@ -128,6 +128,25 @@ export default async function api(app) { return { ok: true }; }); + // Multireset: alle leerlingwachtwoorden van een hele school in één keer + // vernieuwen. Alleen schoolbeheerder (eigen school) en overkoepelend beheerder. + app.post('/admin/schools/:id/reset-passwords', async (req, reply) => { + need(req, reply, ['super', 'admin']); + const schoolId = req.params.id; + if (req.user.role !== 'super' && Number(schoolId) !== Number(req.user.school_id)) + return fail(reply, 403, 'geen rechten'); + const pupils = (await pool.query( + "SELECT * FROM users WHERE school_id = $1 AND role = 'pupil' ORDER BY lower(username)", [schoolId])).rows; + const users = []; + for (const p of pupils) { + const pw = pupilPassword(); + await pool.query('UPDATE users SET password_plain = $1, password_hash = $2, last_login_at = NULL WHERE id = $3', + [pw, await hashPassword(pw), p.id]); + users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw }); + } + return { users }; + }); + // ---- klassen ----------------------------------------------------------------- app.get('/admin/classes', async (req, reply) => { need(req, reply, ['super', 'admin', 'teacher']); @@ -166,6 +185,28 @@ export default async function api(app) { return { ok: true }; }); + // Multireset: alle leerlingwachtwoorden van één klas in één keer vernieuwen. + // Alleen schoolbeheerder (eigen school) en overkoepelend beheerder - niet + // de groepsleiding, om per ongeluk een hele klas resetten te voorkomen. + app.post('/admin/classes/:id/reset-passwords', async (req, reply) => { + need(req, reply, ['super', 'admin']); + const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]); + const c = r.rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (req.user.role !== 'super' && Number(c.school_id) !== Number(req.user.school_id)) + return fail(reply, 403, 'geen rechten'); + const pupils = (await pool.query( + "SELECT * FROM users WHERE class_id = $1 AND role = 'pupil' ORDER BY lower(username)", [c.id])).rows; + const users = []; + for (const p of pupils) { + const pw = pupilPassword(); + await pool.query('UPDATE users SET password_plain = $1, password_hash = $2, last_login_at = NULL WHERE id = $3', + [pw, await hashPassword(pw), p.id]); + users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw }); + } + return { users }; + }); + // Genereer een voorstel-wachtwoord (niet opgeslagen) voor in het aanmaakformulier. app.get('/admin/genpw', async (req, reply) => { need(req, reply, ['super', 'admin', 'teacher']);