/* teach - beheerpaneel (👥): scholen, klassen en gebruikers als één drill-down navigator (tegel-overzicht → gefocust detail, i.p.v. alles tegelijk uitgeklapt op één lange pagina). 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"); let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null; /* schoolbeheerders van alle scholen, voor de schooltegels op "alle scholen" */ let SYS_ADMINS = []; /* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw opgehaald bij elke reload() zoals CLASSES/USERS hierboven */ let ASSIGNMENTS = {}; /* openstaande aanmeldverzoeken van ouders (thuiskind -> deze school) */ let LINK_REQUESTS = []; /* 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 = "school"; /* navigatie binnen "School": welk niveau van de boom je ziet. {type:"overview"} - tegels (scholen op "alle scholen", anders klassen+platte groepen van de gekozen school) {type:"class", id} - gefocust detail van één klas {type:"flat", kind} - gefocust detail van één platte groep (admins/supers/parents/noschool/unassigned/requests) Blijft behouden over her-renders, reset bij het wisselen van school en bij in-/uitloggen - anders belandt een systeemmanager na het kiezen van een andere school midden in de klas van de vórige school. */ let navLevel = { type:"overview" }; /* welke sheet (persoon of aanmelding) open staat; blijft ook over reload() heen zodat een actie ín de sheet (nieuw wachtwoord, klas koppelen) de sheet niet laat verdwijnen maar met verse data laat herladen - zelfde reden als waarom rowResults/resetResults hieronder blijven staan. */ let openSheet = null; /* uitgeklapt "⋯"-actiepaneel (klas- of schoolbeheer) op het huidige niveau; hoort bij navLevel, dus reset zodra dat wijzigt */ let kebabOpen = false; /* systeembrede zoekopdracht: vult, op het tegel-niveau, het scherm met een platte resultatenlijst i.p.v. tegels */ let searchQuery = ""; /* 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] || []))]; /* wie er als groepsleiding aan een klas gekoppeld kan staan - zelfde rollenset als de server accepteert in POST /admin/classes/:id/teachers (niet alleen "teacher": een schoolbeheerder of systeemmanager mag ook aan een klas gekoppeld worden, en moet dan ook als zodanig getoond worden). */ const isClassStaff = u => ["teacher","admin","super"].includes(u.role); const esc = s => String(s ?? "").replace(/&/g,"&").replace(/{ 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-btn", T("amCopy")); b.type = "button"; 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)); }; /* ---- één interactiesysteem voor het hele beheer --------------------------- field(): elk invoerveld/select krijgt een zichtbaar mini-label erboven (een placeholder alleen is geen toelichting). dangerBtn(): acties met gevolgen (wachtwoord/koppelcode vernieuwen, verwijderen, resetten) zien er anders uit dan gewone knoppen en vragen een tweede klik als bevestiging (zelfde arm-then-confirm als elders in de app); wapening loopt vanzelf af. Geen native confirm()-dialogen meer. rowResults: nieuw gegenereerde wachtwoorden/codes verschijnen direct bij de gebruiker in de sheet (met kopieerknop). */ function field(labelKey, control){ const w = h("label","am-field"); w.appendChild(h("span","am-field-lbl", T(labelKey))); w.appendChild(control); return w; } function dangerBtn(label, warnKey, fn){ const b = h("button","am-btn am-danger", label); b.type = "button"; if(warnKey) b.title = T(warnKey); let timer = null; b.addEventListener("click", async ()=>{ if(!timer){ b.classList.add("armed"); b.textContent = T("amSure"); timer = setTimeout(()=>{ timer = null; b.classList.remove("armed"); b.textContent = label; }, 4000); return; } clearTimeout(timer); timer = null; b.classList.remove("armed"); b.textContent = label; await fn(b); }); return b; } /* per gebruiker het laatste credential-resultaat, overleeft de re-render na de actie zodat het bij de rij zichtbaar is tot het paneel sluit */ const rowResults = new Map(); document.addEventListener("userchange", ()=>rowResults.clear()); function resultBadge(u){ const r = rowResults.get(u.id); if(!r) return null; const badge = h("div","am-result"); badge.appendChild(h("span", null, r.label + ":")); const code = h("code", null, r.value); badge.appendChild(code); badge.appendChild(copyBtn(r.value)); return badge; } /* ---- groepskaart: één kader om wat bij elkaar hoort ------------------------ Nog gebruikt door assignmentRow (Toewijzingen) en de Opslag-tab (image-catalog.js) - niet meer door dit bestand zelf. */ function groupCard(opts){ const openSet = opts.openSet || new Set(); const editSet = opts.editSet || new Set(); const card = h("div","am-card"); const head = h("div","am-card-head"); card.appendChild(head); if(opts.icon) head.appendChild(h("span","am-ico", opts.icon)); head.appendChild(h("span","am-card-title", opts.title)); const sum = h("span","am-card-sum"); head.appendChild(sum); let tools = null; if(opts.edit){ tools = h("div","am-card-tools"); tools.appendChild(opts.edit.tools); const btn = h("button","am-edit", T("amEdit")); btn.type = "button"; const ekey = opts.edit.key; const esync = ()=>{ const open = editSet.has(ekey); tools.style.display = open ? "" : "none"; btn.classList.toggle("on", open); }; btn.addEventListener("click", ()=>{ if(editSet.has(ekey)) editSet.delete(ekey); else editSet.add(ekey); esync(); }); esync(); head.appendChild(btn); card.appendChild(tools); } if(opts.subs && opts.subs.length){ const subHost = h("div","am-card-subs"); opts.subs.forEach(el=>subHost.appendChild(el)); card.appendChild(subHost); sum.classList.add("am-card-toggle"); const sync = ()=>{ const open = openSet.has(opts.key); sum.textContent = `${opts.summary} ${open ? "▾" : "▸"}`; subHost.style.display = open ? "" : "none"; }; sum.addEventListener("click", ()=>{ if(openSet.has(opts.key)) openSet.delete(opts.key); else openSet.add(opts.key); sync(); }); sync(); }else if(opts.summary){ sum.textContent = opts.summary; } return card; } window.amGroupCard = groupCard; /* ---- beheer-secties in het instellingen-register (settings.js) ------------ Elke sectie meldt zichzelf aan met rol-zichtbaarheid, een activate() en zoekgegevens; de schil bouwt daar navigatie + zoeken uit. Scholen, klassen en gebruikers zijn hier bewust ÉÉN sectie ("school") die intern een eigen drill-down navigator opbouwt (zie renderSchoolNavigator). */ const ADMIN_SECTIONS = [ { tab:"school", icon:"🏫", group:"school", order:10, labelKey:"amSchoolMenu", subKey:"stSchoolMenuSub", show:()=>true, keywords:()=>[T("amPupils"), T("roleTeacher"), T("roleAdmin"), T("amParents"), T("amLinkRequests"), T("amNewPw"), T("amParentCode"), "leerling","pupil","ouder","parent","wachtwoord","password", "koppelcode","aanmelding","enrolment", T("amNewClass"), T("amResetClass"), "klas","class","groep","group", T("amNewSchool"), T("amNewSuper"), "school","systeemmanager","system manager"], search:q=>[ ...USERS .filter(u=>`${u.displayName} ${u.username}`.toLowerCase().includes(q)) .map(u=>({ icon: u.role==="pupil" ? "🧒" : u.role==="parent" ? "👪" : "🧑‍🏫", label:u.displayName, sub:T(roleKey(u.role)), go:()=>goSettingsSection("school", { openPerson:u.id }) })), ...CLASSES .filter(c=>c.name.toLowerCase().includes(q)) .map(c=>({ icon:"🏷", label:c.name, sub:T("amClasses"), go:()=>{ navLevel={type:"class", id:c.id}; kebabOpen=false; goSettingsSection("school"); } })), ...SCHOOLS .filter(s=>s.name.toLowerCase().includes(q)) .map(s=>({ icon:"🏫", label:s.name, sub:T("amSchools"), go:()=>{ selSchool=String(s.id); navLevel={type:"overview"}; goSettingsSection("school"); } })), ] }, { tab:"toewijzingen", icon:"🎯", group:"school", order:12, labelKey:"amAssignments", subKey:"stToewijzSub", show:()=>can("assignments.manage"), keywords:()=>[T("amModeWatch"), T("amModeWork"), "bord","board","delen","share","toewijzen","assign","widget"] }, { tab:"voortgang", icon:"📈", group:"school", order:13, labelKey:"amProgress", subKey:"stVoortgangSub", show:()=>can("progress.view"), keywords:()=>["voortgang","progress","score","resultaten","results","tijdlijn","timeline"] }, { tab:"opslag", icon:"🖼", group:"system", order:32, labelKey:"stOpslag", subKey:"stOpslagSub", show:()=>currentUser.role==="super", keywords:()=>["opslag","storage","afbeeldingen","images","quota"] }, ]; const adminTabDefs = ()=>ADMIN_SECTIONS.filter(d=>d.show()).map(d=>[d.tab, T(d.labelKey)]); /* data één keer per bezoek aan Instellingen laden; secties onderling hergebruiken de lading (in-flight promise voorkomt dubbel laden) */ let loadPromise = null; function ensureLoaded(force){ if(force) loadPromise = null; if(!loadPromise){ loadPromise = (async ()=>{ /* super: selSchool null = nog niet gekozen (reload() zet dan "alle scholen" als default). Andere rollen: eigen school. */ if(currentUser.role!=="super") selSchool = currentUser.schoolId; await reload(); })(); } return loadPromise; } /* settings.js roept dit bij het openen aan: verse data + zoekindex vullen */ window.adminPrefetch = ()=>{ ensureLoaded(true).catch(()=>{}); }; ADMIN_SECTIONS.forEach(d=>{ registerSettingsSection({ id:d.tab, icon:d.icon, group:d.group, order:d.order, label:()=>T(d.labelKey), sub:()=>T(d.subKey), visible:()=>!!currentUser && can("admin.access") && d.show(), async activate(payload){ await ensureLoaded(); if(d.tab==="school" && payload && payload.openPerson !== undefined) openSheet = { type:"person", id:payload.openPerson }; showAdminHost(); switchAdminTab(d.tab); if(d.tab==="school") render(); }, keywords:d.keywords, searchItems:d.search, }); }); document.addEventListener("userchange", ()=>{ if(!currentUser) resetResults = null; loadPromise = null; selSchool = null; /* nieuwe sessie: laat reload() opnieuw defaulten */ navLevel = { type:"overview" }; openSheet = null; kebabOpen = false; searchQuery = ""; }); async function reload(){ let reloadError = null; try{ if(currentUser.role==="super"){ SCHOOLS = await api("/schools"); /* eerste bezoek: standaard "alle scholen" (het scholenoverzicht) i.p.v. meteen een school te kiezen - de systeemmanager beheert immers over scholen heen; een specifieke school kiezen is een bewuste stap. */ if(selSchool == null) selSchool = ""; } const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : ""; USERS = (await api("/admin/users"+q)).users; /* schooltegels op "alle scholen" tonen schoolbeheerders van élke school; zonder schoolfilter is USERS al compleet en is de extra call overbodig */ if(currentUser.role==="super"){ SYS_ADMINS = q ? ((await api("/admin/users")).users || []).filter(u=>u.role==="admin") : USERS.filter(u=>u.role==="admin"); } const cq = currentUser.role==="super" && selSchool ? "?school="+selSchool : ""; CLASSES = (await api("/admin/classes"+cq)).classes || []; ASSIGNMENTS = {}; if(CLASSES.length){ const results = await Promise.all(CLASSES.map(c=>api("/assignments/class/"+c.id).catch(()=>({assignment:null})))); CLASSES.forEach((c,i)=>{ ASSIGNMENTS[c.id] = results[i].assignment; }); } LINK_REQUESTS = []; if(currentUser.role!=="super" || selSchool){ const lq = currentUser.role==="super" ? "?school="+selSchool : ""; LINK_REQUESTS = ((await api("/admin/link-requests"+lq).catch(()=>({}))).requests) || []; } }catch(e){ USERS = []; CLASSES = []; ASSIGNMENTS = {}; LINK_REQUESTS = []; reloadError = e; } render(); /* render() bouwt de modal (incl. .am-msg) helemaal opnieuw op, dus de melding pas ná render() zetten - anders wordt hij meteen weer gewist. Zonder dit bleef het paneel bij een fout stilletjes helemaal leeg staan zonder enige aanwijzing waarom. */ if(reloadError) msg(T("amLoadError") + ": " + reloadError.message); } 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 avatar(nameStr, big){ const parts = String(nameStr || "?").trim().split(/\s+/); const ini = ((parts[0][0] || "?") + (parts[1] ? parts[1][0] : "")).toUpperCase(); return h("span", "am-avatar" + (big ? " big" : ""), ini); } /* status compact als glyph met uitleg-title: ● in gebruik, ○ wacht nog op eerste login/activatie. */ function userStatusGlyph(u){ const waiting = u.pending || (u.role==="pupil" && !u.everLoggedIn); const label = u.pending ? T("amPending") : (u.role==="pupil" && !u.everLoggedIn) ? T("amNeverLoggedIn") : T("amStatusActive"); const g = h("span","am-glyph" + (waiting ? "" : " ok"), waiting ? "○" : "●"); g.title = label; g.setAttribute("aria-label", label); return g; } /* het bewerkformulier van één gebruiker; wordt in de sheet getoond zodra iemand op een personRow tikt. */ function userTools(u, note){ note = note || msg; const tools = h("div","am-tools"); const actions = h("div","am-actions"); const isStaff = ["teacher","admin","super"].includes(u.role); /* school koppelen/losmaken (systeemmanager): losse accounts terugzetten naar hun school, of bewust losmaken - super/ouder zijn per definitie schoolloos en vallen hierbuiten */ if(currentUser.role==="super" && ["teacher","admin","pupil"].includes(u.role)){ const ss = h("select","am-sel"); ss.appendChild(new Option(T("amNoSchool"), "")); SCHOOLS.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id))); ss.value = u.schoolId ?? ""; ss.addEventListener("change", async ()=>{ try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{ schoolId: ss.value ? +ss.value : null }}); reload(); } catch(e){ note(e.message); ss.value = u.schoolId ?? ""; } }); tools.appendChild(field("amFieldSchool", ss)); } /* rolwijziging: alleen tussen groepsleiding/schoolbeheerder onderling. Systeemmanager is hier bewust nooit een optie - die rol ontstaat alleen via de aparte "Nieuwe systeemmanager"-aanmaak (met bevestiging van het eigen wachtwoord), nooit door een bestaand account te promoveren. Een bestaande systeemmanager toont dit veld daarom ook niet: er is geen veilige weg om die rol via dit simpele dropdown-veld te wijzigen. */ if(isStaff && u.role!=="super" && can("users.role.change") && u.id!==currentUser.id){ const rs = h("select","am-sel"); ["teacher","admin"].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){ note(e.message); } }); tools.appendChild(field("amFieldRole", rs)); } /* elke staf-rol eenvoudig aan klassen koppelen (groepsleiding, schoolbeheerder én systeemmanager): huidige klassen als chips met ✕, en een select om een klas toe te voegen */ if(isStaff && can("classes.manage") && CLASSES.length){ const wrap = h("div","am-classes"); (u.classIds||[]).forEach(cid=>{ const c = CLASSES.find(x=>Number(x.id)===Number(cid)); if(!c) return; const chip = h("span","am-chip on", c.name); const x = h("button","am-chipx","✕"); x.type = "button"; x.title = T("amClassRemove"); x.addEventListener("click", async ()=>{ try{ await api("/admin/classes/"+cid+"/teachers", {body:{ userId: u.id, remove:true }}); reload(); } catch(e){ note(e.message); } }); chip.appendChild(x); wrap.appendChild(chip); }); const rest = CLASSES.filter(c=>!(u.classIds||[]).some(id=>Number(id)===Number(c.id))); if(rest.length){ const addSel = h("select","am-sel"); addSel.appendChild(new Option(T("amClassAdd"), "")); rest.forEach(c=>addSel.appendChild(new Option(c.name, c.id))); addSel.addEventListener("change", async ()=>{ if(!addSel.value) return; try{ await api("/admin/classes/"+addSel.value+"/teachers", {body:{ userId: u.id }}); reload(); } catch(e){ note(e.message); } }); wrap.appendChild(addSel); } tools.appendChild(field("amFieldClasses", wrap)); } 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}}); reload(); } catch(e){ note(e.message); } }); tools.appendChild(field("amFieldClass", cs)); /* wachtwoord inzien en aanpassen - alleen zichtbaar zolang de leerling nog niet is ingelogd; daarna kan alleen een nieuw wachtwoord gezet worden */ const pwWrap = h("div","am-inline"); const pw = h("input","am-pw"); pw.value = u.password || ""; pw.placeholder = u.everLoggedIn ? T("amPwHidden") : T("amPwTypePh"); 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){ note(e.message); } }); pwWrap.appendChild(pw); const pwCopy = h("button","am-btn", T("amCopy")); pwCopy.type = "button"; pwCopy.addEventListener("click", e=>{ e.preventDefault(); if(pw.value) copyText(pwCopy, pw.value); }); pwWrap.appendChild(pwCopy); tools.appendChild(field("amFieldPw", pwWrap)); if(u.everLoggedIn){ actions.appendChild(dangerBtn(T("amNewPw"), "amNewPwWarn", async ()=>{ try{ const { password } = await api("/admin/genpw?role=pupil"); await api("/admin/users/"+u.id, {method:"PATCH", body:{password}}); rowResults.set(u.id, { label: T("amResultPw"), value: password }); reload(); }catch(e){ note(e.message); } })); } /* eenmalige ouder-code waarmee een ouder dit kind aan het eigen ouderaccount koppelt (ouderportaal); een eerdere code vervalt */ actions.appendChild(dangerBtn(T("amParentCode"), "amParentCodeWarn", async ()=>{ try{ const r = await api("/admin/users/"+u.id+"/parent-code", { body:{} }); rowResults.set(u.id, { label: T("amResultParentCode"), value: r.parentCode }); reload(); }catch(e){ note(e.message); } })); }else{ if(u.pending && u.linkCode){ tools.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode)); } if(can("users.staffCredentials") && u.role!=="pupil" && u.id!==currentUser.id){ actions.appendChild(dangerBtn(T("amNewCode"), "amNewCodeWarn", async ()=>{ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}}); rowResults.set(u.id, { label: T("amResultCode"), value: r.linkCode }); reload(); }catch(e){ note(e.message); } })); /* direct een nieuw wachtwoord zetten, zonder de koppelcode-activatie */ actions.appendChild(dangerBtn(T("amNewPw"), "amNewPwWarn", async ()=>{ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}}); rowResults.set(u.id, { label: T("amResultPw"), value: r.password }); reload(); }catch(e){ note(e.message); } })); } } if(u.id !== currentUser.id){ const del = dangerBtn(T("amDelete"), "amDeleteWarn", async ()=>{ try{ await api("/admin/users/"+u.id, {method:"DELETE"}); openSheet = null; reload(); } catch(e){ note(e.message); } }); actions.appendChild(del); } if(actions.childNodes.length) tools.appendChild(field("amFieldActions", actions)); return tools; } /* 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"; /* het formulier zelf herhaalt wat je aan het toevoegen bent, zodat het nooit met een bestaande-gebruikersrij te verwarren is zodra de knoptekst erboven uit beeld is (zie ook .am-add's eigen kader in css) */ f.appendChild(h("div","am-add-title", label)); const name = h("input","am-inp"); name.placeholder = T("amUserPh"); name.addEventListener("keydown", ev=>ev.stopPropagation()); f.appendChild(field("amFieldName", name)); let cs = null; if(role==="pupil"){ cs = classSel(presetClassId ?? ""); f.appendChild(field("amFieldClass", 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) */ const pwWrap = h("div","am-inline"); pwInp = h("input","am-inp am-pwinp"); pwInp.type = "password"; pwInp.autocomplete = "new-password"; pwInp.placeholder = T("amPwOptional"); pwInp.addEventListener("keydown", ev=>ev.stopPropagation()); pwWrap.appendChild(pwInp); const gen = h("button","am-btn", T("amGenerate")); gen.type = "button"; gen.addEventListener("click", async ()=>{ try{ const { password } = await api("/admin/genpw"); pwInp.type = "text"; pwInp.value = password; }catch(e){ msg(e.message); } }); pwWrap.appendChild(gen); f.appendChild(field("amFieldPw", pwWrap)); } /* een systeemmanager aanmaken is de meest ingrijpende actie in dit beheer (school-onafhankelijk, alle rechten overal) - vereist daarom als extra stap het eigen wachtwoord van de aanmaker zelf, ter bevestiging. De server controleert dit ook; dit is niet louter cosmetisch. */ let confirmInp = null; if(role==="super"){ confirmInp = h("input","am-inp"); confirmInp.type = "password"; confirmInp.autocomplete = "current-password"; confirmInp.placeholder = T("amOwnPasswordPh"); confirmInp.addEventListener("keydown", ev=>ev.stopPropagation()); f.appendChild(field("amOwnPassword", confirmInp)); } const go = h("button","tbtn", label); go.type = "button"; go.disabled = true; const sync = ()=>{ go.disabled = name.value.trim().length < 2 || (confirmInp && !confirmInp.value); }; name.addEventListener("input", sync); if(confirmInp) confirmInp.addEventListener("input", sync); /* fouten horen bij het formulier zelf, niet ergens bovenaan het paneel */ const errEl = h("div","am-form-err"); go.addEventListener("click", async ()=>{ errEl.textContent = ""; 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; if(confirmInp) body.confirmPassword = confirmInp.value; try{ const r = await api("/admin/users", { body }); /* groepsleiding die vanuit een klasrij wordt aangemaakt, hoort ook meteen aan die klas gekoppeld te zijn - anders staat de nieuwe leerkracht wel in "Groepsleiding" toegevoegd, maar (nog) niet echt bij die klas. Hergebruikt de al bestaande koppel-route. */ if(role==="teacher" && presetClassId){ await api("/admin/classes/"+presetClassId+"/teachers", { body:{ userId: r.user.id } }); } name.value = ""; if(pwInp) pwInp.value = ""; if(confirmInp) confirmInp.value = ""; rowResults.set(r.user.id, { label: r.user.password ? T("amResultPw") : T("amResultCode"), value: r.user.password || r.user.linkCode, }); reload(); }catch(e){ errEl.textContent = e.message; } }); f.appendChild(go); f.appendChild(errEl); 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); /* export als uitdeelvel - alleen mogelijk zolang dit scherm openstaat: de wachtwoorden zijn hierna nergens meer in leesbare vorm te achterhalen (alleen het gehashte wachtwoord blijft in de database), dus dit is bewust "exporteer wat nu net gegenereerd is", niet "haal wachtwoorden van bestaande accounts op". Gebruikersnaam (het echte inlog-veld) staat er wél bij, ook al toont de schermlijst hierboven alleen de naam. */ const exportBtn = h("button","am-btn", T("amExportCsv")); exportBtn.type = "button"; exportBtn.addEventListener("click", e=>{ e.preventDefault(); const header = [T("amExportColPupil"), T("amExportColUsername"), T("amResultPw")]; const rows = [header, ...resetResults.entries.map(en=>[en.displayName, en.username, en.password])]; const safeName = resetResults.title.replace(/[^a-z0-9]+/gi, "-"); downloadCsv(`${safeName}.csv`, rows); }); box.appendChild(exportBtn); 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; } /* toewijzingsrij: overzicht van de huidige toewijzing (aangemaakt via de Cast-knop op het whiteboard, cast.js) + loskoppelen. Aanmaken/wijzigen gebeurt niet meer hier - zie cast.js. */ function describeCurrent(current){ if(!current) return ""; let s = `${T("amAssignedAs")} “${current.boardName || current.boardId}”`; if(current.widgetId){ const wName = current.widgetType ? T("wg_"+current.widgetType) : current.widgetId; s += ` › ${wName} (${current.mode==="kijken" ? T("amModeWatch") : T("amModeWork")})`; } return s; } function assignmentRow(target, current){ const info = h("span","am-code", describeCurrent(current) || T("amNoAssignment")); const tools = h("div","am-tools"); if(current){ const del = dangerBtn(T("amUnlink"), null, async ()=>{ try{ await api(`/assignments/${target.type}/${target.id}`, {method:"DELETE"}); if(target.type==="class"){ await reload(); } else{ info.textContent = T("amNoAssignment"); del.remove(); msg(T("amAssignSaved")); } }catch(e){ msg(e.message); } }); tools.appendChild(del); } tools.appendChild(info); return groupCard({ title: target.label, summary: describeCurrent(current) || T("amNoAssignment"), edit: { key: "assign:"+target.type+":"+target.id, tools }, }); } /* vult een