diff --git a/VERSION b/VERSION index 53a40fa..f11c412 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.33-beta +0.3.34-beta diff --git a/public/css/teach.css b/public/css/teach.css index eb2d4d4..870c31a 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1164,6 +1164,23 @@ } .am-edit:hover{opacity:1; background:var(--surface-3);} .am-edit.on{opacity:1; background:var(--accent-soft);} +/* één interactiesysteem: mini-label boven elk veld/select/knopgroep */ +.am-field{display:flex; flex-direction:column; gap:3px;} +.am-field-lbl{ + font-size:10.5px; font-weight:800; color:var(--muted); + text-transform:uppercase; letter-spacing:.4px; +} +.am-inline{display:flex; gap:6px; align-items:center;} +/* gevaarlijke acties: afwijkende kleur + tweede klik als bevestiging */ +.am-btn.am-danger{background:var(--red-soft); color:var(--red); font-weight:800;} +.am-btn.am-danger.armed{background:var(--red); color:#fff;} +/* credential-resultaat direct bij de gebruikersrij */ +.am-result{ + display:flex; align-items:center; gap:8px; width:100%; + background:var(--accent-soft); color:var(--accent-ink); + border-radius:10px; padding:8px 12px; font-weight:800; +} +.am-result code{font-size:16px; letter-spacing:.5px;} .am-row{ display:flex; gap:8px; align-items:center; background:var(--surface-2); border-radius:var(--radius); padding:7px 10px; flex-wrap:wrap; diff --git a/public/js/admin.js b/public/js/admin.js index d1bec9a..a2eaad3 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -64,6 +64,55 @@ 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 rij (met kopieerknop), niet bovenaan. */ + 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; + } + /* de beheer-tabs staan als losse knoppen in de ene instellingen-tabbalk (#adminTabsSlot in core.js/index.html) - geen geneste tabbalk meer */ const adminTabDefs = ()=>{ @@ -151,7 +200,7 @@ try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{role: rs.value}}); reload(); } catch(e){ msg(e.message); } }); - tools.appendChild(rs); + tools.appendChild(field("amFieldRole", rs)); /* extra functies naast de hoofdrol (bv. een teacher die ook beheerdersrechten krijgt) - los van de hoofdrol hierboven, direct toggelbaar per rol */ @@ -168,7 +217,7 @@ }); extraWrap.appendChild(chip); }); - tools.appendChild(extraWrap); + tools.appendChild(field("amFieldExtraRoles", extraWrap)); } if(u.role==="pupil"){ /* klas (uitwisselen met andere klassen) */ @@ -177,75 +226,71 @@ try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{classId: cs.value ? +cs.value : null}}); } catch(e){ msg(e.message); } }); - tools.appendChild(cs); + 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") : ""; - pw.title = T("amPw"); + 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){ msg(e.message); } }); - tools.appendChild(pw); + pwWrap.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); + pwWrap.appendChild(pwCopy); + tools.appendChild(field("amFieldPw", pwWrap)); if(u.everLoggedIn){ - const gen = h("button","am-btn","🎲"); - gen.title = T("amGenerate"); - gen.addEventListener("click", async ()=>{ + 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}}); - await reload(); - msg(`${T("amPwSet")} ${password}`, password); + rowResults.set(u.id, { label: T("amResultPw"), value: password }); + openRows.add(u.id); + reload(); }catch(e){ msg(e.message); } - }); - actions.appendChild(gen); + })); } }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){ - const nc = h("button","am-btn", T("amNewCode")); - nc.addEventListener("click", async ()=>{ + actions.appendChild(dangerBtn("🔑 " + T("amNewCode"), "amNewCodeWarn", async ()=>{ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}}); - await reload(); - msg(`${T("amCreated")} ${r.linkCode}`, r.linkCode); + rowResults.set(u.id, { label: T("amResultCode"), value: r.linkCode }); + openRows.add(u.id); + reload(); }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 ()=>{ + actions.appendChild(dangerBtn("🎲 " + T("amNewPw"), "amNewPwWarn", async ()=>{ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}}); - await reload(); - msg(`${T("amPwSet")} ${r.password}`, r.password); + rowResults.set(u.id, { label: T("amResultPw"), value: r.password }); + openRows.add(u.id); + reload(); }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; + const del = dangerBtn("🗑 " + T("amDelete"), "amDeleteWarn", async ()=>{ try{ await api("/admin/users/"+u.id, {method:"DELETE"}); reload(); } catch(e){ msg(e.message); } }); actions.appendChild(del); } - if(actions.childNodes.length) tools.appendChild(actions); + const badge = resultBadge(u); + if(badge) tools.appendChild(badge); + if(actions.childNodes.length) tools.appendChild(field("amFieldActions", actions)); if(tools.childNodes.length){ const edit = h("button","am-edit","✏"); edit.type = "button"; @@ -281,19 +326,20 @@ const name = h("input","am-inp"); name.placeholder = T("amUserPh"); name.addEventListener("keydown", ev=>ev.stopPropagation()); - f.appendChild(name); + f.appendChild(field("amFieldName", name)); let cs = null; - if(role==="pupil"){ cs = classSel(presetClassId ?? ""); f.appendChild(cs); } + 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()); - f.appendChild(pwInp); + pwWrap.appendChild(pwInp); const gen = h("button","am-btn","🎲"); gen.type = "button"; gen.title = T("amGenerate"); @@ -304,9 +350,13 @@ pwInp.value = password; }catch(e){ msg(e.message); } }); - f.appendChild(gen); + pwWrap.appendChild(gen); + f.appendChild(field("amFieldPw", pwWrap)); } const go = h("button","tbtn", label); + go.type = "button"; + go.disabled = true; + name.addEventListener("input", ()=>{ go.disabled = name.value.trim().length < 2; }); 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; @@ -370,20 +420,38 @@ } /* systeem: nieuwe school + systeemmanagers beheren. Dit tabblad is waar een - super zaken regelt die niet aan één school vastzitten; de schoolkiezer - zelf staat als vaste contextbalk boven álle beheer-tabs (schoolContextBar), - zodat geen enkel tabblad meer afhankelijk is van een ander tabblad. */ + super zaken regelt die niet aan één school vastzitten; de schoolkiezer- + contextbalk wordt hier juist verborgen (zie switchAdminTab), want niets + op dit tabblad hangt van een gekozen school af. Een school aanmaken is + bewust een twee-staps-actie: de knop wordt pas actief bij een echte naam + en vraagt daarna een bevestigende tweede klik (typefout-bescherming). */ function renderScholenPanel(panel){ + panel.appendChild(h("div","am-h3", T("amNewSchool"))); + panel.appendChild(h("div","guestnote", T("amNewSchoolDesc"))); const bar = h("div","am-add"); const inp = h("input","am-inp"); - inp.placeholder = T("amNamePh"); + inp.placeholder = T("amSchoolNamePh"); + inp.maxLength = 80; inp.addEventListener("keydown", ev=>ev.stopPropagation()); - bar.appendChild(inp); + bar.appendChild(field("amFieldName", inp)); const add = h("button","tbtn", T("amNewSchool")); + add.type = "button"; + add.disabled = true; + let armTimer = null; + const disarm = ()=>{ clearTimeout(armTimer); armTimer = null; add.classList.remove("danger"); add.textContent = T("amNewSchool"); }; + inp.addEventListener("input", ()=>{ add.disabled = inp.value.trim().length < 2; disarm(); }); add.addEventListener("click", async ()=>{ - if(!inp.value.trim()) return; + const name = inp.value.trim(); + if(name.length < 2) return; + if(!armTimer){ + add.classList.add("danger"); + add.textContent = `${T("amNewSchoolConfirm")} “${name}”?`; + armTimer = setTimeout(disarm, 5000); + return; + } + disarm(); try{ - const r = await api("/admin/schools", { body:{ name: inp.value.trim() } }); + const r = await api("/admin/schools", { body:{ name } }); selSchool = r.school.id; inp.value = ""; reload(); }catch(e){ msg(e.message); } @@ -394,7 +462,10 @@ /* 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")); + if(creatableRoles().includes("super")){ + panel.appendChild(h("div","am-h3", T("roleSuper"))); + panel.appendChild(addForm("super")); + } } /* klassen (admin/super binnen een school) */ @@ -405,41 +476,46 @@ return; } const canResetPw = can("schools.resetPasswords"); - const cwrapEl = h("div","am-classes"); + /* klassen als volwaardige rijen (zelfde maat als de gebruikersvakken), + met de gevaarlijke acties als aparte, bevestigde knoppen */ + const list = h("div","am-list"); CLASSES.forEach(c=>{ - const chip = h("span","am-chip", c.name); + const row = h("div","am-row"); + const pupilCount = USERS.filter(u=>u.role==="pupil" && Number(u.classId)===Number(c.id)).length; + row.appendChild(h("span","am-name", c.name)); + row.appendChild(h("span","am-role", `${pupilCount} ${(pupilCount===1 ? T("amPupilOne") : T("amPupils")).toLowerCase()}`)); if(canResetPw){ - const rs = h("button","am-chipx","🔄"); - rs.title = T("amResetClass"); - rs.addEventListener("click", async ()=>{ - if(!confirm(`${T("amConfirmReset")} ${c.name}?`)) return; + const acts = h("div","am-actions"); + acts.appendChild(dangerBtn("🔄 " + T("amResetClass"), "amConfirmReset", async ()=>{ 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 ()=>{ + })); + acts.appendChild(dangerBtn("🗑 " + T("amDelete"), "amDeleteClassWarn", async ()=>{ try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); } catch(e){ msg(e.message); } - }); - chip.appendChild(x); + })); + row.appendChild(acts); } - cwrapEl.appendChild(chip); + list.appendChild(row); }); - panel.appendChild(cwrapEl); + panel.appendChild(list); if(canResetPw){ const f = h("div","am-add"); const inp = h("input","am-inp"); - inp.placeholder = T("amNamePh"); + inp.placeholder = T("amClassNamePh"); + inp.maxLength = 80; inp.addEventListener("keydown", ev=>ev.stopPropagation()); - f.appendChild(inp); + f.appendChild(field("amFieldName", inp)); const go = h("button","tbtn", T("amNewClass")); + go.type = "button"; + go.disabled = true; + inp.addEventListener("input", ()=>{ go.disabled = inp.value.trim().length < 2; }); go.addEventListener("click", async ()=>{ - if(!inp.value.trim()) return; + if(inp.value.trim().length < 2) return; try{ await api("/admin/classes", { body:{ name: inp.value.trim(), school: selSchool ? +selSchool : undefined } }); reload(); @@ -449,20 +525,16 @@ 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 ()=>{ + panel.appendChild(dangerBtn("🔄 " + T("amResetSchool"), "amConfirmResetSchool", 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); + })); } } @@ -748,6 +820,10 @@ adminTab = tab; document.querySelectorAll("#adminTabsSlot button").forEach(b=>b.classList.toggle("active", b.dataset.admintab===tab)); modal.querySelectorAll(".am-tabpanel").forEach(p=>{ p.style.display = p.dataset.panel===tab ? "block" : "none"; }); + /* het Systeem-tabblad is school-onafhankelijk: daar is de schoolkiezer + alleen maar verwarrend, dus die verbergen we er */ + const sb = modal.querySelector(".am-schoolbar"); + if(sb) sb.style.display = tab === "scholen" ? "none" : ""; } /* vaste contextbalk voor de systeemmanager: op elk beheer-tabblad direct diff --git a/public/js/core.js b/public/js/core.js index 1867ef5..be8ad40 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.33-beta"; +const VERSION = "0.3.34-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -182,7 +182,18 @@ const I18N = { pupilWatching:"meekijken", amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem", amPickSchoolHint:"Kies hierboven eerst een school (🏫).", amPickSchool:"Kies een school", - amPupils:"Leerlingen", amEdit:"Bewerken", + amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken", + amSure:"weet je het zeker?", amDelete:"verwijder", + amFieldRole:"Functie", amFieldExtraRoles:"Extra functies", amFieldClass:"Klas", + amFieldPw:"Wachtwoord", amFieldActions:"Acties", amPwTypePh:"typ een wachtwoord…", + amNewPwWarn:"Maakt een nieuw wachtwoord aan. Het oude wachtwoord werkt daarna niet meer en de gebruiker wordt overal uitgelogd.", + amNewCodeWarn:"Maakt een nieuwe koppelcode aan. Het huidige wachtwoord vervalt direct; de gebruiker moet opnieuw activeren.", + amDeleteWarn:"Verwijdert dit account definitief, inclusief borden en woorden.", + amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode", + amNewSchoolDesc:"De schoolnaam verschijnt op het inlogscherm voor alle gebruikers van die school. Kies de definitieve naam - hernoemen kan later niet via dit scherm.", + amSchoolNamePh:"naam van de school…", amNewSchoolConfirm:"Aanmaken:", + amClassNamePh:"naam van de klas…", + amDeleteClassWarn:"Verwijdert deze klas; leerlingen blijven bestaan maar staan daarna zonder klas.", amNewSchool:"+ School", amNewClass:"+ Klas", amNewTeacher:"+ Groepsleiding", amNewAdmin:"+ Schoolbeheerder", amNewSuper:"+ Systeemmanager", amNewPupil:"+ Leerling", amUserPh:"gebruikersnaam…", amNamePh:"naam…", @@ -367,7 +378,18 @@ const I18N = { pupilWatching:"watching", amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System", amPickSchoolHint:"First pick a school above (🏫).", amPickSchool:"Pick a school", - amPupils:"Pupils", amEdit:"Edit", + amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit", + amSure:"are you sure?", amDelete:"delete", + amFieldRole:"Role", amFieldExtraRoles:"Extra roles", amFieldClass:"Class", + amFieldPw:"Password", amFieldActions:"Actions", amPwTypePh:"type a password…", + amNewPwWarn:"Generates a new password. The old one stops working and the user is signed out everywhere.", + amNewCodeWarn:"Generates a new link code. The current password stops working immediately; the user must re-activate.", + amDeleteWarn:"Deletes this account permanently, including boards and words.", + amResultPw:"New password", amResultCode:"New link code", + amNewSchoolDesc:"The school name appears on the login screen for all users of that school. Pick the final name - it cannot be renamed from this screen later.", + amSchoolNamePh:"name of the school…", amNewSchoolConfirm:"Create:", + amClassNamePh:"name of the class…", + amDeleteClassWarn:"Deletes this class; pupils remain but end up without a class.", amNewSchool:"+ School", amNewClass:"+ Class", amNewTeacher:"+ Group leader", amNewAdmin:"+ School admin", amNewSuper:"+ System manager", amNewPupil:"+ Pupil", amUserPh:"username…", amNamePh:"name…",