teach/public/js/admin.js
Ramon 64acb84f50
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 12s
v0.2.03-beta: kopieerknop voor wachtwoorden + multireset klas/school
- 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>
2026-07-12 22:53:08 +02:00

377 lines
15 KiB
JavaScript

/* teach - beheerpaneel (👥): gebruikers, klassen en scholen.
Zichtbaar voor super (alles), admin (eigen school) en teacher (leerlingen). */
"use strict";
(function(){
const wrap = document.getElementById("adminWrap");
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,"&amp;").replace(/</g,"&lt;");
const h = (tag, cls, text)=>{
const el = document.createElement(tag);
if(cls) el.className = cls;
if(text != null) el.textContent = text;
return el;
};
/* 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;
selSchool = currentUser.role==="super" ? (selSchool ?? "") : currentUser.schoolId;
await reload();
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"); resetResults = null; } });
async function reload(){
try{
if(currentUser.role==="super") SCHOOLS = await api("/schools");
const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : "";
USERS = (await api("/admin/users"+q)).users;
const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=0") : "";
CLASSES = (await api("/admin/classes"+cq)).classes || [];
}catch(e){ USERS = []; CLASSES = []; }
render();
}
function classSel(current){
const sel = h("select","am-sel");
sel.appendChild(new Option(T("amNoClass"), ""));
CLASSES.forEach(c=>sel.appendChild(new Option(c.name, c.id)));
sel.value = current ?? "";
return sel;
}
function userRow(u){
const row = h("div","am-row");
row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : "")));
row.appendChild(h("span","am-role", T(roleKey(u.role))));
if(u.role==="pupil"){
/* klas (uitwisselen met andere klassen) */
const cs = classSel(u.classId);
cs.addEventListener("change", async ()=>{
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{classId: cs.value ? +cs.value : null}}); }
catch(e){ msg(e.message); }
});
row.appendChild(cs);
/* wachtwoord inzien en aanpassen - alleen zichtbaar zolang de leerling
nog niet is ingelogd; daarna kan alleen een nieuw wachtwoord gezet worden */
const pw = h("input","am-pw");
pw.value = u.password || "";
pw.placeholder = u.everLoggedIn ? T("amPwHidden") : "";
pw.title = T("amPw");
pw.addEventListener("keydown", ev=>ev.stopPropagation());
pw.addEventListener("change", async ()=>{
if(pw.value.length < 4) return;
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); reload(); }
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");
gen.addEventListener("click", async ()=>{
try{
const { password } = await api("/admin/genpw?role=pupil");
await api("/admin/users/"+u.id, {method:"PATCH", body:{password}});
await reload();
msg(`${T("amPwSet")} ${password}`, password);
}catch(e){ msg(e.message); }
});
row.appendChild(gen);
}
}else{
if(u.pending && u.linkCode){
row.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode));
}else if(u.pending){
row.appendChild(h("span","am-code", T("amPending")));
}
if(["super","admin"].includes(currentUser.role) && u.role!=="pupil" && u.id!==currentUser.id){
const nc = h("button","am-btn", T("amNewCode"));
nc.addEventListener("click", async ()=>{
try{
const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}});
await reload();
msg(`${T("amCreated")} ${r.linkCode}`, r.linkCode);
}catch(e){ msg(e.message); }
});
row.appendChild(nc);
/* direct een nieuw wachtwoord zetten, zonder de koppelcode-activatie */
const np = h("button","am-btn", T("amNewPw"));
np.addEventListener("click", async ()=>{
try{
const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}});
await reload();
msg(`${T("amPwSet")} ${r.password}`, r.password);
}catch(e){ msg(e.message); }
});
row.appendChild(np);
}
}
if(u.id !== currentUser.id){
const del = h("button","am-del","🗑");
del.title = T("amDelete") || "";
del.addEventListener("click", async ()=>{
if(!confirm(T("amConfirmDel"))) return;
try{ await api("/admin/users/"+u.id, {method:"DELETE"}); reload(); }
catch(e){ msg(e.message); }
});
row.appendChild(del);
}
return row;
}
function addForm(role){
const f = h("div","am-add");
const name = h("input","am-inp");
name.placeholder = T("amUserPh");
name.addEventListener("keydown", ev=>ev.stopPropagation());
f.appendChild(name);
let cs = null;
if(role==="pupil"){ cs = classSel(""); f.appendChild(cs); }
let pwInp = null;
if(role!=="pupil"){
/* optioneel: meteen zelf een wachtwoord instellen i.p.v. een koppelcode
(die de gebruiker normaal zelf gebruikt om een wachtwoord te kiezen) */
pwInp = h("input","am-inp am-pwinp");
pwInp.type = "password";
pwInp.autocomplete = "new-password";
pwInp.placeholder = T("amPwOptional");
pwInp.addEventListener("keydown", ev=>ev.stopPropagation());
f.appendChild(pwInp);
const gen = h("button","am-btn","🎲");
gen.type = "button";
gen.title = T("amGenerate");
gen.addEventListener("click", async ()=>{
try{
const { password } = await api("/admin/genpw");
pwInp.type = "text";
pwInp.value = password;
}catch(e){ msg(e.message); }
});
f.appendChild(gen);
}
const go = h("button","tbtn", T(role==="pupil" ? "amNewPupil" : role==="teacher" ? "amNewTeacher" : role==="admin" ? "amNewAdmin" : "amNewSuper"));
go.addEventListener("click", async ()=>{
const body = { role, username: name.value.trim(), displayName: name.value.trim() };
if(role==="pupil" && cs && cs.value) body.classId = +cs.value;
if(pwInp && pwInp.value.trim()) body.password = pwInp.value.trim();
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}`,
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");
head.appendChild(h("h2", null, T("beheer")));
const close = h("button","wclose","✕");
close.addEventListener("click", ()=>wrap.classList.remove("open"));
head.appendChild(close);
modal.appendChild(head);
/* super: schoolkiezer + nieuwe school */
if(currentUser.role==="super"){
const bar = h("div","am-add");
const sel = h("select","am-sel");
sel.appendChild(new Option(T("amAllSchools"), ""));
SCHOOLS.forEach(s=>sel.appendChild(new Option(s.name, s.id)));
sel.value = selSchool ?? "";
sel.addEventListener("change", ()=>{ selSchool = sel.value; reload(); });
bar.appendChild(sel);
const inp = h("input","am-inp");
inp.placeholder = T("amNamePh");
inp.addEventListener("keydown", ev=>ev.stopPropagation());
bar.appendChild(inp);
const add = h("button","tbtn", T("amNewSchool"));
add.addEventListener("click", async ()=>{
if(!inp.value.trim()) return;
try{
const r = await api("/admin/schools", { body:{ name: inp.value.trim() } });
selSchool = r.school.id; inp.value = "";
reload();
}catch(e){ msg(e.message); }
});
bar.appendChild(add);
modal.appendChild(bar);
}
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(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(); }
catch(e){ msg(e.message); }
});
chip.appendChild(x);
}
cwrapEl.appendChild(chip);
});
modal.appendChild(cwrapEl);
if(canResetPw){
const f = h("div","am-add");
const inp = h("input","am-inp");
inp.placeholder = T("amNamePh");
inp.addEventListener("keydown", ev=>ev.stopPropagation());
f.appendChild(inp);
const go = h("button","tbtn", T("amNewClass"));
go.addEventListener("click", async ()=>{
if(!inp.value.trim()) return;
try{
await api("/admin/classes", { body:{ name: inp.value.trim(), school: selSchool ? +selSchool : undefined } });
reload();
}catch(e){ msg(e.message); }
});
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);
}
}
/* gebruikers */
modal.appendChild(h("h3","am-h3", T("amUsers")));
const list = h("div","am-list");
const groups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")], ["pupil", T("rolePupil")]];
groups.forEach(([role, label])=>{
const us = USERS.filter(u=>u.role===role);
if(!us.length) return;
list.appendChild(h("div","am-group", label));
us.forEach(u=>list.appendChild(userRow(u)));
});
modal.appendChild(list);
/* toevoegen */
if(schoolChosen){
if(["super","admin","teacher"].includes(currentUser.role)) modal.appendChild(addForm("pupil"));
if(["super","admin"].includes(currentUser.role)){
modal.appendChild(addForm("teacher"));
modal.appendChild(addForm("admin"));
}
}
if(currentUser.role==="super") modal.appendChild(addForm("super"));
}
})();