teach/public/js/admin.js
Ramon 2528e5b8f6
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 7s
v0.2.21-beta: meerdere functies per gebruiker (multi-rol voor staf) + zoekveld
- Nieuwe user_roles-koppeltabel (db/004_user_roles.sql): staf kan voortaan naast de hoofdrol extra functies krijgen (bv. groepsleiding + schoolbeheerder tegelijk); leerling blijft altijd exclusief
- can()/PERMISSIONS accepteren nu een array van rollen i.p.v. alleen een losse string (backward compatible), CREATABLE_ROLES-opzoeking wordt een unie over alle rollen van een gebruiker
- Rechten-checks in src/api.js gebruiken voortaan req.user.allRoles (hoofd- + extra rollen); identiteits-/scoping-logica (leerling-filters, aanmaken, groepsleiding-klas-scope) blijft bewust op de hoofdrol staan
- PATCH /admin/users/:id accepteert een nieuw veld extraRoles (super-only, alleen voor staf)
- Beheerpaneel: toggle-knoppen per stafgebruiker om extra rollen aan/uit te zetten; Instellingen toont voortaan alle rollen samengevoegd ("Schoolbeheerder + Groepsleiding")
- Zoekveld boven de gebruikerslijst (live filter op naam, client-side, behoudt focus tijdens typen) omdat die lijst in een grote school lang kan worden

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:41:40 +02:00

536 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* teach - beheerpaneel (👥): gebruikers, klassen en scholen.
Zichtbaar voor super (alles), admin (eigen school) en teacher (leerlingen). */
"use strict";
(function(){
/* het beheerpaneel is een tabblad binnen Instellingen (settingsWrap/settingsModal),
geen los venster meer - modal is alleen nog de content-container voor render() */
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;
/* actief tabblad, blijft behouden over her-renders heen (na elke actie) */
let adminTab = "gebruikers";
/* live zoekfilter voor de gebruikerslijst, blijft ook behouden over her-renders */
let userFilter = "";
/* welke rollen de ingelogde gebruiker mag aanmaken: unie over hoofdrol + extra rollen */
const creatableRoles = () =>
[...new Set([currentUser.role, ...(currentUser.extraRoles||[])].flatMap(r => CREATABLE_ROLES[r] || []))];
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(!can("admin.access")) return;
selSchool = currentUser.role==="super" ? (selSchool ?? "") : currentUser.schoolId;
await reload();
switchSettingsTab("admin");
});
document.addEventListener("userchange", ()=>{ if(!currentUser) 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");
/* actieknoppen (kopieer/genereer/verwijder) samen in één groep, zodat
ze op smalle schermen als blok rechts blijven staan i.p.v. los
tussen de andere rij-elementen te wrappen */
const actions = h("div","am-actions");
row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : "")));
if(u.role!=="pupil" && can("users.role.change") && u.id!==currentUser.id){
/* super mag de rol van andere stafaccounts wijzigen - leerlingen blijven altijd leerling */
const rs = h("select","am-sel");
["teacher","admin","super"].forEach(r=>rs.appendChild(new Option(T(roleKey(r)), r)));
rs.value = u.role;
rs.addEventListener("change", async ()=>{
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{role: rs.value}}); reload(); }
catch(e){ msg(e.message); }
});
row.appendChild(rs);
/* extra functies naast de hoofdrol (bv. een teacher die ook beheerdersrechten
krijgt) - los van de hoofdrol hierboven, direct toggelbaar per rol */
const extraWrap = h("div","am-classes");
["teacher","admin","super"].filter(r=>r!==u.role).forEach(r=>{
const has = (u.extraRoles||[]).includes(r);
const chip = h("button","am-chip", (has?"✓ ":"+ ")+T(roleKey(r)));
chip.type = "button";
chip.classList.toggle("on", has);
chip.addEventListener("click", async ()=>{
const next = has ? (u.extraRoles||[]).filter(x=>x!==r) : [...(u.extraRoles||[]), r];
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{extraRoles: next}}); reload(); }
catch(e){ msg(e.message); }
});
extraWrap.appendChild(chip);
});
row.appendChild(extraWrap);
}else{
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); });
actions.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); }
});
actions.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(can("users.staffCredentials") && 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); }
});
actions.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); }
});
actions.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); }
});
actions.appendChild(del);
}
if(actions.childNodes.length) row.appendChild(actions);
return row;
}
/* toevoegen vereist een bewuste extra stap: begint ingeklapt als alleen de " …"-knop,
pas na een klik verschijnen de invoervelden. presetClassId zet de klas-select alvast op
de klas waar deze toevoegrij bij hoort (leerkracht/beheerder kan dit nog wijzigen). */
function addForm(role, presetClassId){
const label = T(role==="pupil" ? "amNewPupil" : role==="teacher" ? "amNewTeacher" : role==="admin" ? "amNewAdmin" : "amNewSuper");
const wrap = h("div","am-add-wrap");
const toggle = h("button","tbtn ghost", label);
toggle.type = "button";
wrap.appendChild(toggle);
const f = h("div","am-add");
f.style.display = "none";
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(presetClassId ?? ""); 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", label);
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);
wrap.appendChild(f);
toggle.addEventListener("click", ()=>{
toggle.style.display = "none";
f.style.display = "flex";
name.focus();
});
return wrap;
}
/* 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;
}
/* overkoepelend: schoolkiezer + nieuwe school + overkoepelend-beheerders beheren.
Dit tabblad is waar een super zaken regelt die niet aan één school vastzitten. */
function renderScholenPanel(panel){
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);
panel.appendChild(bar);
/* overkoepelend beheerders horen niet bij één school, dus het aanmaken ervan
staat hier i.p.v. tussen de school-gebonden gebruikers in de Gebruikers-tab
(de lijst van bestaande supers blijft wel gewoon in Gebruikers staan) */
if(creatableRoles().includes("super")) panel.appendChild(addForm("super"));
}
/* klassen (admin/super binnen een school) */
function renderKlassenPanel(panel){
const schoolChosen = currentUser.role!=="super" || !!selSchool;
if(!schoolChosen){
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
return;
}
const canResetPw = can("schools.resetPasswords");
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);
});
panel.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);
panel.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); }
});
panel.appendChild(rsAll);
}
}
/* zoekfilter voor de gebruikerslijst: verbergt niet-matchende rijen en lege groepen
via style.display i.p.v. een her-render, zodat het zoekveld focus houdt tijdens
het typen (een volledige render() zou het veld elke toets opnieuw aanmaken) */
function applyUserFilter(panel){
const q = userFilter.trim().toLowerCase();
const list = panel.querySelector(".am-list");
if(!list) return;
let pendingGroup = null, groupVisible = false;
[...list.children].forEach(el=>{
if(el.classList.contains("am-group")){
if(pendingGroup) pendingGroup.style.display = groupVisible ? "" : "none";
pendingGroup = el; groupVisible = false;
}else if(el.classList.contains("am-row")){
const name = el.querySelector(".am-name");
const match = !q || (name && name.textContent.toLowerCase().includes(q));
el.style.display = match ? "" : "none";
if(match) groupVisible = true;
}
/* .am-add-wrap (toevoegrij) blijft altijd zichtbaar, ongeacht het filter */
});
if(pendingGroup) pendingGroup.style.display = groupVisible ? "" : "none";
}
/* gebruikers: lijst (altijd, ook zonder gekozen school) + aanmaak-formulieren.
Leerlingen krijgen een aparte behandeling: voor schoolbeheerder/super gegroepeerd
per klas (met een inline toevoegrij per klas), voor groepsleiding een platte lijst
(die dankzij de server-side scope toch al beperkt is tot de eigen klas(sen)). */
function renderGebruikersPanel(panel){
const filterBar = h("div","am-add");
const filterInp = h("input","am-inp am-filter");
filterInp.type = "search";
filterInp.placeholder = T("amSearchPh");
filterInp.value = userFilter;
filterInp.addEventListener("keydown", ev=>ev.stopPropagation());
filterInp.addEventListener("input", ()=>{ userFilter = filterInp.value; applyUserFilter(panel); });
filterBar.appendChild(filterInp);
panel.appendChild(filterBar);
const list = h("div","am-list");
const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")]];
staffGroups.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)));
});
const pupils = USERS.filter(u=>u.role==="pupil");
const schoolChosen = currentUser.role!=="super" || !!selSchool;
const creatable = creatableRoles();
const canAddPupil = schoolChosen && creatable.includes("pupil");
/* per-klas indeling heeft alleen zin met één gekozen school (CLASSES is dan
betrouwbaar); zonder gekozen school (super, alle scholen) of voor groepsleiding
gewoon een platte lijst - anders vallen leerlingen met een classId die niet in
de (dan lege) CLASSES-lijst voorkomt onterecht helemaal weg */
if(currentUser.role==="teacher" || !schoolChosen){
if(pupils.length){
list.appendChild(h("div","am-group", T("rolePupil")));
pupils.forEach(u=>list.appendChild(userRow(u)));
}
if(canAddPupil) list.appendChild(addForm("pupil"));
}else{
CLASSES.forEach(c=>{
const us = pupils.filter(u=>u.classId===c.id);
if(!us.length && !canAddPupil) return;
list.appendChild(h("div","am-group", c.name));
us.forEach(u=>list.appendChild(userRow(u)));
if(canAddPupil) list.appendChild(addForm("pupil", c.id));
});
const noClass = pupils.filter(u=>!u.classId);
if(noClass.length || canAddPupil){
list.appendChild(h("div","am-group", T("amNoClass")));
noClass.forEach(u=>list.appendChild(userRow(u)));
if(canAddPupil) list.appendChild(addForm("pupil"));
}
}
panel.appendChild(list);
/* staf-aanmaakformulieren - welke rollen deze gebruiker mag aanmaken (CREATABLE_ROLES).
"super" aanmaken staat in het Overkoepelend-tabblad, niet hier (die rol hoort niet
bij één school). */
if(schoolChosen){
["teacher","admin"].forEach(r=>{ if(creatable.includes(r)) panel.appendChild(addForm(r)); });
}
applyUserFilter(panel);
}
function switchAdminTab(tab){
adminTab = tab;
modal.querySelectorAll(".ed-tabs button").forEach(b=>b.classList.toggle("active", b.dataset.tab===tab));
modal.querySelectorAll(".am-tabpanel").forEach(p=>{ p.style.display = p.dataset.panel===tab ? "block" : "none"; });
}
function render(){
modal.innerHTML = "";
modal.appendChild(h("div","am-msg"));
const rp = resetPanel();
if(rp) modal.appendChild(rp);
const tabDefs = [];
if(currentUser.role==="super") tabDefs.push(["scholen", T("amOverarching")]);
tabDefs.push(["klassen", T("amClasses")]);
tabDefs.push(["gebruikers", T("amUsers")]);
if(!tabDefs.some(([t])=>t===adminTab)) adminTab = tabDefs[0][0];
const tabsBar = h("div","ed-tabs");
tabDefs.forEach(([t, label])=>{
const b = h("button", null, label);
b.type = "button";
b.dataset.tab = t;
b.addEventListener("click", ()=>switchAdminTab(t));
tabsBar.appendChild(b);
});
modal.appendChild(tabsBar);
const panels = { scholen: renderScholenPanel, klassen: renderKlassenPanel, gebruikers: renderGebruikersPanel };
tabDefs.forEach(([t])=>{
const panel = h("div","am-tabpanel");
panel.dataset.panel = t;
panels[t](panel);
modal.appendChild(panel);
});
switchAdminTab(adminTab);
}
})();