From df921d5968aa5c240af5eaca4a6b2da6d7d769dc Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 15 Jul 2026 16:02:57 +0200 Subject: [PATCH] =?UTF-8?q?v0.3.36-beta:=20scholen=20m=C3=A9t=20schoolbehe?= =?UTF-8?q?erders=20in=20het=20Systeem-tabblad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Systeem toont per school een rij in klassen-stijl (🏫, accentkleur, aantal schoolbeheerders) met daaronder de schoolbeheerders als duidelijk onderscheiden subrijen (🛡, ingesprongen, eigen randkleur) - Beide zijn in dit menu aan te passen via hetzelfde ✏-gereedschap: de school heeft een Naam-veld (nieuwe route PATCH /admin/schools/:id, alleen systeemmanager) en de bevestigde verwijder-actie; beheerders hebben hun volledige gereedschap (functie, extra functies, code, wachtwoord, verwijderen) - Systeem-tab laadt beheerders van álle scholen, ook als de contextbalk elders op één school staat - Drie nieuwe servertests voor het hernoemen (super ok, admin 403, naamvalidatie) --- VERSION | 2 +- public/css/teach.css | 7 +++++ public/js/admin.js | 58 ++++++++++++++++++++++++++++++++++++----- public/js/core.js | 4 ++- src/api.js | 8 ++++++ test/schools.test.js | 62 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 test/schools.test.js diff --git a/VERSION b/VERSION index c8b0afe..955cbea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.35-beta +0.3.36-beta diff --git a/public/css/teach.css b/public/css/teach.css index 51c6726..ddc0661 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1153,6 +1153,13 @@ /* inklapbare groepskop (leerlingen per klas): klikbaar, pijltje in de tekst */ .am-group-toggle{cursor:pointer; user-select:none;} .am-group-toggle:hover{color:var(--ink);} +/* Systeem-tab: schoolrij duidelijk zwaarder dan de beheerder-subrijen */ +.am-row-school{background:var(--accent-soft);} +.am-row-school > .am-name{font-size:15px;} +.am-row-sub{ + margin-left:22px; background:var(--surface); + border-left:3px solid var(--line); border-radius:var(--radius-s); +} /* uitklapbaar gereedschapsblok per gebruikersrij (achter het ✏-knopje) */ .am-row-tools{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; width:100%; diff --git a/public/js/admin.js b/public/js/admin.js index e7f52c2..222f4bf 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -6,6 +6,8 @@ geen los venster meer - modal is alleen nog de content-container voor render() */ const modal = document.getElementById("adminModal"); let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null; + /* schoolbeheerders van alle scholen, voor het Systeem-tabblad (super) */ + let SYS_ADMINS = []; /* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw opgehaald bij elke reload() zoals CLASSES/USERS hierboven */ let ASSIGNMENTS = {}; @@ -159,6 +161,13 @@ if(currentUser.role==="super") SCHOOLS = await api("/schools"); const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : ""; USERS = (await api("/admin/users"+q)).users; + /* het Systeem-tabblad toont schoolbeheerders van álle scholen, ook als + de contextbalk op één school staat; zonder schoolfilter is USERS al + compleet en is de extra call overbodig */ + if(currentUser.role==="super"){ + SYS_ADMINS = q ? ((await api("/admin/users")).users || []).filter(u=>u.role==="admin") + : USERS.filter(u=>u.role==="admin"); + } const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=0") : ""; CLASSES = (await api("/admin/classes"+cq)).classes || []; ASSIGNMENTS = {}; @@ -178,13 +187,14 @@ return sel; } - function userRow(u){ - const row = h("div","am-row"); + function userRow(u, opts){ + const row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : "")); /* rustig standaardbeeld: alleen naam + rol/klas; alle knoppen en velden zitten achter het ✏-knopje in een uitklapbaar gereedschapsblok */ const tools = h("div","am-row-tools"); const actions = h("div","am-actions"); - row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : ""))); + const icon = opts && opts.icon ? opts.icon + " " : ""; + row.appendChild(h("span","am-name", icon + u.displayName + (u.displayName!==u.username ? ` (${u.username})` : ""))); if(u.role==="pupil"){ const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId)); row.appendChild(h("span","am-role", T(roleKey(u.role)) + (cls ? ` · ${cls.name}` : ""))); @@ -427,8 +437,25 @@ function renderScholenPanel(panel){ const list = h("div","am-list"); SCHOOLS.forEach(s=>{ - const row = h("div","am-row"); - row.appendChild(h("span","am-name", s.name)); + const admins = SYS_ADMINS.filter(a=>Number(a.schoolId)===Number(s.id)); + const row = h("div","am-row am-row-school"); + row.appendChild(h("span","am-name", "🏫 " + s.name)); + row.appendChild(h("span","am-role", + `${admins.length} ${(admins.length===1 ? T("amAdminOne") : T("amAdmins")).toLowerCase()}`)); + /* zelfde ✏-systeem als bij gebruikers: hernoemen en verwijderen + zitten achter het gereedschapsblok, de rij zelf blijft rustig */ + const tools = h("div","am-row-tools"); + const nameInp = h("input","am-inp"); + nameInp.value = s.name; + nameInp.maxLength = 80; + nameInp.addEventListener("keydown", ev=>ev.stopPropagation()); + nameInp.addEventListener("change", async ()=>{ + const name = nameInp.value.trim(); + if(name.length < 2 || name === s.name) return; + try{ await api("/admin/schools/"+s.id, {method:"PATCH", body:{ name }}); reload(); } + catch(e){ msg(e.message); } + }); + tools.appendChild(field("amFieldName", nameInp)); const acts = h("div","am-actions"); acts.appendChild(dangerBtn("🗑 " + T("amDelete"), "amDeleteSchoolWarn", async ()=>{ try{ @@ -437,8 +464,27 @@ reload(); }catch(e){ msg(e.message); } })); - row.appendChild(acts); + tools.appendChild(field("amFieldActions", acts)); + const edit = h("button","am-edit","✏"); + edit.type = "button"; + edit.title = T("amEdit"); + const key = "school:"+s.id; + const sync = ()=>{ + const open = openRows.has(key); + tools.style.display = open ? "" : "none"; + edit.classList.toggle("on", open); + }; + edit.addEventListener("click", ()=>{ + if(openRows.has(key)) openRows.delete(key); else openRows.add(key); + sync(); + }); + sync(); + row.appendChild(edit); + row.appendChild(tools); list.appendChild(row); + /* de schoolbeheerders van deze school, duidelijk onderscheiden van de + schoolrij (🛡-icoon, ingesprongen) maar met hetzelfde ✏-gereedschap */ + admins.forEach(a=>list.appendChild(userRow(a, { icon:"🛡", sub:true }))); }); panel.appendChild(list); diff --git a/public/js/core.js b/public/js/core.js index 73f6810..55bb2da 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.35-beta"; +const VERSION = "0.3.36-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -183,6 +183,7 @@ const I18N = { amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem", amPickSchoolHint:"Kies hierboven eerst een school (🏫).", amPickSchool:"Kies een school", amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken", + amAdmins:"Schoolbeheerders", amAdminOne:"Schoolbeheerder", amSure:"weet je het zeker?", amDelete:"verwijder", amFieldName:"Naam", amFieldRole:"Functie", amFieldExtraRoles:"Extra functies", amFieldClass:"Klas", amFieldPw:"Wachtwoord", amFieldActions:"Acties", amPwTypePh:"typ een wachtwoord…", @@ -379,6 +380,7 @@ const I18N = { amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System", amPickSchoolHint:"First pick a school above (🏫).", amPickSchool:"Pick a school", amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit", + amAdmins:"School admins", amAdminOne:"School admin", amSure:"are you sure?", amDelete:"delete", amFieldName:"Name", amFieldRole:"Role", amFieldExtraRoles:"Extra roles", amFieldClass:"Class", amFieldPw:"Password", amFieldActions:"Actions", amPwTypePh:"type a password…", diff --git a/src/api.js b/src/api.js index d5e8238..7fc491e 100644 --- a/src/api.js +++ b/src/api.js @@ -267,6 +267,14 @@ export default async function api(app) { const r = await pool.query('INSERT INTO schools (name) VALUES ($1) RETURNING id, name', [name.trim()]); return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } }; }); + app.patch('/admin/schools/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['schools.manage']); + const { name } = req.body ?? {}; + if (!name || typeof name !== 'string' || !name.trim() || name.trim().length > 80) return fail(reply, 400, 'ongeldige naam'); + const r = await pool.query('UPDATE schools SET name = $1 WHERE id = $2 RETURNING id, name', [name.trim(), req.params.id]); + if (!r.rows[0]) return fail(reply, 404, 'school onbekend'); + return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } }; + }); app.delete('/admin/schools/:id', async (req, reply) => { need(req, reply, PERMISSIONS['schools.manage']); await pool.query('DELETE FROM schools WHERE id = $1', [req.params.id]); diff --git a/test/schools.test.js b/test/schools.test.js new file mode 100644 index 0000000..bc62ba1 --- /dev/null +++ b/test/schools.test.js @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import cookie from '@fastify/cookie'; +import rateLimit from '@fastify/rate-limit'; +import api from '../src/api.js'; + +function makeApp(user) { + const calls = []; + const pool = { + async query(sql, params = []) { + calls.push({ sql, params }); + if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + if (sql.startsWith('UPDATE schools SET name')) return { rows: [{ id: params[1], name: params[0] }] }; + return { rows: [] }; + }, + }; + return (async () => { + 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(); + 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 }; + +test('systeemmanager kan een school hernoemen', async () => { + const { app, calls } = await makeApp(superUser); + const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies, + payload: { name: 'De Nieuwe Naam' } }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(res.json().school.name, 'De Nieuwe Naam'); + const upd = calls.find((c) => c.sql.startsWith('UPDATE schools SET name')); + assert.deepEqual(upd.params, ['De Nieuwe Naam', '5']); + await app.close(); +}); + +test('schoolbeheerder mag geen scholen hernoemen', async () => { + const { app } = await makeApp(admin); + const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies, + payload: { name: 'Hack' } }); + assert.equal(res.statusCode, 403); + 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, + payload: { name: ' ' } }); + assert.equal(res.statusCode, 400); + const res2 = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies, + payload: { name: 'x'.repeat(90) } }); + assert.equal(res2.statusCode, 400); + await app.close(); +});