Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1
5 changed files with 128 additions and 5 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.89-beta
|
||||
0.3.90-beta
|
||||
|
|
|
|||
|
|
@ -348,6 +348,20 @@
|
|||
const tools = h("div","am-tools");
|
||||
const actions = h("div","am-actions");
|
||||
const isStaff = ["teacher","admin","super"].includes(u.role);
|
||||
/* school koppelen/losmaken (systeemmanager): losse accounts terugzetten
|
||||
naar hun school, of bewust losmaken - super/ouder zijn per definitie
|
||||
schoolloos en vallen hierbuiten */
|
||||
if(currentUser.role==="super" && ["teacher","admin","pupil"].includes(u.role)){
|
||||
const ss = h("select","am-sel");
|
||||
ss.appendChild(new Option(T("amNoSchool"), ""));
|
||||
SCHOOLS.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id)));
|
||||
ss.value = u.schoolId ?? "";
|
||||
ss.addEventListener("change", async ()=>{
|
||||
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{ schoolId: ss.value ? +ss.value : null }}); reload(); }
|
||||
catch(e){ msg(e.message); ss.value = u.schoolId ?? ""; }
|
||||
});
|
||||
tools.appendChild(field("amFieldSchool", ss));
|
||||
}
|
||||
if(isStaff && can("users.role.change") && u.id!==currentUser.id){
|
||||
/* één functie per gebruiker: alleen de hoofdrol is te wijzigen (super);
|
||||
het aparte extra-functies-systeem is uit de UI gehaald */
|
||||
|
|
@ -1236,15 +1250,19 @@
|
|||
master.appendChild(thead);
|
||||
|
||||
const list = h("div","am-list am-userlist");
|
||||
const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")], ["parent", T("amParents")]];
|
||||
/* losse accounts (ooit aan een school gekoppeld, nu zonder): alleen
|
||||
relevant voor de systeemmanager in de alle-scholen-weergave */
|
||||
const showLoose = currentUser.role==="super" && !selSchool;
|
||||
const isLoose = u=>showLoose && ["teacher","admin","pupil"].includes(u.role) && u.schoolId==null;
|
||||
const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")]];
|
||||
staffGroups.forEach(([role, label])=>{
|
||||
const us = USERS.filter(u=>u.role===role);
|
||||
const us = USERS.filter(u=>u.role===role && !isLoose(u));
|
||||
if(!us.length) return;
|
||||
list.appendChild(h("div","am-group", `${label} (${us.length})`));
|
||||
us.forEach(u=>list.appendChild(userCells(u)));
|
||||
});
|
||||
|
||||
const pupils = USERS.filter(u=>u.role==="pupil");
|
||||
const pupils = USERS.filter(u=>u.role==="pupil" && !isLoose(u));
|
||||
const schoolChosen = schoolChosenTop;
|
||||
|
||||
/* leerlingen staan onder inklapbare groepskoppen (standaard dicht): zo
|
||||
|
|
@ -1279,6 +1297,10 @@
|
|||
const noClass = pupils.filter(u=>!u.classId);
|
||||
if(noClass.length) pupilGroup("class:none", T("amNoClass"), noClass);
|
||||
}
|
||||
const parents = USERS.filter(u=>u.role==="parent");
|
||||
if(parents.length) pupilGroup("parents", T("amParents"), parents);
|
||||
const loose = USERS.filter(isLoose);
|
||||
if(loose.length) pupilGroup("noschool", T("amNoSchoolGroup"), loose);
|
||||
master.appendChild(list);
|
||||
split.appendChild(master);
|
||||
split.appendChild(detail);
|
||||
|
|
|
|||
|
|
@ -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.89-beta";
|
||||
const VERSION = "0.3.90-beta";
|
||||
(function(){
|
||||
const tag = document.getElementById("verTag");
|
||||
tag.textContent = "v"+VERSION;
|
||||
|
|
@ -279,6 +279,7 @@ const I18N = {
|
|||
amDeleteWarn:"Verwijdert dit account definitief, inclusief borden en woorden.",
|
||||
amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode",
|
||||
amColStatus:"Status", amNeverLoggedIn:"nog niet ingelogd", amFieldSchool:"School", amStatusActive:"actief",
|
||||
amNoSchool:"— geen school —", amNoSchoolGroup:"Zonder school",
|
||||
amDetailEmpty:"Selecteer links een gebruiker om te bewerken, of maak een nieuwe aan.",
|
||||
amNewUser:"Nieuwe gebruiker", amRequestOne:"Aanmelding", amReqNew:"nieuw",
|
||||
amParents:"Ouders",
|
||||
|
|
@ -573,6 +574,7 @@ const I18N = {
|
|||
amDeleteWarn:"Deletes this account permanently, including boards and words.",
|
||||
amResultPw:"New password", amResultCode:"New link code",
|
||||
amColStatus:"Status", amNeverLoggedIn:"not signed in yet", amFieldSchool:"School", amStatusActive:"active",
|
||||
amNoSchool:"— no school —", amNoSchoolGroup:"No school",
|
||||
amDetailEmpty:"Select a user on the left to edit, or create a new one.",
|
||||
amNewUser:"New user", amRequestOne:"Enrolment", amReqNew:"new",
|
||||
amParents:"Parents",
|
||||
|
|
|
|||
24
src/api.js
24
src/api.js
|
|
@ -583,6 +583,30 @@ export default async function api(app) {
|
|||
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
|
||||
return { ok: true, linkCode: code };
|
||||
}
|
||||
// school koppelen/losmaken (alleen systeemmanager): losse accounts kunnen
|
||||
// teruggezet worden naar een school en bewust losgemaakt. Super en parent
|
||||
// zijn per definitie schoolloos - daarvoor is dit een stille no-op.
|
||||
if (b.schoolId !== undefined && req.user.allRoles.includes('super')
|
||||
&& ['teacher', 'admin', 'pupil'].includes(u.role)) {
|
||||
const target = b.schoolId === null ? null : Number(b.schoolId);
|
||||
if (target !== null) {
|
||||
const school = (await pool.query('SELECT 1 FROM schools WHERE id = $1', [target])).rows[0];
|
||||
if (!school) return fail(reply, 404, 'school onbekend');
|
||||
}
|
||||
if (target !== (u.school_id == null ? null : Number(u.school_id))) {
|
||||
try {
|
||||
await pool.query('UPDATE users SET school_id = $1, class_id = NULL WHERE id = $2', [target, u.id]);
|
||||
} catch (e) {
|
||||
// naam kan botsen in de doelschool óf (bij losmaken) in de
|
||||
// school-loze naamruimte (systeemmanagers/ouders/thuiskinderen)
|
||||
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al');
|
||||
throw e;
|
||||
}
|
||||
await pool.query('DELETE FROM class_teachers WHERE user_id = $1', [u.id]);
|
||||
// een oude individuele toewijzing verwijst naar een bord van de vorige school
|
||||
if (u.role === 'pupil') await pool.query('DELETE FROM assignments WHERE pupil_id = $1', [u.id]);
|
||||
}
|
||||
}
|
||||
// rolwijziging alleen tussen staf-rollen onderling: leerlingen blijven
|
||||
// leerling en ouder-accounts kunnen nooit naar een beheerrol promoveren
|
||||
if (b.role && can(req.user.allRoles, 'users.role.change')
|
||||
|
|
|
|||
|
|
@ -29,6 +29,20 @@ function makeApp(user, targetUser) {
|
|||
})();
|
||||
}
|
||||
|
||||
/* variant waarin de scholen-existentiecheck slaagt */
|
||||
function makeAppWithSchool(user, targetUser) {
|
||||
return (async () => {
|
||||
const { app, calls } = await makeApp(user, targetUser);
|
||||
const pool = app.pg;
|
||||
const orig = pool.query.bind(pool);
|
||||
pool.query = async (sql, params = []) => {
|
||||
if (sql.startsWith('SELECT 1 FROM schools')) { calls.push({ sql, params }); return { rows: [{ 1: 1 }] }; }
|
||||
return orig(sql, params);
|
||||
};
|
||||
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 };
|
||||
|
|
@ -97,6 +111,67 @@ test('ouder-achtige rol krijgt geen promotie en geen klaskoppeling', async () =>
|
|||
await app2.close();
|
||||
});
|
||||
|
||||
test('systeemmanager koppelt een gebruiker aan een school en maakt hem weer los', async () => {
|
||||
// koppelen: school bestaat -> update + klas-/toewijzing-opruiming
|
||||
const { app, calls } = await makeApp(superUser, { id: 7, role: 'teacher', school_id: null, username: 'juf' });
|
||||
const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies,
|
||||
payload: { schoolId: 2 } });
|
||||
assert.equal(res.statusCode, 404, res.body); // mock geeft geen schools-rij terug
|
||||
const { app: app2, calls: calls2 } = await makeAppWithSchool(superUser, { id: 7, role: 'teacher', school_id: null, username: 'juf' });
|
||||
const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies,
|
||||
payload: { schoolId: 2 } });
|
||||
assert.equal(res2.statusCode, 200, res2.body);
|
||||
const upd = calls2.find((c) => c.sql.startsWith('UPDATE users SET school_id'));
|
||||
assert.deepEqual(upd.params, [2, 7]);
|
||||
assert.ok(calls2.some((c) => c.sql.includes('DELETE FROM class_teachers')));
|
||||
await app.close(); await app2.close();
|
||||
// losmaken: schoolId null, geen schools-check nodig; leerling verliest toewijzing
|
||||
const { app: app3, calls: calls3 } = await makeApp(superUser, { id: 9, role: 'pupil', school_id: 2, class_id: 5, username: 'lena' });
|
||||
const res3 = await app3.inject({ method: 'PATCH', url: '/api/admin/users/9', cookies,
|
||||
payload: { schoolId: null } });
|
||||
assert.equal(res3.statusCode, 200, res3.body);
|
||||
const upd3 = calls3.find((c) => c.sql.startsWith('UPDATE users SET school_id'));
|
||||
assert.deepEqual(upd3.params, [null, 9]);
|
||||
assert.ok(calls3.some((c) => c.sql.includes('DELETE FROM assignments WHERE pupil_id')));
|
||||
await app3.close();
|
||||
});
|
||||
|
||||
test('school koppelen kan niet door een schoolbeheerder en niet op super/parent', async () => {
|
||||
const { app, calls } = await makeAppWithSchool(admin, { id: 7, role: 'teacher', school_id: 2, username: 'juf' });
|
||||
await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, payload: { schoolId: 3 } });
|
||||
assert.ok(!calls.some((c) => c.sql.startsWith('UPDATE users SET school_id')), 'admin mag geen school wijzigen');
|
||||
await app.close();
|
||||
const { app: app2, calls: calls2 } = await makeAppWithSchool(superUser, { id: 20, role: 'parent', school_id: null, username: 'ouder' });
|
||||
await app2.inject({ method: 'PATCH', url: '/api/admin/users/20', cookies, payload: { schoolId: 2 } });
|
||||
assert.ok(!calls2.some((c) => c.sql.startsWith('UPDATE users SET school_id')), 'parent blijft schoolloos');
|
||||
await app2.close();
|
||||
});
|
||||
|
||||
test('school koppelen meldt een naamconflict netjes als 409', async () => {
|
||||
const calls = [];
|
||||
const pool = {
|
||||
async query(sql, params = []) {
|
||||
calls.push({ sql, params });
|
||||
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [superUser] };
|
||||
if (sql.includes('FROM user_roles')) return { rows: [] };
|
||||
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 7, role: 'teacher', school_id: null, username: 'juf' }] };
|
||||
if (sql.startsWith('SELECT 1 FROM schools')) return { rows: [{ 1: 1 }] };
|
||||
if (sql.startsWith('UPDATE users SET school_id')) { const e = new Error('dup'); e.code = '23505'; throw e; }
|
||||
return { rows: [] };
|
||||
},
|
||||
};
|
||||
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();
|
||||
const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, payload: { schoolId: 2 } });
|
||||
assert.equal(res.statusCode, 409);
|
||||
assert.match(res.json().error, /bestaat al/);
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue