teach/public/js/admin.js
Ramon 2ceeec2de2
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
v0.2.19-beta: leerlingen per klas in het beheerpaneel + groepsleiding-scope + extra stap bij toevoegen
- GET /admin/users: groepsleiding (teacher) ziet voortaan alleen leerlingen uit de eigen klas(sen) (via class_teachers), i.p.v. de hele school - staf blijft zichtbaar zoals voorheen
- Gebruikers-tab: leerlingen worden voor schoolbeheerder/super gegroepeerd per klas getoond (met een "geen klas"-groep), i.p.v. één platte lijst los van de Klassen-tab; groepsleiding houdt een platte lijst (nu vanzelf al beperkt tot eigen klas door de backend-scope)
- Alle "gebruiker toevoegen"-formulieren (staf + leerling, incl. de nieuwe per-klas toevoegrij) starten ingeklapt als alleen een "+ …"-knop; pas na een klik verschijnen de invoervelden
- Verwijderen van een gebruiker had al een bevestigingsvraag - ongewijzigd, bevestigd dat dit blijft werken
- Koppelcode-functie bewust ongewijzigd gelaten (privacy-optie naast direct-wachtwoord-aanmaken)

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

465 lines
19 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";
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);
}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;
}
/* super: schoolkiezer + nieuwe school */
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);
}
/* klassen (admin/super binnen een school) */
function renderKlassenPanel(panel){
const schoolChosen = currentUser.role!=="super" || !!selSchool;
if(!schoolChosen) 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);
}
}
/* 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 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 = CREATABLE_ROLES[currentUser.role];
const canAddPupil = schoolChosen && creatable.includes("pupil");
if(currentUser.role==="teacher"){
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) */
if(schoolChosen){
["teacher","admin"].forEach(r=>{ if(creatable.includes(r)) panel.appendChild(addForm(r)); });
}
if(creatable.includes("super")) panel.appendChild(addForm("super"));
}
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("amSchools")]);
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);
}
})();