From b4eee9f78f46121c48e64476575700190dba6abe Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 17 Jul 2026 14:30:41 +0200 Subject: [PATCH] feat: gebruikers aan een school koppelen en losmaken (v0.3.90-beta) - PATCH /admin/users/:id ondersteunt schoolId (alleen systeemmanager, rollen teacher/admin/pupil): null = losmaken, anders koppelen met bestaanscheck (404); bij wissel worden klas, klaskoppelingen en (voor leerlingen) oude toewijzingen opgeruimd; naamconflicten in doelschool of school-loze naamruimte geven een nette 409 - Detailpaneel toont voor de systeemmanager een School-veld bij deze rollen - losse accounts (ooit aan een school gekoppeld) zijn hiermee terug te zetten en bewust los te maken; werkt ook in de beheerder-subrijen op het Scholen-tabblad - Gebruikers-tab (alle scholen): losse accounts staan in een eigen inklapbare groep "Zonder school (n)" i.p.v. onopvallend tussen de rolgroepen; de oudergroep is nu ook inklapbaar - 3 nieuwe servertests (koppelen incl. opruiming, losmaken, 404, geen rechten/no-op voor admin en parent, 409 bij naamconflict) + 10 Playwright-checks voor de volledige flow Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF --- VERSION | 2 +- public/js/admin.js | 28 +++++++++++++++-- public/js/core.js | 4 ++- src/api.js | 24 ++++++++++++++ test/schools.test.js | 75 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index 76418e2..46c023e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.89-beta +0.3.90-beta diff --git a/public/js/admin.js b/public/js/admin.js index 42a2660..9c0ebb4 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -348,6 +348,20 @@ 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){ msg(e.message); ss.value = u.schoolId ?? ""; } + }); + tools.appendChild(field("amFieldSchool", ss)); + } 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 */ @@ -1236,15 +1250,19 @@ master.appendChild(thead); const list = h("div","am-list am-userlist"); - const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")], ["parent", T("amParents")]]; + /* losse accounts (ooit aan een school gekoppeld, nu zonder): alleen + relevant voor de systeemmanager in de alle-scholen-weergave */ + const showLoose = currentUser.role==="super" && !selSchool; + const isLoose = u=>showLoose && ["teacher","admin","pupil"].includes(u.role) && u.schoolId==null; + const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")]]; staffGroups.forEach(([role, label])=>{ - const us = USERS.filter(u=>u.role===role); + const us = USERS.filter(u=>u.role===role && !isLoose(u)); if(!us.length) return; list.appendChild(h("div","am-group", `${label} (${us.length})`)); us.forEach(u=>list.appendChild(userCells(u))); }); - const pupils = USERS.filter(u=>u.role==="pupil"); + const pupils = USERS.filter(u=>u.role==="pupil" && !isLoose(u)); const schoolChosen = schoolChosenTop; /* leerlingen staan onder inklapbare groepskoppen (standaard dicht): zo @@ -1279,6 +1297,10 @@ const noClass = pupils.filter(u=>!u.classId); if(noClass.length) pupilGroup("class:none", T("amNoClass"), noClass); } + const parents = USERS.filter(u=>u.role==="parent"); + if(parents.length) pupilGroup("parents", T("amParents"), parents); + const loose = USERS.filter(isLoose); + if(loose.length) pupilGroup("noschool", T("amNoSchoolGroup"), loose); master.appendChild(list); split.appendChild(master); split.appendChild(detail); diff --git a/public/js/core.js b/public/js/core.js index 969b555..af92735 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.89-beta"; +const VERSION = "0.3.90-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -279,6 +279,7 @@ const I18N = { amDeleteWarn:"Verwijdert dit account definitief, inclusief borden en woorden.", amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode", amColStatus:"Status", amNeverLoggedIn:"nog niet ingelogd", amFieldSchool:"School", amStatusActive:"actief", + amNoSchool:"— geen school —", amNoSchoolGroup:"Zonder school", amDetailEmpty:"Selecteer links een gebruiker om te bewerken, of maak een nieuwe aan.", amNewUser:"Nieuwe gebruiker", amRequestOne:"Aanmelding", amReqNew:"nieuw", amParents:"Ouders", @@ -573,6 +574,7 @@ const I18N = { amDeleteWarn:"Deletes this account permanently, including boards and words.", amResultPw:"New password", amResultCode:"New link code", amColStatus:"Status", amNeverLoggedIn:"not signed in yet", amFieldSchool:"School", amStatusActive:"active", + amNoSchool:"— no school —", amNoSchoolGroup:"No school", amDetailEmpty:"Select a user on the left to edit, or create a new one.", amNewUser:"New user", amRequestOne:"Enrolment", amReqNew:"new", amParents:"Parents", diff --git a/src/api.js b/src/api.js index d3b4f10..0601a4a 100644 --- a/src/api.js +++ b/src/api.js @@ -583,6 +583,30 @@ export default async function api(app) { await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]); return { ok: true, linkCode: code }; } + // school koppelen/losmaken (alleen systeemmanager): losse accounts kunnen + // teruggezet worden naar een school en bewust losgemaakt. Super en parent + // zijn per definitie schoolloos - daarvoor is dit een stille no-op. + if (b.schoolId !== undefined && req.user.allRoles.includes('super') + && ['teacher', 'admin', 'pupil'].includes(u.role)) { + const target = b.schoolId === null ? null : Number(b.schoolId); + if (target !== null) { + const school = (await pool.query('SELECT 1 FROM schools WHERE id = $1', [target])).rows[0]; + if (!school) return fail(reply, 404, 'school onbekend'); + } + if (target !== (u.school_id == null ? null : Number(u.school_id))) { + try { + await pool.query('UPDATE users SET school_id = $1, class_id = NULL WHERE id = $2', [target, u.id]); + } catch (e) { + // naam kan botsen in de doelschool óf (bij losmaken) in de + // school-loze naamruimte (systeemmanagers/ouders/thuiskinderen) + if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al'); + throw e; + } + await pool.query('DELETE FROM class_teachers WHERE user_id = $1', [u.id]); + // een oude individuele toewijzing verwijst naar een bord van de vorige school + if (u.role === 'pupil') await pool.query('DELETE FROM assignments WHERE pupil_id = $1', [u.id]); + } + } // 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') diff --git a/test/schools.test.js b/test/schools.test.js index 99a8273..aedf472 100644 --- a/test/schools.test.js +++ b/test/schools.test.js @@ -29,6 +29,20 @@ function makeApp(user, targetUser) { })(); } +/* variant waarin de scholen-existentiecheck slaagt */ +function makeAppWithSchool(user, targetUser) { + return (async () => { + const { app, calls } = await makeApp(user, targetUser); + const pool = app.pg; + const orig = pool.query.bind(pool); + pool.query = async (sql, params = []) => { + if (sql.startsWith('SELECT 1 FROM schools')) { calls.push({ sql, params }); return { rows: [{ 1: 1 }] }; } + return orig(sql, params); + }; + return { app, calls }; + })(); +} + const cookies = { teach_session: 'x'.repeat(64) }; const superUser = { id: 1, username: 'sm', role: 'super', school_id: null, class_id: null, data: {}, data_rev: 0 }; const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 }; @@ -97,6 +111,67 @@ test('ouder-achtige rol krijgt geen promotie en geen klaskoppeling', async () => await app2.close(); }); +test('systeemmanager koppelt een gebruiker aan een school en maakt hem weer los', async () => { + // koppelen: school bestaat -> update + klas-/toewijzing-opruiming + const { app, calls } = await makeApp(superUser, { id: 7, role: 'teacher', school_id: null, username: 'juf' }); + const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, + payload: { schoolId: 2 } }); + assert.equal(res.statusCode, 404, res.body); // mock geeft geen schools-rij terug + const { app: app2, calls: calls2 } = await makeAppWithSchool(superUser, { id: 7, role: 'teacher', school_id: null, username: 'juf' }); + const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, + payload: { schoolId: 2 } }); + assert.equal(res2.statusCode, 200, res2.body); + const upd = calls2.find((c) => c.sql.startsWith('UPDATE users SET school_id')); + assert.deepEqual(upd.params, [2, 7]); + assert.ok(calls2.some((c) => c.sql.includes('DELETE FROM class_teachers'))); + await app.close(); await app2.close(); + // losmaken: schoolId null, geen schools-check nodig; leerling verliest toewijzing + const { app: app3, calls: calls3 } = await makeApp(superUser, { id: 9, role: 'pupil', school_id: 2, class_id: 5, username: 'lena' }); + const res3 = await app3.inject({ method: 'PATCH', url: '/api/admin/users/9', cookies, + payload: { schoolId: null } }); + assert.equal(res3.statusCode, 200, res3.body); + const upd3 = calls3.find((c) => c.sql.startsWith('UPDATE users SET school_id')); + assert.deepEqual(upd3.params, [null, 9]); + assert.ok(calls3.some((c) => c.sql.includes('DELETE FROM assignments WHERE pupil_id'))); + await app3.close(); +}); + +test('school koppelen kan niet door een schoolbeheerder en niet op super/parent', async () => { + const { app, calls } = await makeAppWithSchool(admin, { id: 7, role: 'teacher', school_id: 2, username: 'juf' }); + await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, payload: { schoolId: 3 } }); + assert.ok(!calls.some((c) => c.sql.startsWith('UPDATE users SET school_id')), 'admin mag geen school wijzigen'); + await app.close(); + const { app: app2, calls: calls2 } = await makeAppWithSchool(superUser, { id: 20, role: 'parent', school_id: null, username: 'ouder' }); + await app2.inject({ method: 'PATCH', url: '/api/admin/users/20', cookies, payload: { schoolId: 2 } }); + assert.ok(!calls2.some((c) => c.sql.startsWith('UPDATE users SET school_id')), 'parent blijft schoolloos'); + await app2.close(); +}); + +test('school koppelen meldt een naamconflict netjes als 409', async () => { + const calls = []; + const pool = { + async query(sql, params = []) { + calls.push({ sql, params }); + if (sql.includes('FROM sessions s JOIN users u')) return { rows: [superUser] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 7, role: 'teacher', school_id: null, username: 'juf' }] }; + if (sql.startsWith('SELECT 1 FROM schools')) return { rows: [{ 1: 1 }] }; + if (sql.startsWith('UPDATE users SET school_id')) { const e = new Error('dup'); e.code = '23505'; throw e; } + return { rows: [] }; + }, + }; + const app = Fastify({ trustProxy: 2 }); + await app.register(cookie); + await app.register(rateLimit, { global: false }); + app.decorate('pg', pool); + await app.register(api, { prefix: '/api' }); + await app.ready(); + const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, payload: { schoolId: 2 } }); + assert.equal(res.statusCode, 409); + assert.match(res.json().error, /bestaat al/); + await app.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,