Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1
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 */
|
||||
.am-group-toggle{cursor:pointer; user-select:none;}
|
||||
.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) */
|
||||
.am-row-tools{
|
||||
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() */
|
||||
const modal = document.getElementById("adminModal");
|
||||
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
|
||||
opgehaald bij elke reload() zoals CLASSES/USERS hierboven */
|
||||
let ASSIGNMENTS = {};
|
||||
|
|
@ -159,6 +161,13 @@
|
|||
if(currentUser.role==="super") SCHOOLS = await api("/schools");
|
||||
const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : "";
|
||||
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") : "";
|
||||
CLASSES = (await api("/admin/classes"+cq)).classes || [];
|
||||
ASSIGNMENTS = {};
|
||||
|
|
@ -178,13 +187,14 @@
|
|||
return sel;
|
||||
}
|
||||
|
||||
function userRow(u){
|
||||
const row = h("div","am-row");
|
||||
function userRow(u, opts){
|
||||
const row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : ""));
|
||||
/* rustig standaardbeeld: alleen naam + rol/klas; alle knoppen en velden
|
||||
zitten achter het ✏-knopje in een uitklapbaar gereedschapsblok */
|
||||
const tools = h("div","am-row-tools");
|
||||
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"){
|
||||
const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId));
|
||||
row.appendChild(h("span","am-role", T(roleKey(u.role)) + (cls ? ` · ${cls.name}` : "")));
|
||||
|
|
@ -427,8 +437,25 @@
|
|||
function renderScholenPanel(panel){
|
||||
const list = h("div","am-list");
|
||||
SCHOOLS.forEach(s=>{
|
||||
const row = h("div","am-row");
|
||||
row.appendChild(h("span","am-name", s.name));
|
||||
const admins = SYS_ADMINS.filter(a=>Number(a.schoolId)===Number(s.id));
|
||||
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");
|
||||
acts.appendChild(dangerBtn("🗑 " + T("amDelete"), "amDeleteSchoolWarn", async ()=>{
|
||||
try{
|
||||
|
|
@ -437,8 +464,27 @@
|
|||
reload();
|
||||
}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);
|
||||
/* 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.3.35-beta";
|
||||
const VERSION = "0.3.36-beta";
|
||||
(function(){
|
||||
const tag = document.getElementById("verTag");
|
||||
tag.textContent = "v"+VERSION;
|
||||
|
|
@ -183,6 +183,7 @@ const I18N = {
|
|||
amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem",
|
||||
amPickSchoolHint:"Kies hierboven eerst een school (🏫).", amPickSchool:"Kies een school",
|
||||
amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken",
|
||||
amAdmins:"Schoolbeheerders", amAdminOne:"Schoolbeheerder",
|
||||
amSure:"weet je het zeker?", amDelete:"verwijder",
|
||||
amFieldName:"Naam", amFieldRole:"Functie", amFieldExtraRoles:"Extra functies", amFieldClass:"Klas",
|
||||
amFieldPw:"Wachtwoord", amFieldActions:"Acties", amPwTypePh:"typ een wachtwoord…",
|
||||
|
|
@ -379,6 +380,7 @@ const I18N = {
|
|||
amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System",
|
||||
amPickSchoolHint:"First pick a school above (🏫).", amPickSchool:"Pick a school",
|
||||
amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit",
|
||||
amAdmins:"School admins", amAdminOne:"School admin",
|
||||
amSure:"are you sure?", amDelete:"delete",
|
||||
amFieldName:"Name", amFieldRole:"Role", amFieldExtraRoles:"Extra roles", amFieldClass:"Class",
|
||||
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()]);
|
||||
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) => {
|
||||
need(req, reply, PERMISSIONS['schools.manage']);
|
||||
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