Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1

Open
bes-r wants to merge 192 commits from bes-r/avatar-realism into main AGit
5 changed files with 128 additions and 5 deletions
Showing only changes of commit b4eee9f78f - Show all commits

View file

@ -1 +1 @@
0.3.89-beta 0.3.90-beta

View file

@ -348,6 +348,20 @@
const tools = h("div","am-tools"); const tools = h("div","am-tools");
const actions = h("div","am-actions"); const actions = h("div","am-actions");
const isStaff = ["teacher","admin","super"].includes(u.role); 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){ if(isStaff && can("users.role.change") && u.id!==currentUser.id){
/* één functie per gebruiker: alleen de hoofdrol is te wijzigen (super); /* één functie per gebruiker: alleen de hoofdrol is te wijzigen (super);
het aparte extra-functies-systeem is uit de UI gehaald */ het aparte extra-functies-systeem is uit de UI gehaald */
@ -1236,15 +1250,19 @@
master.appendChild(thead); master.appendChild(thead);
const list = h("div","am-list am-userlist"); 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])=>{ 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; if(!us.length) return;
list.appendChild(h("div","am-group", `${label} (${us.length})`)); list.appendChild(h("div","am-group", `${label} (${us.length})`));
us.forEach(u=>list.appendChild(userCells(u))); 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; const schoolChosen = schoolChosenTop;
/* leerlingen staan onder inklapbare groepskoppen (standaard dicht): zo /* leerlingen staan onder inklapbare groepskoppen (standaard dicht): zo
@ -1279,6 +1297,10 @@
const noClass = pupils.filter(u=>!u.classId); const noClass = pupils.filter(u=>!u.classId);
if(noClass.length) pupilGroup("class:none", T("amNoClass"), noClass); 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); master.appendChild(list);
split.appendChild(master); split.appendChild(master);
split.appendChild(detail); split.appendChild(detail);

View file

@ -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.89-beta"; const VERSION = "0.3.90-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -279,6 +279,7 @@ const I18N = {
amDeleteWarn:"Verwijdert dit account definitief, inclusief borden en woorden.", amDeleteWarn:"Verwijdert dit account definitief, inclusief borden en woorden.",
amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode", amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode",
amColStatus:"Status", amNeverLoggedIn:"nog niet ingelogd", amFieldSchool:"School", amStatusActive:"actief", 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.", amDetailEmpty:"Selecteer links een gebruiker om te bewerken, of maak een nieuwe aan.",
amNewUser:"Nieuwe gebruiker", amRequestOne:"Aanmelding", amReqNew:"nieuw", amNewUser:"Nieuwe gebruiker", amRequestOne:"Aanmelding", amReqNew:"nieuw",
amParents:"Ouders", amParents:"Ouders",
@ -573,6 +574,7 @@ const I18N = {
amDeleteWarn:"Deletes this account permanently, including boards and words.", amDeleteWarn:"Deletes this account permanently, including boards and words.",
amResultPw:"New password", amResultCode:"New link code", amResultPw:"New password", amResultCode:"New link code",
amColStatus:"Status", amNeverLoggedIn:"not signed in yet", amFieldSchool:"School", amStatusActive:"active", 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.", amDetailEmpty:"Select a user on the left to edit, or create a new one.",
amNewUser:"New user", amRequestOne:"Enrolment", amReqNew:"new", amNewUser:"New user", amRequestOne:"Enrolment", amReqNew:"new",
amParents:"Parents", amParents:"Parents",

View file

@ -583,6 +583,30 @@ export default async function api(app) {
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]); await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
return { ok: true, linkCode: code }; 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 // rolwijziging alleen tussen staf-rollen onderling: leerlingen blijven
// leerling en ouder-accounts kunnen nooit naar een beheerrol promoveren // leerling en ouder-accounts kunnen nooit naar een beheerrol promoveren
if (b.role && can(req.user.allRoles, 'users.role.change') if (b.role && can(req.user.allRoles, 'users.role.change')

View file

@ -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 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 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 }; 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(); 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 () => { test('hernoemen weigert een lege of te lange naam', async () => {
const { app } = await makeApp(superUser); const { app } = await makeApp(superUser);
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies, const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,