From a9bf2270d0ddb9075cdb8861c2b15ae8d9652643 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 15 Jul 2026 23:56:39 +0200 Subject: [PATCH] fix: beperk rollen en klaskoppeling tot staf (v0.3.56-beta) - Functie-select in gebruikersbeheer alleen zichtbaar voor staf-rollen (teacher/admin/super) - Klaskoppeling-UI alleen voor staf-rollen - Server: rolwijziging alleen tussen staf-rollen onderling (leerlingen en toekomstige ouder-accounts kunnen nooit promoveren) - Server: klaskoppeling weigert alle niet-staf-rollen expliciet - canPersist() sluit de komende ouderrol uit van borden-opslag - Servertest: ouder-achtige rol krijgt geen promotie en geen klaskoppeling Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF --- VERSION | 2 +- public/js/admin.js | 5 +++-- public/js/core.js | 6 ++++-- src/api.js | 8 ++++++-- test/schools.test.js | 18 ++++++++++++++++++ 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index 1ea7270..c605a1f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.55-beta +0.3.56-beta diff --git a/public/js/admin.js b/public/js/admin.js index bb501f7..b46427b 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -206,7 +206,8 @@ + (names.length ? ` · ${names.join(", ")}` : "") + (u.pending ? ` · ${T("amPending")}` : ""))); } - if(u.role!=="pupil" && can("users.role.change") && u.id!==currentUser.id){ + const isStaff = ["teacher","admin","super"].includes(u.role); + if(isStaff && can("users.role.change") && u.id!==currentUser.id){ /* één functie per gebruiker: alleen de hoofdrol is te wijzigen (super); het aparte extra-functies-systeem is uit de UI gehaald */ const rs = h("select","am-sel"); @@ -221,7 +222,7 @@ /* 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(u.role!=="pupil" && can("classes.manage") && CLASSES.length){ + 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)); diff --git a/public/js/core.js b/public/js/core.js index 68abe38..b4fcfec 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.55-beta"; +const VERSION = "0.3.56-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -554,7 +554,9 @@ let DATA_REV = 0; let saveConflict = false; let saveInFlight = false; function canPersist(){ - return !!currentUser && currentUser.role !== "pupil"; + /* leerlingen en ouders slaan geen eigen borden-data op; het ouderportaal + werkt straks met gekoppelde kind-accounts i.p.v. een eigen blob */ + return !!currentUser && currentUser.role !== "pupil" && currentUser.role !== "parent"; } /* welke secties er sinds de laatste geslaagde save zijn gewijzigd; een save verstuurt alleen die secties i.p.v. altijd de complete blob (die MB's kan diff --git a/src/api.js b/src/api.js index 474e67b..fe7f0a8 100644 --- a/src/api.js +++ b/src/api.js @@ -344,7 +344,7 @@ export default async function api(app) { if (!c || !u) return fail(reply, 404, 'klas of gebruiker onbekend'); if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id)) return fail(reply, 403, 'geen rechten'); - if (u.role === 'pupil') return fail(reply, 400, 'leerlingen horen al bij één klas'); + if (!['teacher', 'admin', 'super'].includes(u.role)) return fail(reply, 400, 'alleen staf kan aan een klas gekoppeld worden'); if (u.role !== 'super' && Number(c.school_id) !== Number(u.school_id)) return fail(reply, 400, 'klas en gebruiker moeten bij dezelfde school horen'); if (remove) await pool.query('DELETE FROM class_teachers WHERE class_id = $1 AND user_id = $2', [c.id, u.id]); @@ -526,7 +526,11 @@ export default async function api(app) { await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]); return { ok: true, linkCode: code }; } - if (b.role && can(req.user.allRoles, 'users.role.change') && ['super', 'admin', 'teacher'].includes(b.role) && u.role !== 'pupil') + // rolwijziging alleen tussen staf-rollen onderling: leerlingen blijven + // leerling en ouder-accounts kunnen nooit naar een beheerrol promoveren + if (b.role && can(req.user.allRoles, 'users.role.change') + && ['super', 'admin', 'teacher'].includes(b.role) + && ['super', 'admin', 'teacher'].includes(u.role)) await pool.query('UPDATE users SET role = $1, school_id = $2 WHERE id = $3', [b.role, b.role === 'super' ? null : u.school_id, u.id]); // extra functies naast de hoofdrol (bv. een teacher die ook admin-rechten krijgt) - diff --git a/test/schools.test.js b/test/schools.test.js index b761692..99a8273 100644 --- a/test/schools.test.js +++ b/test/schools.test.js @@ -79,6 +79,24 @@ test('schoolbeheerder en systeemmanager kunnen aan een klas gekoppeld worden', a await app4.close(); }); +test('ouder-achtige rol krijgt geen promotie en geen klaskoppeling', async () => { + // rolwijziging: alleen staf → staf; een parent-account mag nooit promoveren + const { app, calls } = await makeApp(superUser, { id: 7, role: 'parent', school_id: null }); + const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, + payload: { role: 'admin' } }); + assert.equal(res.statusCode, 200, res.body); + assert.ok(!calls.some((c) => c.sql.startsWith('UPDATE users SET role')), + 'rolwijziging mag voor een ouder-account niet uitgevoerd worden'); + await app.close(); + // klaskoppeling: parent weigeren, net als pupil + const { app: app2, calls: calls2 } = await makeApp(superUser, { id: 7, role: 'parent', school_id: null }); + const res2 = await app2.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies, + payload: { userId: 7 } }); + assert.equal(res2.statusCode, 400); + assert.ok(!calls2.some((c) => c.sql.includes('INSERT INTO class_teachers'))); + await app2.close(); +}); + test('hernoemen weigert een lege of te lange naam', async () => { const { app } = await makeApp(superUser); const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,