v0.2.03-beta: kopieerknop voor wachtwoorden + multireset klas/school
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 12s

- 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 <noreply@anthropic.com>
This commit is contained in:
Ramon 2026-07-12 22:53:08 +02:00
parent bd7c7e07e7
commit 64acb84f50
4 changed files with 189 additions and 15 deletions

View file

@ -964,7 +964,10 @@
.am-head{display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;} .am-head{display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;}
.am-head h2{margin:0; font-size:20px;} .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-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-add{display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0;}
.am-inp,.am-sel{ .am-inp,.am-sel{
border:2px solid #cdd9e8; border-radius:10px; padding:8px 10px; font:inherit; border:2px solid #cdd9e8; border-radius:10px; padding:8px 10px; font:inherit;
@ -1001,4 +1004,21 @@
font-weight:800; font-size:13px; 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-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-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;}

View file

@ -6,6 +6,10 @@
const modal = document.getElementById("adminModal"); const modal = document.getElementById("adminModal");
const btn = document.getElementById("btnAdmin"); const btn = document.getElementById("btnAdmin");
let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null; 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,"&amp;").replace(/</g,"&lt;"); const esc = s => String(s ?? "").replace(/&/g,"&amp;").replace(/</g,"&lt;");
const h = (tag, cls, text)=>{ const h = (tag, cls, text)=>{
@ -14,7 +18,36 @@
if(text != null) el.textContent = text; if(text != null) el.textContent = text;
return el; 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 ()=>{ btn.addEventListener("click", async ()=>{
if(!currentUser || currentUser.role==="pupil") return; if(!currentUser || currentUser.role==="pupil") return;
@ -23,7 +56,7 @@
wrap.classList.add("open"); wrap.classList.add("open");
}); });
wrap.addEventListener("click", e=>{ if(e.target===wrap) wrap.classList.remove("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(){ async function reload(){
try{ try{
@ -69,6 +102,11 @@
catch(e){ msg(e.message); } catch(e){ msg(e.message); }
}); });
row.appendChild(pw); 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){ if(u.everLoggedIn){
const gen = h("button","am-btn","🎲"); const gen = h("button","am-btn","🎲");
gen.title = T("amGenerate"); gen.title = T("amGenerate");
@ -76,8 +114,8 @@
try{ try{
const { password } = await api("/admin/genpw?role=pupil"); const { password } = await api("/admin/genpw?role=pupil");
await api("/admin/users/"+u.id, {method:"PATCH", body:{password}}); await api("/admin/users/"+u.id, {method:"PATCH", body:{password}});
msg(`${T("amPwSet")} ${password}`); await reload();
reload(); msg(`${T("amPwSet")} ${password}`, password);
}catch(e){ msg(e.message); } }catch(e){ msg(e.message); }
}); });
row.appendChild(gen); row.appendChild(gen);
@ -93,8 +131,8 @@
nc.addEventListener("click", async ()=>{ nc.addEventListener("click", async ()=>{
try{ try{
const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}}); const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}});
msg(`${T("amCreated")} ${r.linkCode}`); await reload();
reload(); msg(`${T("amCreated")} ${r.linkCode}`, r.linkCode);
}catch(e){ msg(e.message); } }catch(e){ msg(e.message); }
}); });
row.appendChild(nc); row.appendChild(nc);
@ -103,8 +141,8 @@
np.addEventListener("click", async ()=>{ np.addEventListener("click", async ()=>{
try{ try{
const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}}); const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}});
msg(`${T("amPwSet")} ${r.password}`); await reload();
reload(); msg(`${T("amPwSet")} ${r.password}`, r.password);
}catch(e){ msg(e.message); } }catch(e){ msg(e.message); }
}); });
row.appendChild(np); row.appendChild(np);
@ -161,20 +199,54 @@
if(currentUser.role==="super" && role!=="super") body.school = +selSchool; if(currentUser.role==="super" && role!=="super") body.school = +selSchool;
try{ try{
const r = await api("/admin/users", { body }); const r = await api("/admin/users", { body });
name.value = "";
if(pwInp) pwInp.value = "";
await reload();
msg(role==="pupil" msg(role==="pupil"
? `${T("amPupilMade")} ${r.user.password}` ? `${T("amPupilMade")} ${r.user.password}`
: r.user.password : r.user.password
? `${T("amPwSet")} ${r.user.password}` ? `${T("amPwSet")} ${r.user.password}`
: `${T("amCreated")} ${r.user.linkCode}`); : `${T("amCreated")} ${r.user.linkCode}`,
name.value = ""; r.user.password || r.user.linkCode);
if(pwInp) pwInp.value = "";
reload();
}catch(e){ msg(e.message); } }catch(e){ msg(e.message); }
}); });
f.appendChild(go); f.appendChild(go);
return f; 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(){ function render(){
modal.innerHTML = ""; modal.innerHTML = "";
const head = h("div","am-head"); const head = h("div","am-head");
@ -211,15 +283,30 @@
} }
modal.appendChild(h("div","am-msg")); modal.appendChild(h("div","am-msg"));
const rp = resetPanel();
if(rp) modal.appendChild(rp);
/* klassen (admin/super binnen een school) */ /* klassen (admin/super binnen een school) */
const schoolChosen = currentUser.role!=="super" || !!selSchool; const schoolChosen = currentUser.role!=="super" || !!selSchool;
const canResetPw = ["super","admin"].includes(currentUser.role);
if(schoolChosen){ if(schoolChosen){
modal.appendChild(h("h3","am-h3", T("amClasses"))); modal.appendChild(h("h3","am-h3", T("amClasses")));
const cwrapEl = h("div","am-classes"); const cwrapEl = h("div","am-classes");
CLASSES.forEach(c=>{ CLASSES.forEach(c=>{
const chip = h("span","am-chip", c.name); 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","✕"); const x = h("button","am-chipx","✕");
x.addEventListener("click", async ()=>{ x.addEventListener("click", async ()=>{
try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); } try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); }
@ -230,7 +317,7 @@
cwrapEl.appendChild(chip); cwrapEl.appendChild(chip);
}); });
modal.appendChild(cwrapEl); modal.appendChild(cwrapEl);
if(["super","admin"].includes(currentUser.role)){ if(canResetPw){
const f = h("div","am-add"); const f = h("div","am-add");
const inp = h("input","am-inp"); const inp = h("input","am-inp");
inp.placeholder = T("amNamePh"); inp.placeholder = T("amNamePh");
@ -246,6 +333,22 @@
}); });
f.appendChild(go); f.appendChild(go);
modal.appendChild(f); 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);
} }
} }

View file

@ -133,6 +133,11 @@ const I18N = {
amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:", amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:",
amPwOptional:"wachtwoord (leeg = koppelcode)", amPwOptional:"wachtwoord (leeg = koppelcode)",
amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", 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.", errExists:"Deze naam bestaat al.",
errName:"Naam: 220 letters of cijfers.", errName:"Naam: 220 letters of cijfers.",
errPass:"Wachtwoord: minimaal 4 tekens.", errPass:"Wachtwoord: minimaal 4 tekens.",
@ -251,6 +256,11 @@ const I18N = {
amNewPw:"new password", amPwSet:"Password set:", amNewPw:"new password", amPwSet:"Password set:",
amPwOptional:"password (empty = link code)", amPwOptional:"password (empty = link code)",
amConfirmDel:"Are you sure you want to delete this user?", 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.", errExists:"This name already exists.",
errName:"Name: 220 letters or digits.", errName:"Name: 220 letters or digits.",
errPass:"Password: at least 4 characters.", errPass:"Password: at least 4 characters.",

View file

@ -128,6 +128,25 @@ export default async function api(app) {
return { ok: true }; 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 ----------------------------------------------------------------- // ---- klassen -----------------------------------------------------------------
app.get('/admin/classes', async (req, reply) => { app.get('/admin/classes', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']); need(req, reply, ['super', 'admin', 'teacher']);
@ -166,6 +185,28 @@ export default async function api(app) {
return { ok: true }; 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. // Genereer een voorstel-wachtwoord (niet opgeslagen) voor in het aanmaakformulier.
app.get('/admin/genpw', async (req, reply) => { app.get('/admin/genpw', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']); need(req, reply, ['super', 'admin', 'teacher']);