Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-66 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;
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;

View file

@ -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,"&amp;").replace(/</g,"&lt;");
const h = (tag, cls, text)=>{
@ -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){

View file

@ -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";
}
}

View file

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

View file

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

View file

@ -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,

View file

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