From 2528e5b8f61b695d05224c20f19e3e03394c074b Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 13 Jul 2026 22:41:40 +0200 Subject: [PATCH] v0.2.21-beta: meerdere functies per gebruiker (multi-rol voor staf) + zoekveld - Nieuwe user_roles-koppeltabel (db/004_user_roles.sql): staf kan voortaan naast de hoofdrol extra functies krijgen (bv. groepsleiding + schoolbeheerder tegelijk); leerling blijft altijd exclusief - can()/PERMISSIONS accepteren nu een array van rollen i.p.v. alleen een losse string (backward compatible), CREATABLE_ROLES-opzoeking wordt een unie over alle rollen van een gebruiker - Rechten-checks in src/api.js gebruiken voortaan req.user.allRoles (hoofd- + extra rollen); identiteits-/scoping-logica (leerling-filters, aanmaken, groepsleiding-klas-scope) blijft bewust op de hoofdrol staan - PATCH /admin/users/:id accepteert een nieuw veld extraRoles (super-only, alleen voor staf) - Beheerpaneel: toggle-knoppen per stafgebruiker om extra rollen aan/uit te zetten; Instellingen toont voortaan alle rollen samengevoegd ("Schoolbeheerder + Groepsleiding") - Zoekveld boven de gebruikerslijst (live filter op naam, client-side, behoudt focus tijdens typen) omdat die lijst in een grote school lang kan worden Co-Authored-By: Claude Sonnet 5 --- VERSION | 2 +- db/004_user_roles.sql | 9 ++++++ public/css/teach.css | 2 ++ public/js/admin.js | 61 ++++++++++++++++++++++++++++++++-- public/js/core.js | 7 ++-- public/js/permissions.js | 7 ++-- src/api.js | 70 ++++++++++++++++++++++++++++------------ src/auth.js | 10 +++++- src/permissions.js | 7 +++- 9 files changed, 146 insertions(+), 29 deletions(-) create mode 100644 db/004_user_roles.sql diff --git a/VERSION b/VERSION index 3bfb9a4..599028f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.20 +0.2.21 diff --git a/db/004_user_roles.sql b/db/004_user_roles.sql new file mode 100644 index 0000000..5497ab3 --- /dev/null +++ b/db/004_user_roles.sql @@ -0,0 +1,9 @@ +-- v0.2.21: extra functies naast de hoofdrol (users.role) - alleen voor stafaccounts. +-- Leerlingen krijgen nooit een extra rol; dat wordt in de applicatielaag afgedwongen +-- (net als de bestaande regel dat alleen super rollen mag wijzigen). +CREATE TABLE IF NOT EXISTS user_roles ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL, + PRIMARY KEY (user_id, role) +); +CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id); diff --git a/public/css/teach.css b/public/css/teach.css index 7ec5aff..0573b8f 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1042,6 +1042,8 @@ border:2px solid #e3eaf3; border-radius:999px; padding:5px 12px; font-weight:800; font-size:13px; } +button.am-chip{cursor:pointer; color:var(--muted);} +button.am-chip.on{border-color:var(--blue); background:#eaf1fc; color:var(--blue-d);} .am-chipx{border:none; background:transparent; color:var(--red); font-weight:900; cursor:pointer; padding:0; font-size:11px;} .am-copy{ border:none; background:#e8eef6; border-radius:8px; padding:5px 8px; diff --git a/public/js/admin.js b/public/js/admin.js index 3126770..0714572 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -13,6 +13,12 @@ let resetResults = null; /* actief tabblad, blijft behouden over her-renders heen (na elke actie) */ let adminTab = "gebruikers"; + /* live zoekfilter voor de gebruikerslijst, blijft ook behouden over her-renders */ + let userFilter = ""; + + /* welke rollen de ingelogde gebruiker mag aanmaken: unie over hoofdrol + extra rollen */ + const creatableRoles = () => + [...new Set([currentUser.role, ...(currentUser.extraRoles||[])].flatMap(r => CREATABLE_ROLES[r] || []))]; const esc = s => String(s ?? "").replace(/&/g,"&").replace(/{ @@ -96,6 +102,23 @@ catch(e){ msg(e.message); } }); row.appendChild(rs); + + /* extra functies naast de hoofdrol (bv. een teacher die ook beheerdersrechten + krijgt) - los van de hoofdrol hierboven, direct toggelbaar per rol */ + const extraWrap = h("div","am-classes"); + ["teacher","admin","super"].filter(r=>r!==u.role).forEach(r=>{ + const has = (u.extraRoles||[]).includes(r); + const chip = h("button","am-chip", (has?"✓ ":"+ ")+T(roleKey(r))); + chip.type = "button"; + chip.classList.toggle("on", has); + chip.addEventListener("click", async ()=>{ + const next = has ? (u.extraRoles||[]).filter(x=>x!==r) : [...(u.extraRoles||[]), r]; + try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{extraRoles: next}}); reload(); } + catch(e){ msg(e.message); } + }); + extraWrap.appendChild(chip); + }); + row.appendChild(extraWrap); }else{ row.appendChild(h("span","am-role", T(roleKey(u.role)))); } @@ -312,7 +335,7 @@ /* overkoepelend beheerders 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(CREATABLE_ROLES[currentUser.role].includes("super")) panel.appendChild(addForm("super")); + if(creatableRoles().includes("super")) panel.appendChild(addForm("super")); } /* klassen (admin/super binnen een school) */ @@ -384,11 +407,44 @@ } } + /* zoekfilter voor de gebruikerslijst: verbergt niet-matchende rijen en lege groepen + via style.display i.p.v. een her-render, zodat het zoekveld focus houdt tijdens + het typen (een volledige render() zou het veld elke toets opnieuw aanmaken) */ + function applyUserFilter(panel){ + const q = userFilter.trim().toLowerCase(); + const list = panel.querySelector(".am-list"); + if(!list) return; + let pendingGroup = null, groupVisible = false; + [...list.children].forEach(el=>{ + if(el.classList.contains("am-group")){ + if(pendingGroup) pendingGroup.style.display = groupVisible ? "" : "none"; + pendingGroup = el; groupVisible = false; + }else if(el.classList.contains("am-row")){ + const name = el.querySelector(".am-name"); + const match = !q || (name && name.textContent.toLowerCase().includes(q)); + el.style.display = match ? "" : "none"; + if(match) groupVisible = true; + } + /* .am-add-wrap (toevoegrij) blijft altijd zichtbaar, ongeacht het filter */ + }); + if(pendingGroup) pendingGroup.style.display = groupVisible ? "" : "none"; + } + /* gebruikers: lijst (altijd, ook zonder gekozen school) + aanmaak-formulieren. Leerlingen krijgen een aparte behandeling: voor schoolbeheerder/super gegroepeerd per klas (met een inline toevoegrij per klas), voor groepsleiding een platte lijst (die dankzij de server-side scope toch al beperkt is tot de eigen klas(sen)). */ function renderGebruikersPanel(panel){ + const filterBar = h("div","am-add"); + const filterInp = h("input","am-inp am-filter"); + filterInp.type = "search"; + filterInp.placeholder = T("amSearchPh"); + filterInp.value = userFilter; + filterInp.addEventListener("keydown", ev=>ev.stopPropagation()); + filterInp.addEventListener("input", ()=>{ userFilter = filterInp.value; applyUserFilter(panel); }); + filterBar.appendChild(filterInp); + panel.appendChild(filterBar); + const list = h("div","am-list"); const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")]]; staffGroups.forEach(([role, label])=>{ @@ -400,7 +456,7 @@ const pupils = USERS.filter(u=>u.role==="pupil"); const schoolChosen = currentUser.role!=="super" || !!selSchool; - const creatable = CREATABLE_ROLES[currentUser.role]; + const creatable = creatableRoles(); const canAddPupil = schoolChosen && creatable.includes("pupil"); /* per-klas indeling heeft alleen zin met één gekozen school (CLASSES is dan @@ -436,6 +492,7 @@ if(schoolChosen){ ["teacher","admin"].forEach(r=>{ if(creatable.includes(r)) panel.appendChild(addForm(r)); }); } + applyUserFilter(panel); } function switchAdminTab(tab){ diff --git a/public/js/core.js b/public/js/core.js index a3d9089..7a7f417 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.2.20"; +const VERSION = "0.2.21"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -137,6 +137,7 @@ const I18N = { amNewSchool:"+ School", amNewClass:"+ Klas", amNewTeacher:"+ Groepsleiding", amNewAdmin:"+ Schoolbeheerder", amNewSuper:"+ Overkoepelend beheerder", amNewPupil:"+ Leerling", amUserPh:"gebruikersnaam…", amNamePh:"naam…", + amSearchPh:"🔎 zoek op naam…", amCode:"koppelcode", amNewCode:"nieuwe code", amPw:"wachtwoord", amNoClass:"— geen klas —", amPending:"nog niet geactiveerd", amCouple:"koppel leerkracht", amAllSchools:"— alle scholen —", amSuperGroup:"Overkoepelend", @@ -265,6 +266,7 @@ const I18N = { amNewSchool:"+ School", amNewClass:"+ Class", amNewTeacher:"+ Group leader", amNewAdmin:"+ School admin", amNewSuper:"+ Overarching admin", amNewPupil:"+ Pupil", amUserPh:"username…", amNamePh:"name…", + amSearchPh:"🔎 search by name…", amCode:"link code", amNewCode:"new code", amPw:"password", amNoClass:"— no class —", amPending:"not yet activated", amCouple:"link teacher", amAllSchools:"— all schools —", amSuperGroup:"Overarching", @@ -390,7 +392,8 @@ function updateUserUI(){ if(!showAdmin && adminTabBtn.classList.contains("active")) switchSettingsTab("account"); if(currentUser){ document.getElementById("helloName").textContent = currentUser.displayName; - document.getElementById("helloRole").textContent = T(roleKey(currentUser.role)); + const roles = [currentUser.role, ...(currentUser.extraRoles||[])]; + document.getElementById("helloRole").textContent = roles.map(r=>T(roleKey(r))).join(" + "); document.getElementById("ownPwBlock").style.display = can("password.own.change") ? "flex" : "none"; } } diff --git a/public/js/permissions.js b/public/js/permissions.js index 149f19d..d87f844 100644 --- a/public/js/permissions.js +++ b/public/js/permissions.js @@ -20,7 +20,10 @@ const CREATABLE_ROLES = { teacher: ['pupil'], }; -/* can(action) gebruikt de ingelogde gebruiker (currentUser, uit core.js) */ +/* can(action) gebruikt de ingelogde gebruiker (currentUser, uit core.js) - hoofdrol + plus eventuele extra stafrollen (currentUser.extraRoles) */ function can(action){ - return !!currentUser && (PERMISSIONS[action] || []).includes(currentUser.role); + if(!currentUser) return false; + const roles = [currentUser.role, ...(currentUser.extraRoles||[])]; + return (PERMISSIONS[action] || []).some(r => roles.includes(r)); } diff --git a/src/api.js b/src/api.js index ca7b9f2..5a71d8d 100644 --- a/src/api.js +++ b/src/api.js @@ -27,10 +27,20 @@ export default async function api(app) { }); const need = (req, reply, roles) => { if (!req.user) { reply.code(401); throw new Error('niet ingelogd'); } - if (roles && !roles.includes(req.user.role)) { reply.code(403); throw new Error('geen rechten'); } + if (roles && !roles.some((r) => req.user.allRoles.includes(r))) { reply.code(403); throw new Error('geen rechten'); } }; + // groepsleiding-only regels gelden alleen als iemand *uitsluitend* teacher is; + // wie er (via een extra rol) ook admin of super bij heeft, valt onder de + // ruimere regels van die rol i.p.v. de leerling-only-beperking van teacher. + const teacherOnly = (req) => + req.user.allRoles.includes('teacher') + && !req.user.allRoles.includes('admin') + && !req.user.allRoles.includes('super'); + // welke rollen iemand mag aanmaken: unie over al hun rollen (hoofd + extra) + const creatableRoles = (req) => + [...new Set(req.user.allRoles.flatMap((r) => CREATABLE_ROLES[r] || []))]; const sameSchool = (req, u) => - req.user.role === 'super' || Number(u.school_id) === Number(req.user.school_id); + req.user.allRoles.includes('super') || Number(u.school_id) === Number(req.user.school_id); app.setErrorHandler((err, req, reply) => { if (reply.statusCode >= 400 && reply.statusCode < 500) return reply.send({ error: err.message }); @@ -135,7 +145,7 @@ export default async function api(app) { app.post('/admin/schools/:id/reset-passwords', async (req, reply) => { need(req, reply, PERMISSIONS['schools.resetPasswords']); const schoolId = req.params.id; - if (req.user.role !== 'super' && Number(schoolId) !== Number(req.user.school_id)) + if (!req.user.allRoles.includes('super') && Number(schoolId) !== Number(req.user.school_id)) return fail(reply, 403, 'geen rechten'); const pupils = (await pool.query( "SELECT * FROM users WHERE school_id = $1 AND role = 'pupil' ORDER BY lower(username)", [schoolId])).rows; @@ -152,7 +162,7 @@ export default async function api(app) { // ---- klassen ----------------------------------------------------------------- app.get('/admin/classes', async (req, reply) => { need(req, reply, PERMISSIONS['classes.view']); - const schoolId = req.user.role === 'super' ? req.query.school : req.user.school_id; + const schoolId = req.user.allRoles.includes('super') ? req.query.school : req.user.school_id; if (!schoolId) return { classes: [] }; const r = await pool.query( `SELECT c.id, c.name, @@ -164,7 +174,7 @@ export default async function api(app) { app.post('/admin/classes', async (req, reply) => { need(req, reply, PERMISSIONS['classes.manage']); const { name, school } = req.body ?? {}; - const schoolId = req.user.role === 'super' ? school : req.user.school_id; + const schoolId = req.user.allRoles.includes('super') ? school : req.user.school_id; if (!name || !schoolId) return fail(reply, 400, 'naam en school verplicht'); const r = await pool.query('INSERT INTO classes (school_id, name) VALUES ($1,$2) RETURNING id, name', [schoolId, name.trim()]); return { class: { id: Number(r.rows[0].id), name: r.rows[0].name } }; @@ -173,7 +183,7 @@ export default async function api(app) { need(req, reply, PERMISSIONS['classes.manage']); const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]); if (!r.rows[0]) return fail(reply, 404, 'klas onbekend'); - if (req.user.role !== 'super' && Number(r.rows[0].school_id) !== Number(req.user.school_id)) + if (!req.user.allRoles.includes('super') && Number(r.rows[0].school_id) !== Number(req.user.school_id)) return fail(reply, 403, 'geen rechten'); await pool.query('DELETE FROM classes WHERE id = $1', [req.params.id]); return { ok: true }; @@ -195,7 +205,7 @@ export default async function api(app) { const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]); const c = r.rows[0]; if (!c) return fail(reply, 404, 'klas onbekend'); - if (req.user.role !== 'super' && Number(c.school_id) !== Number(req.user.school_id)) + if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id)) return fail(reply, 403, 'geen rechten'); const pupils = (await pool.query( "SELECT * FROM users WHERE class_id = $1 AND role = 'pupil' ORDER BY lower(username)", [c.id])).rows; @@ -222,11 +232,11 @@ export default async function api(app) { app.get('/admin/users', async (req, reply) => { need(req, reply, PERMISSIONS['users.manage']); let rows; - if (req.user.role === 'super') { + if (req.user.allRoles.includes('super')) { rows = (req.query.school ? await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.query.school]) : await pool.query('SELECT * FROM users ORDER BY school_id NULLS FIRST, role, lower(username)')).rows; - } else if (req.user.role === 'teacher') { + } else if (teacherOnly(req)) { rows = (await pool.query( `SELECT * FROM users WHERE school_id = $1 @@ -236,9 +246,18 @@ export default async function api(app) { } else { rows = (await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.user.school_id])).rows; } - const canSeePw = can(req.user.role, 'users.manage'); + const canSeePw = can(req.user.allRoles, 'users.manage'); + // extra stafrollen per gebruiker erbij ophalen (in één keer, niet per rij) + const roleRows = rows.length + ? (await pool.query('SELECT user_id, role FROM user_roles WHERE user_id = ANY($1)', [rows.map((u) => u.id)])).rows + : []; + const extraByUser = new Map(); + roleRows.forEach((r) => { + const key = String(r.user_id); + extraByUser.set(key, [...(extraByUser.get(key) || []), r.role]); + }); return { users: rows.map((u) => ({ - ...publicUser(u), + ...publicUser({ ...u, extra_roles: extraByUser.get(String(u.id)) || [] }), linkCode: u.link_code || null, // leerlingwachtwoord is alleen inzichtelijk zolang het account nog niet // gebruikt is (na de eerste keer inloggen wordt password_plain gewist) @@ -253,8 +272,8 @@ export default async function api(app) { need(req, reply, PERMISSIONS['users.manage']); const b = req.body ?? {}; const role = b.role; - if (!CREATABLE_ROLES[req.user.role].includes(role)) return fail(reply, 403, 'geen rechten voor deze rol'); - const schoolId = req.user.role === 'super' ? (role === 'super' ? null : b.school) : req.user.school_id; + if (!creatableRoles(req).includes(role)) return fail(reply, 403, 'geen rechten voor deze rol'); + 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(); if (!/^[a-zA-Z0-9_.-]{2,30}$/.test(username)) return fail(reply, 400, 'ongeldige gebruikersnaam'); @@ -300,8 +319,8 @@ export default async function api(app) { const u = r.rows[0]; if (!u) return fail(reply, 404, 'gebruiker onbekend'); if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten'); - if (req.user.role === 'teacher' && u.role !== 'pupil') return fail(reply, 403, 'groepsleiding beheert alleen leerlingen'); - if (u.role === 'super' && req.user.role !== 'super') return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'groepsleiding beheert alleen leerlingen'); + if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten'); const b = req.body ?? {}; if (b.displayName) await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName, u.id]); if (b.classId !== undefined && u.role === 'pupil') @@ -314,21 +333,32 @@ export default async function api(app) { } // groepsleiding/beheer: direct een nieuw wachtwoord zetten (zelf gekozen // of gegenereerd) zonder de koppelcode-activatie opnieuw te doorlopen. - if ((b.password || b.generatePassword) && u.role !== 'pupil' && can(req.user.role, 'users.staffCredentials')) { + if ((b.password || b.generatePassword) && u.role !== 'pupil' && can(req.user.allRoles, 'users.staffCredentials')) { const pw = b.password || staffPassword(); const err = checkPassword(pw, u.role); if (err) return fail(reply, 400, err); await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [await hashPassword(pw), u.id]); return { ok: true, password: pw }; } - if (b.newLinkCode && u.role !== 'pupil' && can(req.user.role, 'users.staffCredentials')) { + if (b.newLinkCode && u.role !== 'pupil' && can(req.user.allRoles, 'users.staffCredentials')) { const code = newLinkCode(); await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]); return { ok: true, linkCode: code }; } - if (b.role && can(req.user.role, 'users.role.change') && ['super', 'admin', 'teacher'].includes(b.role) && u.role !== 'pupil') + if (b.role && can(req.user.allRoles, 'users.role.change') && ['super', 'admin', 'teacher'].includes(b.role) && u.role !== 'pupil') 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) - + // alleen voor staf, zelfde recht als hoofdrol-wijziging; hoofdrol zelf telt niet + // ook nog als "extra" + if (b.extraRoles !== undefined && can(req.user.allRoles, 'users.role.change') && u.role !== 'pupil') { + const extra = [...new Set(b.extraRoles)].filter((r) => ['super', 'admin', 'teacher'].includes(r) && r !== u.role); + await pool.query('DELETE FROM user_roles WHERE user_id = $1', [u.id]); + for (const role of extra) { + await pool.query('INSERT INTO user_roles (user_id, role) VALUES ($1, $2) ON CONFLICT DO NOTHING', [u.id, role]); + } + return { ok: true, extraRoles: extra }; + } return { ok: true }; }); @@ -339,8 +369,8 @@ export default async function api(app) { if (!u) return fail(reply, 404, 'gebruiker onbekend'); if (Number(u.id) === Number(req.user.id)) return fail(reply, 400, 'je kunt jezelf niet verwijderen'); if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten'); - if (req.user.role === 'teacher' && u.role !== 'pupil') return fail(reply, 403, 'geen rechten'); - if (u.role === 'super' && req.user.role !== 'super') return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'geen rechten'); + if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten'); await pool.query('DELETE FROM users WHERE id = $1', [u.id]); return { ok: true }; }); diff --git a/src/auth.js b/src/auth.js index 07c05ae..4ddb7c8 100644 --- a/src/auth.js +++ b/src/auth.js @@ -88,7 +88,14 @@ export async function userFromRequest(pool, req) { WHERE s.token = $1 AND s.expires_at > now()`, [token], ); - return r.rows[0] ?? null; + const u = r.rows[0]; + if (!u) return null; + // extra stafrollen naast de hoofdrol (role) - alleen relevant voor rechten-checks, + // zie src/permissions.js + const er = await pool.query('SELECT role FROM user_roles WHERE user_id = $1', [u.id]); + u.extra_roles = er.rows.map((row) => row.role); + u.allRoles = [u.role, ...u.extra_roles]; + return u; } // Publiek profiel (zonder wachtwoordvelden) @@ -99,6 +106,7 @@ export function publicUser(u) { username: u.username, displayName: u.display_name || u.username, role: u.role, + extraRoles: u.extra_roles || [], schoolId: u.school_id == null ? null : Number(u.school_id), classId: u.class_id == null ? null : Number(u.class_id), pending: !u.password_hash && !!u.link_code, diff --git a/src/permissions.js b/src/permissions.js index e035d71..8bb05f3 100644 --- a/src/permissions.js +++ b/src/permissions.js @@ -23,4 +23,9 @@ export const CREATABLE_ROLES = { teacher: ['pupil'], }; -export const can = (role, action) => (PERMISSIONS[action] || []).includes(role); +// roles mag een losse rol-string zijn (bestaande aanroepen) of een array van alle +// rollen die een gebruiker heeft (hoofdrol + eventuele extra stafrollen). +export const can = (roles, action) => { + const list = Array.isArray(roles) ? roles : [roles]; + return (PERMISSIONS[action] || []).some(r => list.includes(r)); +};