/* 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; /* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw opgehaald bij elke reload() zoals CLASSES/USERS hierboven */ let ASSIGNMENTS = {}; /* 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,"&").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-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 || []; 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; }); } }catch(e){ USERS = []; CLASSES = []; ASSIGNMENTS = {}; } 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; } /* systeem: schoolkiezer + nieuwe school + systeemmanagers 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); /* systeemmanagers 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); } } /* toewijzingsrij: kies een van je eigen borden (uit BS, board.js) voor een klas of een individuele leerling. Geen momentopname - de leerling-pagina zoekt het bord live op, dus nieuwe woorden die je toevoegt komen vanzelf door zonder opnieuw toe te wijzen. Een expliciete "Toewijzen"-knop i.p.v. direct bij het kiezen van een bord opslaan, zodat een klas-brede wijziging niet per ongeluk gebeurt. */ /* widgets van een eigen bord (voor de één-widget-keuze in assignmentRow); widgets zonder wid komen uit borden van vóór v0.3.13 en verschijnen pas na de eerstvolgende save van dat bord */ function boardWidgets(boardId){ let found = null; BS.folders.forEach(fo=>fo.boards.forEach(bo=>{ if(bo.id===boardId) found = bo; })); return ((found && found.data && found.data.widgets) || []).filter(w=>w.wid); } 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, boardOptions, current){ const row = h("div","am-row"); row.appendChild(h("span","am-name", target.label)); const sel = h("select","am-sel"); sel.appendChild(new Option(T("amNoAssignment"), "")); boardOptions.forEach(b=>sel.appendChild(new Option(b.label, b.id))); sel.value = current ? current.boardId : ""; /* keuze: heel bord (default, bestaand gedrag) of één widget + modus */ const widgetSel = h("select","am-sel am-widget-sel"); const modeSel = h("select","am-sel am-mode-sel"); modeSel.appendChild(new Option(T("amModeWork"), "werken")); modeSel.appendChild(new Option(T("amModeWatch"), "kijken")); const syncWidgetSel = ()=>{ widgetSel.innerHTML = ""; widgetSel.appendChild(new Option(T("amWholeBoard"), "")); boardWidgets(sel.value).forEach(w=>widgetSel.appendChild(new Option(T("wg_"+w.id), w.wid))); widgetSel.value = (current && current.boardId===sel.value && current.widgetId) ? current.widgetId : ""; widgetSel.style.display = sel.value ? "" : "none"; syncModeSel(); }; const syncModeSel = ()=>{ modeSel.style.display = (sel.value && widgetSel.value) ? "" : "none"; }; modeSel.value = current && current.mode ? current.mode : "werken"; sel.addEventListener("change", syncWidgetSel); widgetSel.addEventListener("change", syncModeSel); const info = h("span","am-code", describeCurrent(current)); row.appendChild(sel); row.appendChild(widgetSel); row.appendChild(modeSel); row.appendChild(info); syncWidgetSel(); const apply = h("button","am-btn", T("amAssign")); apply.type = "button"; apply.addEventListener("click", async ()=>{ try{ if(sel.value){ const body = { boardId: sel.value }; if(widgetSel.value){ body.widgetId = widgetSel.value; body.mode = modeSel.value; } await api(`/assignments/${target.type}/${target.id}`, {method:"PUT", body}); } else await api(`/assignments/${target.type}/${target.id}`, {method:"DELETE"}); if(target.type==="class"){ await reload(); } else{ const a = (await api("/assignments/pupil/"+target.id)).assignment; info.textContent = describeCurrent(a); msg(T("amAssignSaved")); } }catch(e){ msg(e.message); } }); row.appendChild(apply); return row; } /* vult een