From c6638d63f1faa1f9192c7834dcedc2efc32122a8 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sat, 18 Jul 2026 04:00:30 +0200 Subject: [PATCH] feat: systeemmanager aanmaken verplaatst naar Gebruikers + eigen wachtwoord ter bevestiging (v0.4.04-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - het aanmaken van een systeemmanager stond onder Scholen, los van de rest van het gebruikersbeheer - verplaatst naar de Gebruikers-tab (bij "alle scholen", naast de bestaande systeemmanagers-groep) - extra veiligheidsstap: de aanmaker moet het eigen wachtwoord invullen ter bevestiging vóórdat een nieuwe systeemmanager wordt aangemaakt - server verifieert dit (POST /admin/users), niet alleen de client - wie dit al mocht (alleen systeemmanagers, via creatableRoles) blijft ongewijzigd - de bevestigingsstap is een aanvullende, geen vervangende, controle --- VERSION | 2 +- public/js/admin.js | 28 ++++++++++++++++++++++------ public/js/core.js | 4 +++- src/api.js | 8 ++++++++ test/schools.test.js | 38 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/VERSION b/VERSION index 46e7081..10f3c0c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.03-beta +0.4.04-beta diff --git a/public/js/admin.js b/public/js/admin.js index 8496a3c..faa901d 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -644,10 +644,25 @@ 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; - name.addEventListener("input", ()=>{ go.disabled = name.value.trim().length < 2; }); + 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 ()=>{ @@ -656,10 +671,12 @@ 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 }); name.value = ""; if(pwInp) pwInp.value = ""; + if(confirmInp) confirmInp.value = ""; /* het wachtwoord of de koppelcode verschijnt direct bij de nieuwe gebruiker in de lijst (rij en groep klappen vanzelf open) */ rowResults.set(r.user.id, { @@ -880,11 +897,6 @@ inp.focus(); }); panel.appendChild(wrap); - - /* 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 de gekozen school). Een klas hoort altijd bij @@ -1338,6 +1350,10 @@ }); const supers = USERS.filter(u=>u.role==="super"); if(supers.length) group("gsys", T("amSuperGroup"), supers); + /* systeemmanagers horen niet bij één school, dus het aanmaken ervan + staat hier (bij "alle scholen") i.p.v. tussen de school-gebonden + gebruikers - met een extra bevestigingsstap (eigen wachtwoord). */ + if(creatableRoles().includes("super")) list.appendChild(addForm("super")); const parents = USERS.filter(u=>u.role==="parent"); if(parents.length) group("gparents", T("amParents"), parents); const loose = USERS.filter(u=>["teacher","admin","pupil"].includes(u.role) && u.schoolId==null); diff --git a/public/js/core.js b/public/js/core.js index e613fba..7434622 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.4.03-beta"; +const VERSION = "0.4.04-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -308,6 +308,7 @@ const I18N = { amPwHidden:"al gebruikt", amGenerate:"genereer wachtwoord", amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:", amPwOptional:"wachtwoord (leeg = koppelcode)", + amOwnPassword:"Eigen wachtwoord ter bevestiging", amOwnPasswordPh:"jouw huidige wachtwoord…", amAssignments:"Toewijzingen", amAssignClasses:"Per klas (standaard)", amAssignPupils:"Per leerling (uitzondering)", amPickPupil:"— kies een leerling —", amNoAssignment:"— geen toewijzing —", amAssignedAs:"Toegewezen:", amAssign:"Toewijzen", amAssignSaved:"Toewijzing opgeslagen.", @@ -606,6 +607,7 @@ const I18N = { amPwHidden:"already used", amGenerate:"generate password", amNewPw:"new password", amPwSet:"Password set:", amPwOptional:"password (empty = link code)", + amOwnPassword:"Your own password, to confirm", amOwnPasswordPh:"your current password…", amAssignments:"Assignments", amAssignClasses:"Per class (default)", amAssignPupils:"Per pupil (override)", amPickPupil:"— pick a pupil —", amNoAssignment:"— no assignment —", amAssignedAs:"Assigned:", amAssign:"Assign", amAssignSaved:"Assignment saved.", diff --git a/src/api.js b/src/api.js index 9cf6242..42ffe26 100644 --- a/src/api.js +++ b/src/api.js @@ -530,6 +530,14 @@ export default async function api(app) { const b = req.body ?? {}; const role = b.role; if (!creatableRoles(req).includes(role)) return fail(reply, 403, 'geen rechten voor deze rol'); + // Extra veiligheidsstap voor de meest ingrijpende actie in dit beheer: een + // nieuwe systeemmanager aanmaken vereist het eigen wachtwoord van de + // aanmaker, ter bevestiging - los van (en aanvullend op) creatableRoles(), + // die al bepaalt dát alleen een systeemmanager dit mag proberen. + if (role === 'super') { + const ok = await verifyPassword(b.confirmPassword, req.user.password_hash); + if (!ok) return fail(reply, 403, 'eigen wachtwoord onjuist'); + } const schoolId = req.user.allRoles.includes('super') ? (role === 'super' ? null : b.school) : req.user.school_id; if (role !== 'super' && !schoolId) return fail(reply, 400, 'school verplicht'); const username = (b.username || '').trim(); diff --git a/test/schools.test.js b/test/schools.test.js index e11d299..4967c0c 100644 --- a/test/schools.test.js +++ b/test/schools.test.js @@ -4,6 +4,7 @@ import Fastify from 'fastify'; import cookie from '@fastify/cookie'; import rateLimit from '@fastify/rate-limit'; import api from '../src/api.js'; +import { hashPassword } from '../src/auth.js'; import { readFile } from 'node:fs/promises'; function makeApp(user, targetUser) { @@ -320,3 +321,40 @@ test('hernoemen weigert een lege of te lange naam', async () => { assert.equal(res2.statusCode, 400); await app.close(); }); + +test('systeemmanager aanmaken vereist het eigen wachtwoord van de aanmaker ter bevestiging', async () => { + const ownHash = await hashPassword('correcthorse1'); + const superWithPw = { ...superUser, password_hash: ownHash }; + // fout eigen wachtwoord: geweigerd, geen insert + const { app, calls } = await makeApp(superWithPw); + const res = await app.inject({ method: 'POST', url: '/api/admin/users', cookies, + payload: { role: 'super', username: 'nieuwesm', confirmPassword: 'helemaal-fout' } }); + assert.equal(res.statusCode, 403, res.body); + assert.match(res.json().error, /wachtwoord onjuist/); + assert.ok(!calls.some((c) => c.sql.startsWith('INSERT INTO users'))); + await app.close(); + // geen eigen wachtwoord meegestuurd: ook geweigerd + const { app: app2, calls: calls2 } = await makeApp(superWithPw); + const res2 = await app2.inject({ method: 'POST', url: '/api/admin/users', cookies, + payload: { role: 'super', username: 'nieuwesm' } }); + assert.equal(res2.statusCode, 403, res2.body); + assert.ok(!calls2.some((c) => c.sql.startsWith('INSERT INTO users'))); + await app2.close(); + // correct eigen wachtwoord: mag door naar de insert + const { app: app3, calls: calls3 } = await makeApp(superWithPw); + const res3 = await app3.inject({ method: 'POST', url: '/api/admin/users', cookies, + payload: { role: 'super', username: 'nieuwesm', confirmPassword: 'correcthorse1' } }); + assert.equal(res3.statusCode, 200, res3.body); + assert.ok(calls3.some((c) => c.sql.startsWith('INSERT INTO users'))); + await app3.close(); + // een schoolbeheerder mag sowieso geen systeemmanager aanmaken (creatableRoles), + // ongeacht wat er als confirmPassword meegestuurd wordt + const adminHash = await hashPassword('adminwachtwoord1'); + const { app: app4, calls: calls4 } = await makeApp({ ...admin, password_hash: adminHash }); + const res4 = await app4.inject({ method: 'POST', url: '/api/admin/users', cookies, + payload: { role: 'super', username: 'nieuwesm', confirmPassword: 'adminwachtwoord1' } }); + assert.equal(res4.statusCode, 403, res4.body); + assert.match(res4.json().error, /geen rechten voor deze rol/); + assert.ok(!calls4.some((c) => c.sql.startsWith('INSERT INTO users'))); + await app4.close(); +});