diff --git a/VERSION b/VERSION index b3b574d..171c2a7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.92-beta +0.3.93-beta diff --git a/public/css/teach.css b/public/css/teach.css index 67caa8e..29589a8 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1914,9 +1914,13 @@ body.dark .world-fact{background:#1d3b2a;color:#a9e5b7}body.dark .world-fact .wo } #settingsModal .am-btn, #settingsModal .am-copy, #settingsModal .tbtn{ border:1px solid var(--line); border-radius:var(--radius-s); background:var(--surface); + color:var(--ink); /* .tbtn is standaard wit-op-blauw; hier wit-op-wit dus overschrijven */ font-size:12.5px; font-weight:550; box-shadow:none; padding:0 12px; } #settingsModal .am-btn:hover, #settingsModal .tbtn:hover{background:var(--surface-2);} +/* de primaire knop (accent) houdt bewust witte tekst */ +#settingsModal .am-btn.am-primary, #settingsModal .tbtn.am-primary{color:#fff;} +#settingsModal .tbtn:disabled{opacity:.5;} #settingsModal .am-btn.am-danger{background:transparent; border:1px solid var(--line); color:var(--red); font-weight:600;} #settingsModal .am-btn.am-danger.armed{background:var(--red); border-color:var(--red); color:#fff;} #settingsModal .am-edit{border:1px solid transparent; border-radius:var(--radius-s); font-size:12.5px; opacity:.75;} diff --git a/public/js/admin.js b/public/js/admin.js index 06f3722..541e432 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -220,7 +220,10 @@ if(force) loadPromise = null; if(!loadPromise){ loadPromise = (async ()=>{ - selSchool = currentUser.role==="super" ? (selSchool ?? "") : currentUser.schoolId; + /* super: selSchool null = nog niet gekozen (reload() zet dan de eerste + school als default zodat klassen/gebruikers meteen een concrete + school tonen); "" = bewust "alle scholen". Andere rollen: eigen school. */ + if(currentUser.role!=="super") selSchool = currentUser.schoolId; await reload(); })(); } @@ -252,11 +255,17 @@ if(!currentUser) resetResults = null; loadPromise = null; selectedUserKey = null; + selSchool = null; /* nieuwe sessie: laat reload() opnieuw defaulten */ }); async function reload(){ try{ - if(currentUser.role==="super") SCHOOLS = await api("/schools"); + if(currentUser.role==="super"){ + SCHOOLS = await api("/schools"); + /* eerste bezoek: kies meteen een concrete school i.p.v. het verwarrende + "alle scholen" (klassen en per-school-lijsten zijn dan direct gevuld) */ + if(selSchool == null) selSchool = SCHOOLS.length ? String(SCHOOLS[0].id) : ""; + } 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 @@ -266,7 +275,7 @@ 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=none") : ""; + const cq = currentUser.role==="super" && selSchool ? "?school="+selSchool : ""; CLASSES = (await api("/admin/classes"+cq)).classes || []; ASSIGNMENTS = {}; if(CLASSES.length){ @@ -867,46 +876,43 @@ if(creatableRoles().includes("super")) panel.appendChild(addForm("super")); } - /* klassen (admin/super binnen een school); de systeemmanager ziet zonder - gekozen school de losse klassen (school verwijderd) om ze terug te koppelen */ + /* klassen (admin/super binnen de gekozen school). Een klas hoort altijd bij + een school; de systeemmanager kan een klas naar een andere school + verplaatsen. Zonder gekozen school ("alle scholen") is er geen concrete + school om klassen bij te tonen of aan te maken. */ function renderKlassenPanel(panel){ - const looseView = currentUser.role==="super" && !selSchool; - const canResetPw = can("schools.resetPasswords"); - if(looseView){ - if(!CLASSES.length){ - panel.appendChild(h("div","guestnote", T("amPickSchoolHint"))); - return; - } - panel.appendChild(h("div","guestnote", T("amLooseClassesHint"))); + if(currentUser.role==="super" && !selSchool){ + panel.appendChild(h("div","guestnote", T("amPickSchoolFirst"))); + return; } + const canResetPw = can("schools.resetPasswords"); + const otherSchools = SCHOOLS.filter(sc=>String(sc.id)!==String(selSchool)); CLASSES.forEach(c=>{ const pupilCount = USERS.filter(u=>u.role==="pupil" && Number(u.classId)===Number(c.id)).length; let edit = null; if(canResetPw){ const tools = h("div","am-tools"); - /* systeemmanager: klas aan een school (terug)koppelen */ - if(currentUser.role==="super"){ + /* systeemmanager: klas naar een andere school verplaatsen */ + if(currentUser.role==="super" && otherSchools.length){ 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 = looseView ? "" : (selSchool || ""); + ss.appendChild(new Option(T("amMoveToSchool"), "")); + otherSchools.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id))); ss.addEventListener("change", async ()=>{ - try{ await api("/admin/classes/"+c.id, {method:"PATCH", body:{ schoolId: ss.value ? +ss.value : null }}); reload(); } + if(!ss.value) return; + try{ await api("/admin/classes/"+c.id, {method:"PATCH", body:{ schoolId: +ss.value }}); reload(); } catch(e){ msg(e.message); } }); - tools.appendChild(field("amFieldSchool", ss)); + tools.appendChild(field("amFieldMoveSchool", ss)); } const acts = h("div","am-actions"); - if(!looseView){ - acts.appendChild(dangerBtn(T("amResetClass"), "amConfirmReset", async ()=>{ - try{ - const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"}); - await reload(); - resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users }; - render(); - }catch(e){ msg(e.message); } - })); - } + acts.appendChild(dangerBtn(T("amResetClass"), "amConfirmReset", async ()=>{ + try{ + const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"}); + await reload(); + resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users }; + render(); + }catch(e){ msg(e.message); } + })); acts.appendChild(dangerBtn(T("amDelete"), "amDeleteClassWarn", async ()=>{ try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); } catch(e){ msg(e.message); } @@ -920,7 +926,11 @@ edit, })); }); - if(looseView) return; + /* lege school: maak duidelijk dat er (nog) geen klassen zijn en dat + bestaande klassen bij een andere school kunnen horen */ + if(!CLASSES.length){ + panel.appendChild(h("div","guestnote", T("amNoClassesHere"))); + } if(canResetPw){ const f = h("div","am-add"); const inp = h("input","am-inp"); diff --git a/public/js/core.js b/public/js/core.js index e89f1d8..3276f55 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -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.92-beta"; +const VERSION = "0.3.93-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -280,7 +280,8 @@ const I18N = { amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode", amColStatus:"Status", amNeverLoggedIn:"nog niet ingelogd", amFieldSchool:"School", amStatusActive:"actief", amNoSchool:"— geen school —", amNoSchoolGroup:"Zonder school", amShowMore:"meer tonen", - amLooseClassesHint:"Deze klassen horen bij geen enkele school. Koppel ze via Bewerken.", + amMoveToSchool:"— verplaats naar school… —", amFieldMoveSchool:"Verplaats naar", amPickSchoolFirst:"Kies hierboven eerst een school om de klassen te zien of aan te maken.", + amNoClassesHere:"Deze school heeft nog geen klassen. Bestaande klassen kunnen bij een andere school horen — kies die school hierboven, of maak hieronder een nieuwe klas.", amDetailEmpty:"Selecteer links een gebruiker om te bewerken, of maak een nieuwe aan.", amNewUser:"Nieuwe gebruiker", amRequestOne:"Aanmelding", amReqNew:"nieuw", amParents:"Ouders", @@ -576,7 +577,8 @@ const I18N = { amResultPw:"New password", amResultCode:"New link code", amColStatus:"Status", amNeverLoggedIn:"not signed in yet", amFieldSchool:"School", amStatusActive:"active", amNoSchool:"— no school —", amNoSchoolGroup:"No school", amShowMore:"show more", - amLooseClassesHint:"These classes belong to no school. Attach them via Edit.", + amMoveToSchool:"— move to school… —", amFieldMoveSchool:"Move to", amPickSchoolFirst:"First pick a school above to see or create its classes.", + amNoClassesHere:"This school has no classes yet. Existing classes may belong to another school — pick that school above, or create a new class below.", amDetailEmpty:"Select a user on the left to edit, or create a new one.", amNewUser:"New user", amRequestOne:"Enrolment", amReqNew:"new", amParents:"Parents", diff --git a/src/api.js b/src/api.js index 4377b40..989438c 100644 --- a/src/api.js +++ b/src/api.js @@ -338,30 +338,26 @@ export default async function api(app) { need(req, reply, PERMISSIONS['classes.view']); const schoolId = req.user.allRoles.includes('super') ? req.query.school : req.user.school_id; if (!schoolId) return { classes: [] }; - // 'none' (alleen super): losse klassen (school ooit verwijderd) - anders - // zijn die nergens zichtbaar en dus ook niet terug te koppelen - const loose = schoolId === 'none' && req.user.allRoles.includes('super'); const r = await pool.query( `SELECT c.id, c.name, COALESCE(json_agg(ct.user_id) FILTER (WHERE ct.user_id IS NOT NULL), '[]') AS teacher_ids FROM classes c LEFT JOIN class_teachers ct ON ct.class_id = c.id - WHERE ${loose ? 'c.school_id IS NULL' : 'c.school_id = $1'} GROUP BY c.id ORDER BY c.name`, - loose ? [] : [schoolId]); + WHERE c.school_id = $1 GROUP BY c.id ORDER BY c.name`, [schoolId]); return { classes: r.rows.map((c) => ({ id: Number(c.id), name: c.name, teacherIds: c.teacher_ids.map(Number) })) }; }); - // klas aan een (andere) school koppelen of losmaken - alleen systeemmanager; - // herstelinstrument voor klassen die hun school kwijt zijn + // een klas naar een ANDERE school verplaatsen - alleen systeemmanager. + // Een klas hoort altijd bij een school (classes.school_id is NOT NULL); + // losmaken kan dus niet - er moet een geldige doelschool zijn. app.patch('/admin/classes/:id', async (req, reply) => { need(req, reply, PERMISSIONS['schools.manage']); const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; if (!c) return fail(reply, 404, 'klas onbekend'); - const b = req.body ?? {}; - if (b.schoolId === undefined) return fail(reply, 400, 'school verplicht'); - 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'); - } + const raw = req.body?.schoolId; + if (raw == null) return fail(reply, 400, 'school verplicht'); // een klas hoort altijd bij een school + const target = Number(raw); + if (!Number.isInteger(target) || target <= 0) return fail(reply, 400, 'ongeldige school'); + const school = (await pool.query('SELECT 1 FROM schools WHERE id = $1', [target])).rows[0]; + if (!school) return fail(reply, 404, 'school onbekend'); await pool.query('UPDATE classes SET school_id = $1 WHERE id = $2', [target, c.id]); return { ok: true }; }); diff --git a/test/schools.test.js b/test/schools.test.js index 407b50a..d924770 100644 --- a/test/schools.test.js +++ b/test/schools.test.js @@ -172,34 +172,25 @@ test('school koppelen meldt een naamconflict netjes als 409', async () => { await app.close(); }); -test('systeemmanager koppelt een losse klas terug aan een school', async () => { +test('systeemmanager verplaatst een klas naar een andere school; losmaken kan niet', async () => { const { app, calls } = await makeAppWithSchool(superUser, null); - const origQuery = app.pg.query.bind(app.pg); - app.pg.query = async (sql, params = []) => { - if (sql.startsWith('SELECT * FROM classes WHERE id')) { calls.push({ sql, params }); return { rows: [{ id: 9, name: 'Groep 4', school_id: null }] }; } - return origQuery(sql, params); - }; - const res = await app.inject({ method: 'PATCH', url: '/api/admin/classes/9', cookies, - payload: { schoolId: 2 } }); + const res = await app.inject({ method: 'PATCH', url: '/api/admin/classes/100', cookies, + payload: { schoolId: 3 } }); assert.equal(res.statusCode, 200, res.body); const upd = calls.find((c) => c.sql.startsWith('UPDATE classes SET school_id')); - assert.deepEqual(upd.params, [2, 9]); + assert.deepEqual(upd.params, [3, 100]); + // een klas hoort altijd bij een school: geen/null wordt geweigerd + const { app: app2 } = await makeAppWithSchool(superUser, null); + const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/classes/100', cookies, + payload: { schoolId: null } }); + assert.equal(res2.statusCode, 400); // schoolbeheerder mag dit niet (schools.manage = super) - const { app: app2, calls: calls2 } = await makeAppWithSchool(admin, null); - const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/classes/9', cookies, - payload: { schoolId: 2 } }); - assert.equal(res2.statusCode, 403); - assert.ok(!calls2.some((c) => c.sql.startsWith('UPDATE classes SET school_id'))); - await app.close(); await app2.close(); -}); - -test('losse klassen zijn opvraagbaar met school=none (alleen super)', async () => { - const { app, calls } = await makeApp(superUser); - const res = await app.inject({ url: '/api/admin/classes?school=none', cookies }); - assert.equal(res.statusCode, 200, res.body); - const q = calls.find((c) => c.sql.includes('FROM classes c')); - assert.ok(q.sql.includes('school_id IS NULL'), 'filtert op klassen zonder school'); - await app.close(); + const { app: app3, calls: calls3 } = await makeAppWithSchool(admin, null); + const res3 = await app3.inject({ method: 'PATCH', url: '/api/admin/classes/100', cookies, + payload: { schoolId: 3 } }); + assert.equal(res3.statusCode, 403); + assert.ok(!calls3.some((c) => c.sql.startsWith('UPDATE classes SET school_id'))); + await app.close(); await app2.close(); await app3.close(); }); test('hernoemen weigert een lege of te lange naam', async () => {