Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5

Open
bes-r wants to merge 208 commits from bes-r/coloring-quality-v0.4.67 into main AGit
9 changed files with 146 additions and 29 deletions
Showing only changes of commit 2528e5b8f6 - Show all commits

View file

@ -1 +1 @@
0.2.20 0.2.21

9
db/004_user_roles.sql Normal file
View file

@ -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);

View file

@ -1042,6 +1042,8 @@
border:2px solid #e3eaf3; border-radius:999px; padding:5px 12px; border:2px solid #e3eaf3; border-radius:999px; padding:5px 12px;
font-weight:800; font-size:13px; 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-chipx{border:none; background:transparent; color:var(--red); font-weight:900; cursor:pointer; padding:0; font-size:11px;}
.am-copy{ .am-copy{
border:none; background:#e8eef6; border-radius:8px; padding:5px 8px; border:none; background:#e8eef6; border-radius:8px; padding:5px 8px;

View file

@ -13,6 +13,12 @@
let resetResults = null; let resetResults = null;
/* actief tabblad, blijft behouden over her-renders heen (na elke actie) */ /* actief tabblad, blijft behouden over her-renders heen (na elke actie) */
let adminTab = "gebruikers"; 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,"&amp;").replace(/</g,"&lt;"); const esc = s => String(s ?? "").replace(/&/g,"&amp;").replace(/</g,"&lt;");
const h = (tag, cls, text)=>{ const h = (tag, cls, text)=>{
@ -96,6 +102,23 @@
catch(e){ msg(e.message); } catch(e){ msg(e.message); }
}); });
row.appendChild(rs); 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{ }else{
row.appendChild(h("span","am-role", T(roleKey(u.role)))); 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 /* 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 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) */ (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) */ /* 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. /* gebruikers: lijst (altijd, ook zonder gekozen school) + aanmaak-formulieren.
Leerlingen krijgen een aparte behandeling: voor schoolbeheerder/super gegroepeerd Leerlingen krijgen een aparte behandeling: voor schoolbeheerder/super gegroepeerd
per klas (met een inline toevoegrij per klas), voor groepsleiding een platte lijst 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)). */ (die dankzij de server-side scope toch al beperkt is tot de eigen klas(sen)). */
function renderGebruikersPanel(panel){ 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 list = h("div","am-list");
const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")]]; const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")]];
staffGroups.forEach(([role, label])=>{ staffGroups.forEach(([role, label])=>{
@ -400,7 +456,7 @@
const pupils = USERS.filter(u=>u.role==="pupil"); const pupils = USERS.filter(u=>u.role==="pupil");
const schoolChosen = currentUser.role!=="super" || !!selSchool; const schoolChosen = currentUser.role!=="super" || !!selSchool;
const creatable = CREATABLE_ROLES[currentUser.role]; const creatable = creatableRoles();
const canAddPupil = schoolChosen && creatable.includes("pupil"); const canAddPupil = schoolChosen && creatable.includes("pupil");
/* per-klas indeling heeft alleen zin met één gekozen school (CLASSES is dan /* per-klas indeling heeft alleen zin met één gekozen school (CLASSES is dan
@ -436,6 +492,7 @@
if(schoolChosen){ if(schoolChosen){
["teacher","admin"].forEach(r=>{ if(creatable.includes(r)) panel.appendChild(addForm(r)); }); ["teacher","admin"].forEach(r=>{ if(creatable.includes(r)) panel.appendChild(addForm(r)); });
} }
applyUserFilter(panel);
} }
function switchAdminTab(tab){ function switchAdminTab(tab){

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* 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. */ 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(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -137,6 +137,7 @@ const I18N = {
amNewSchool:" School", amNewClass:" Klas", amNewTeacher:" Groepsleiding", amNewSchool:" School", amNewClass:" Klas", amNewTeacher:" Groepsleiding",
amNewAdmin:" Schoolbeheerder", amNewSuper:" Overkoepelend beheerder", amNewAdmin:" Schoolbeheerder", amNewSuper:" Overkoepelend beheerder",
amNewPupil:" Leerling", amUserPh:"gebruikersnaam…", amNamePh:"naam…", amNewPupil:" Leerling", amUserPh:"gebruikersnaam…", amNamePh:"naam…",
amSearchPh:"🔎 zoek op naam…",
amCode:"koppelcode", amNewCode:"nieuwe code", amPw:"wachtwoord", amCode:"koppelcode", amNewCode:"nieuwe code", amPw:"wachtwoord",
amNoClass:"— geen klas —", amPending:"nog niet geactiveerd", amNoClass:"— geen klas —", amPending:"nog niet geactiveerd",
amCouple:"koppel leerkracht", amAllSchools:"— alle scholen —", amSuperGroup:"Overkoepelend", amCouple:"koppel leerkracht", amAllSchools:"— alle scholen —", amSuperGroup:"Overkoepelend",
@ -265,6 +266,7 @@ const I18N = {
amNewSchool:" School", amNewClass:" Class", amNewTeacher:" Group leader", amNewSchool:" School", amNewClass:" Class", amNewTeacher:" Group leader",
amNewAdmin:" School admin", amNewSuper:" Overarching admin", amNewAdmin:" School admin", amNewSuper:" Overarching admin",
amNewPupil:" Pupil", amUserPh:"username…", amNamePh:"name…", amNewPupil:" Pupil", amUserPh:"username…", amNamePh:"name…",
amSearchPh:"🔎 search by name…",
amCode:"link code", amNewCode:"new code", amPw:"password", amCode:"link code", amNewCode:"new code", amPw:"password",
amNoClass:"— no class —", amPending:"not yet activated", amNoClass:"— no class —", amPending:"not yet activated",
amCouple:"link teacher", amAllSchools:"— all schools —", amSuperGroup:"Overarching", amCouple:"link teacher", amAllSchools:"— all schools —", amSuperGroup:"Overarching",
@ -390,7 +392,8 @@ function updateUserUI(){
if(!showAdmin && adminTabBtn.classList.contains("active")) switchSettingsTab("account"); if(!showAdmin && adminTabBtn.classList.contains("active")) switchSettingsTab("account");
if(currentUser){ if(currentUser){
document.getElementById("helloName").textContent = currentUser.displayName; 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"; document.getElementById("ownPwBlock").style.display = can("password.own.change") ? "flex" : "none";
} }
} }

View file

@ -20,7 +20,10 @@ const CREATABLE_ROLES = {
teacher: ['pupil'], 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){ 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));
} }

View file

@ -27,10 +27,20 @@ export default async function api(app) {
}); });
const need = (req, reply, roles) => { const need = (req, reply, roles) => {
if (!req.user) { reply.code(401); throw new Error('niet ingelogd'); } 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) => 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) => { app.setErrorHandler((err, req, reply) => {
if (reply.statusCode >= 400 && reply.statusCode < 500) return reply.send({ error: err.message }); 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) => { app.post('/admin/schools/:id/reset-passwords', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.resetPasswords']); need(req, reply, PERMISSIONS['schools.resetPasswords']);
const schoolId = req.params.id; 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'); return fail(reply, 403, 'geen rechten');
const pupils = (await pool.query( const pupils = (await pool.query(
"SELECT * FROM users WHERE school_id = $1 AND role = 'pupil' ORDER BY lower(username)", [schoolId])).rows; "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 ----------------------------------------------------------------- // ---- klassen -----------------------------------------------------------------
app.get('/admin/classes', async (req, reply) => { app.get('/admin/classes', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.view']); 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: [] }; if (!schoolId) return { classes: [] };
const r = await pool.query( const r = await pool.query(
`SELECT c.id, c.name, `SELECT c.id, c.name,
@ -164,7 +174,7 @@ export default async function api(app) {
app.post('/admin/classes', async (req, reply) => { app.post('/admin/classes', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.manage']); need(req, reply, PERMISSIONS['classes.manage']);
const { name, school } = req.body ?? {}; 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'); 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()]); 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 } }; 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']); need(req, reply, PERMISSIONS['classes.manage']);
const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]); 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 (!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'); return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM classes WHERE id = $1', [req.params.id]); await pool.query('DELETE FROM classes WHERE id = $1', [req.params.id]);
return { ok: true }; 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 r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]);
const c = r.rows[0]; const c = r.rows[0];
if (!c) return fail(reply, 404, 'klas onbekend'); 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'); return fail(reply, 403, 'geen rechten');
const pupils = (await pool.query( const pupils = (await pool.query(
"SELECT * FROM users WHERE class_id = $1 AND role = 'pupil' ORDER BY lower(username)", [c.id])).rows; "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) => { app.get('/admin/users', async (req, reply) => {
need(req, reply, PERMISSIONS['users.manage']); need(req, reply, PERMISSIONS['users.manage']);
let rows; let rows;
if (req.user.role === 'super') { if (req.user.allRoles.includes('super')) {
rows = (req.query.school 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 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; : 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( rows = (await pool.query(
`SELECT * FROM users `SELECT * FROM users
WHERE school_id = $1 WHERE school_id = $1
@ -236,9 +246,18 @@ export default async function api(app) {
} else { } else {
rows = (await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.user.school_id])).rows; 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) => ({ return { users: rows.map((u) => ({
...publicUser(u), ...publicUser({ ...u, extra_roles: extraByUser.get(String(u.id)) || [] }),
linkCode: u.link_code || null, linkCode: u.link_code || null,
// leerlingwachtwoord is alleen inzichtelijk zolang het account nog niet // leerlingwachtwoord is alleen inzichtelijk zolang het account nog niet
// gebruikt is (na de eerste keer inloggen wordt password_plain gewist) // 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']); need(req, reply, PERMISSIONS['users.manage']);
const b = req.body ?? {}; const b = req.body ?? {};
const role = b.role; const role = b.role;
if (!CREATABLE_ROLES[req.user.role].includes(role)) return fail(reply, 403, 'geen rechten voor deze rol'); if (!creatableRoles(req).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; 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'); if (role !== 'super' && !schoolId) return fail(reply, 400, 'school verplicht');
const username = (b.username || '').trim(); const username = (b.username || '').trim();
if (!/^[a-zA-Z0-9_.-]{2,30}$/.test(username)) return fail(reply, 400, 'ongeldige gebruikersnaam'); 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]; const u = r.rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend'); if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten'); 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 (teacherOnly(req) && 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 (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten');
const b = req.body ?? {}; 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.displayName) await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName, u.id]);
if (b.classId !== undefined && u.role === 'pupil') 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 // groepsleiding/beheer: direct een nieuw wachtwoord zetten (zelf gekozen
// of gegenereerd) zonder de koppelcode-activatie opnieuw te doorlopen. // 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 pw = b.password || staffPassword();
const err = checkPassword(pw, u.role); const err = checkPassword(pw, u.role);
if (err) return fail(reply, 400, err); 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]); 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 }; 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(); const code = newLinkCode();
await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]); await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]);
return { ok: true, linkCode: code }; 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', 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]); [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 }; return { ok: true };
}); });
@ -339,8 +369,8 @@ export default async function api(app) {
if (!u) return fail(reply, 404, 'gebruiker onbekend'); 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 (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 (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten');
if (req.user.role === 'teacher' && u.role !== 'pupil') return fail(reply, 403, 'geen rechten'); if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'geen rechten');
if (u.role === 'super' && req.user.role !== 'super') 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]); await pool.query('DELETE FROM users WHERE id = $1', [u.id]);
return { ok: true }; return { ok: true };
}); });

View file

@ -88,7 +88,14 @@ export async function userFromRequest(pool, req) {
WHERE s.token = $1 AND s.expires_at > now()`, WHERE s.token = $1 AND s.expires_at > now()`,
[token], [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) // Publiek profiel (zonder wachtwoordvelden)
@ -99,6 +106,7 @@ export function publicUser(u) {
username: u.username, username: u.username,
displayName: u.display_name || u.username, displayName: u.display_name || u.username,
role: u.role, role: u.role,
extraRoles: u.extra_roles || [],
schoolId: u.school_id == null ? null : Number(u.school_id), schoolId: u.school_id == null ? null : Number(u.school_id),
classId: u.class_id == null ? null : Number(u.class_id), classId: u.class_id == null ? null : Number(u.class_id),
pending: !u.password_hash && !!u.link_code, pending: !u.password_hash && !!u.link_code,

View file

@ -23,4 +23,9 @@ export const CREATABLE_ROLES = {
teacher: ['pupil'], 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));
};