Voeg thematische kleurplaten en creatieve niveaus toe (v0.4.65-beta) #3
9 changed files with 146 additions and 29 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.2.20
|
0.2.21
|
||||||
|
|
|
||||||
9
db/004_user_roles.sql
Normal file
9
db/004_user_roles.sql
Normal 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);
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,"&").replace(/</g,"<");
|
const esc = s => String(s ?? "").replace(/&/g,"&").replace(/</g,"<");
|
||||||
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){
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
70
src/api.js
70
src/api.js
|
|
@ -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 };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
10
src/auth.js
10
src/auth.js
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue