Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5
6 changed files with 133 additions and 8 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.3.35-beta
|
0.3.36-beta
|
||||||
|
|
|
||||||
|
|
@ -1153,6 +1153,13 @@
|
||||||
/* inklapbare groepskop (leerlingen per klas): klikbaar, pijltje in de tekst */
|
/* inklapbare groepskop (leerlingen per klas): klikbaar, pijltje in de tekst */
|
||||||
.am-group-toggle{cursor:pointer; user-select:none;}
|
.am-group-toggle{cursor:pointer; user-select:none;}
|
||||||
.am-group-toggle:hover{color:var(--ink);}
|
.am-group-toggle:hover{color:var(--ink);}
|
||||||
|
/* Systeem-tab: schoolrij duidelijk zwaarder dan de beheerder-subrijen */
|
||||||
|
.am-row-school{background:var(--accent-soft);}
|
||||||
|
.am-row-school > .am-name{font-size:15px;}
|
||||||
|
.am-row-sub{
|
||||||
|
margin-left:22px; background:var(--surface);
|
||||||
|
border-left:3px solid var(--line); border-radius:var(--radius-s);
|
||||||
|
}
|
||||||
/* uitklapbaar gereedschapsblok per gebruikersrij (achter het ✏-knopje) */
|
/* uitklapbaar gereedschapsblok per gebruikersrij (achter het ✏-knopje) */
|
||||||
.am-row-tools{
|
.am-row-tools{
|
||||||
display:flex; gap:8px; align-items:center; flex-wrap:wrap; width:100%;
|
display:flex; gap:8px; align-items:center; flex-wrap:wrap; width:100%;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
geen los venster meer - modal is alleen nog de content-container voor render() */
|
geen los venster meer - modal is alleen nog de content-container voor render() */
|
||||||
const modal = document.getElementById("adminModal");
|
const modal = document.getElementById("adminModal");
|
||||||
let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null;
|
let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null;
|
||||||
|
/* schoolbeheerders van alle scholen, voor het Systeem-tabblad (super) */
|
||||||
|
let SYS_ADMINS = [];
|
||||||
/* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw
|
/* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw
|
||||||
opgehaald bij elke reload() zoals CLASSES/USERS hierboven */
|
opgehaald bij elke reload() zoals CLASSES/USERS hierboven */
|
||||||
let ASSIGNMENTS = {};
|
let ASSIGNMENTS = {};
|
||||||
|
|
@ -159,6 +161,13 @@
|
||||||
if(currentUser.role==="super") SCHOOLS = await api("/schools");
|
if(currentUser.role==="super") SCHOOLS = await api("/schools");
|
||||||
const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : "";
|
const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : "";
|
||||||
USERS = (await api("/admin/users"+q)).users;
|
USERS = (await api("/admin/users"+q)).users;
|
||||||
|
/* het Systeem-tabblad toont schoolbeheerders van álle scholen, ook als
|
||||||
|
de contextbalk op één school staat; zonder schoolfilter is USERS al
|
||||||
|
compleet en is de extra call overbodig */
|
||||||
|
if(currentUser.role==="super"){
|
||||||
|
SYS_ADMINS = q ? ((await api("/admin/users")).users || []).filter(u=>u.role==="admin")
|
||||||
|
: USERS.filter(u=>u.role==="admin");
|
||||||
|
}
|
||||||
const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=0") : "";
|
const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=0") : "";
|
||||||
CLASSES = (await api("/admin/classes"+cq)).classes || [];
|
CLASSES = (await api("/admin/classes"+cq)).classes || [];
|
||||||
ASSIGNMENTS = {};
|
ASSIGNMENTS = {};
|
||||||
|
|
@ -178,13 +187,14 @@
|
||||||
return sel;
|
return sel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function userRow(u){
|
function userRow(u, opts){
|
||||||
const row = h("div","am-row");
|
const row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : ""));
|
||||||
/* rustig standaardbeeld: alleen naam + rol/klas; alle knoppen en velden
|
/* rustig standaardbeeld: alleen naam + rol/klas; alle knoppen en velden
|
||||||
zitten achter het ✏-knopje in een uitklapbaar gereedschapsblok */
|
zitten achter het ✏-knopje in een uitklapbaar gereedschapsblok */
|
||||||
const tools = h("div","am-row-tools");
|
const tools = h("div","am-row-tools");
|
||||||
const actions = h("div","am-actions");
|
const actions = h("div","am-actions");
|
||||||
row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : "")));
|
const icon = opts && opts.icon ? opts.icon + " " : "";
|
||||||
|
row.appendChild(h("span","am-name", icon + u.displayName + (u.displayName!==u.username ? ` (${u.username})` : "")));
|
||||||
if(u.role==="pupil"){
|
if(u.role==="pupil"){
|
||||||
const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId));
|
const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId));
|
||||||
row.appendChild(h("span","am-role", T(roleKey(u.role)) + (cls ? ` · ${cls.name}` : "")));
|
row.appendChild(h("span","am-role", T(roleKey(u.role)) + (cls ? ` · ${cls.name}` : "")));
|
||||||
|
|
@ -427,8 +437,25 @@
|
||||||
function renderScholenPanel(panel){
|
function renderScholenPanel(panel){
|
||||||
const list = h("div","am-list");
|
const list = h("div","am-list");
|
||||||
SCHOOLS.forEach(s=>{
|
SCHOOLS.forEach(s=>{
|
||||||
const row = h("div","am-row");
|
const admins = SYS_ADMINS.filter(a=>Number(a.schoolId)===Number(s.id));
|
||||||
row.appendChild(h("span","am-name", s.name));
|
const row = h("div","am-row am-row-school");
|
||||||
|
row.appendChild(h("span","am-name", "🏫 " + s.name));
|
||||||
|
row.appendChild(h("span","am-role",
|
||||||
|
`${admins.length} ${(admins.length===1 ? T("amAdminOne") : T("amAdmins")).toLowerCase()}`));
|
||||||
|
/* zelfde ✏-systeem als bij gebruikers: hernoemen en verwijderen
|
||||||
|
zitten achter het gereedschapsblok, de rij zelf blijft rustig */
|
||||||
|
const tools = h("div","am-row-tools");
|
||||||
|
const nameInp = h("input","am-inp");
|
||||||
|
nameInp.value = s.name;
|
||||||
|
nameInp.maxLength = 80;
|
||||||
|
nameInp.addEventListener("keydown", ev=>ev.stopPropagation());
|
||||||
|
nameInp.addEventListener("change", async ()=>{
|
||||||
|
const name = nameInp.value.trim();
|
||||||
|
if(name.length < 2 || name === s.name) return;
|
||||||
|
try{ await api("/admin/schools/"+s.id, {method:"PATCH", body:{ name }}); reload(); }
|
||||||
|
catch(e){ msg(e.message); }
|
||||||
|
});
|
||||||
|
tools.appendChild(field("amFieldName", nameInp));
|
||||||
const acts = h("div","am-actions");
|
const acts = h("div","am-actions");
|
||||||
acts.appendChild(dangerBtn("🗑 " + T("amDelete"), "amDeleteSchoolWarn", async ()=>{
|
acts.appendChild(dangerBtn("🗑 " + T("amDelete"), "amDeleteSchoolWarn", async ()=>{
|
||||||
try{
|
try{
|
||||||
|
|
@ -437,8 +464,27 @@
|
||||||
reload();
|
reload();
|
||||||
}catch(e){ msg(e.message); }
|
}catch(e){ msg(e.message); }
|
||||||
}));
|
}));
|
||||||
row.appendChild(acts);
|
tools.appendChild(field("amFieldActions", acts));
|
||||||
|
const edit = h("button","am-edit","✏");
|
||||||
|
edit.type = "button";
|
||||||
|
edit.title = T("amEdit");
|
||||||
|
const key = "school:"+s.id;
|
||||||
|
const sync = ()=>{
|
||||||
|
const open = openRows.has(key);
|
||||||
|
tools.style.display = open ? "" : "none";
|
||||||
|
edit.classList.toggle("on", open);
|
||||||
|
};
|
||||||
|
edit.addEventListener("click", ()=>{
|
||||||
|
if(openRows.has(key)) openRows.delete(key); else openRows.add(key);
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
sync();
|
||||||
|
row.appendChild(edit);
|
||||||
|
row.appendChild(tools);
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
|
/* de schoolbeheerders van deze school, duidelijk onderscheiden van de
|
||||||
|
schoolrij (🛡-icoon, ingesprongen) maar met hetzelfde ✏-gereedschap */
|
||||||
|
admins.forEach(a=>list.appendChild(userRow(a, { icon:"🛡", sub:true })));
|
||||||
});
|
});
|
||||||
panel.appendChild(list);
|
panel.appendChild(list);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.3.35-beta";
|
const VERSION = "0.3.36-beta";
|
||||||
(function(){
|
(function(){
|
||||||
const tag = document.getElementById("verTag");
|
const tag = document.getElementById("verTag");
|
||||||
tag.textContent = "v"+VERSION;
|
tag.textContent = "v"+VERSION;
|
||||||
|
|
@ -183,6 +183,7 @@ const I18N = {
|
||||||
amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem",
|
amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem",
|
||||||
amPickSchoolHint:"Kies hierboven eerst een school (🏫).", amPickSchool:"Kies een school",
|
amPickSchoolHint:"Kies hierboven eerst een school (🏫).", amPickSchool:"Kies een school",
|
||||||
amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken",
|
amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken",
|
||||||
|
amAdmins:"Schoolbeheerders", amAdminOne:"Schoolbeheerder",
|
||||||
amSure:"weet je het zeker?", amDelete:"verwijder",
|
amSure:"weet je het zeker?", amDelete:"verwijder",
|
||||||
amFieldName:"Naam", amFieldRole:"Functie", amFieldExtraRoles:"Extra functies", amFieldClass:"Klas",
|
amFieldName:"Naam", amFieldRole:"Functie", amFieldExtraRoles:"Extra functies", amFieldClass:"Klas",
|
||||||
amFieldPw:"Wachtwoord", amFieldActions:"Acties", amPwTypePh:"typ een wachtwoord…",
|
amFieldPw:"Wachtwoord", amFieldActions:"Acties", amPwTypePh:"typ een wachtwoord…",
|
||||||
|
|
@ -379,6 +380,7 @@ const I18N = {
|
||||||
amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System",
|
amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System",
|
||||||
amPickSchoolHint:"First pick a school above (🏫).", amPickSchool:"Pick a school",
|
amPickSchoolHint:"First pick a school above (🏫).", amPickSchool:"Pick a school",
|
||||||
amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit",
|
amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit",
|
||||||
|
amAdmins:"School admins", amAdminOne:"School admin",
|
||||||
amSure:"are you sure?", amDelete:"delete",
|
amSure:"are you sure?", amDelete:"delete",
|
||||||
amFieldName:"Name", amFieldRole:"Role", amFieldExtraRoles:"Extra roles", amFieldClass:"Class",
|
amFieldName:"Name", amFieldRole:"Role", amFieldExtraRoles:"Extra roles", amFieldClass:"Class",
|
||||||
amFieldPw:"Password", amFieldActions:"Actions", amPwTypePh:"type a password…",
|
amFieldPw:"Password", amFieldActions:"Actions", amPwTypePh:"type a password…",
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,14 @@ export default async function api(app) {
|
||||||
const r = await pool.query('INSERT INTO schools (name) VALUES ($1) RETURNING id, name', [name.trim()]);
|
const r = await pool.query('INSERT INTO schools (name) VALUES ($1) RETURNING id, name', [name.trim()]);
|
||||||
return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } };
|
return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } };
|
||||||
});
|
});
|
||||||
|
app.patch('/admin/schools/:id', async (req, reply) => {
|
||||||
|
need(req, reply, PERMISSIONS['schools.manage']);
|
||||||
|
const { name } = req.body ?? {};
|
||||||
|
if (!name || typeof name !== 'string' || !name.trim() || name.trim().length > 80) return fail(reply, 400, 'ongeldige naam');
|
||||||
|
const r = await pool.query('UPDATE schools SET name = $1 WHERE id = $2 RETURNING id, name', [name.trim(), req.params.id]);
|
||||||
|
if (!r.rows[0]) return fail(reply, 404, 'school onbekend');
|
||||||
|
return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } };
|
||||||
|
});
|
||||||
app.delete('/admin/schools/:id', async (req, reply) => {
|
app.delete('/admin/schools/:id', async (req, reply) => {
|
||||||
need(req, reply, PERMISSIONS['schools.manage']);
|
need(req, reply, PERMISSIONS['schools.manage']);
|
||||||
await pool.query('DELETE FROM schools WHERE id = $1', [req.params.id]);
|
await pool.query('DELETE FROM schools WHERE id = $1', [req.params.id]);
|
||||||
|
|
|
||||||
62
test/schools.test.js
Normal file
62
test/schools.test.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import api from '../src/api.js';
|
||||||
|
|
||||||
|
function makeApp(user) {
|
||||||
|
const calls = [];
|
||||||
|
const pool = {
|
||||||
|
async query(sql, params = []) {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] };
|
||||||
|
if (sql.includes('FROM user_roles')) return { rows: [] };
|
||||||
|
if (sql.startsWith('UPDATE schools SET name')) return { rows: [{ id: params[1], name: params[0] }] };
|
||||||
|
return { rows: [] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (async () => {
|
||||||
|
const app = Fastify({ trustProxy: 2 });
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(rateLimit, { global: false });
|
||||||
|
app.decorate('pg', pool);
|
||||||
|
await app.register(api, { prefix: '/api' });
|
||||||
|
await app.ready();
|
||||||
|
return { app, calls };
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = { teach_session: 'x'.repeat(64) };
|
||||||
|
const superUser = { id: 1, username: 'sm', role: 'super', school_id: null, class_id: null, data: {}, data_rev: 0 };
|
||||||
|
const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 };
|
||||||
|
|
||||||
|
test('systeemmanager kan een school hernoemen', async () => {
|
||||||
|
const { app, calls } = await makeApp(superUser);
|
||||||
|
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
|
||||||
|
payload: { name: 'De Nieuwe Naam' } });
|
||||||
|
assert.equal(res.statusCode, 200, res.body);
|
||||||
|
assert.equal(res.json().school.name, 'De Nieuwe Naam');
|
||||||
|
const upd = calls.find((c) => c.sql.startsWith('UPDATE schools SET name'));
|
||||||
|
assert.deepEqual(upd.params, ['De Nieuwe Naam', '5']);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('schoolbeheerder mag geen scholen hernoemen', async () => {
|
||||||
|
const { app } = await makeApp(admin);
|
||||||
|
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
|
||||||
|
payload: { name: 'Hack' } });
|
||||||
|
assert.equal(res.statusCode, 403);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hernoemen weigert een lege of te lange naam', async () => {
|
||||||
|
const { app } = await makeApp(superUser);
|
||||||
|
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
|
||||||
|
payload: { name: ' ' } });
|
||||||
|
assert.equal(res.statusCode, 400);
|
||||||
|
const res2 = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
|
||||||
|
payload: { name: 'x'.repeat(90) } });
|
||||||
|
assert.equal(res2.statusCode, 400);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue