v0.2.08-beta: instellingen-pagina, vernieuwd inlogscherm, centrale permissiematrix + rolwijziging
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 18s

- Inlogscherm herzien: aparte tabs voor "Inloggen" en "Koppelcode activeren" i.p.v. één doorlopende vorm
- Nieuwe Instellingen-pagina: account-info, wachtwoord wijzigen en uitloggen zijn verplaatst uit het inlogscherm naar een eigen scherm (👤-knop opent nu Instellingen i.p.v. het inlogscherm zodra je bent ingelogd)
- Laadstatus op de inlog-, koppelcode- en wachtwoordknoppen tijdens het versturen; consistente foutmeldingen i.p.v. een losse alert() bij het wijzigen van je eigen wachtwoord
- Centrale, benoemde permissiematrix (nieuw: src/permissions.js + public/js/permissions.js) vervangt de verspreide rol-arrays in src/api.js en de losse rol-checks in admin.js/core.js
- Nieuw: een overkoepelend beheerder (super) kan nu de rol van een stafaccount (groepsleiding/schoolbeheerder/overkoepelend) rechtstreeks in het beheerpaneel wijzigen; leerlingaccounts blijven altijd leerling

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ramon 2026-07-13 10:39:50 +02:00
parent b80da89ab6
commit b52c45329c
7 changed files with 184 additions and 73 deletions

View file

@ -51,6 +51,7 @@
.tbtn:hover{background:var(--blue-d);} .tbtn:hover{background:var(--blue-d);}
.tbtn.ghost{background:#e8eef6; color:var(--ink);} .tbtn.ghost{background:#e8eef6; color:var(--ink);}
.tbtn.ghost:hover{background:#dbe5f1;} .tbtn.ghost:hover{background:#dbe5f1;}
.tbtn:disabled{opacity:.55; cursor:default;}
#btnSave{min-width:158px; text-align:center;} #btnSave{min-width:158px; text-align:center;}
#btnSave.ok{background:var(--green); color:#fff;} #btnSave.ok{background:var(--green); color:#fff;}
#btnUser.on{background:var(--green); color:#fff;} #btnUser.on{background:var(--green); color:#fff;}
@ -411,22 +412,21 @@
.gcard .gi{font-size:38px;} .gcard .gi{font-size:38px;}
.gcard .gn{font-weight:800; margin-top:8px; font-size:15px;} .gcard .gn{font-weight:800; margin-top:8px; font-size:15px;}
.gcard .gd{font-size:12.5px; color:var(--muted); margin-top:4px; line-height:1.35;} .gcard .gd{font-size:12.5px; color:var(--muted); margin-top:4px; line-height:1.35;}
/* login modal */ /* login + instellingen modals */
#loginModal{width:min(400px,92vw);} #loginModal,#settingsModal{width:min(400px,92vw);}
#loginModal input{ #loginModal input,#settingsModal input{
width:100%; border:2px solid #cdd9e8; border-radius:12px; padding:11px 12px; width:100%; border:2px solid #cdd9e8; border-radius:12px; padding:11px 12px;
font:inherit; font-size:16px; outline:none; margin-bottom:10px; font:inherit; font-size:16px; outline:none; margin-bottom:10px;
} }
#loginModal input:focus{border-color:var(--blue);} #loginModal input:focus,#settingsModal input:focus{border-color:var(--blue);}
.lrow{display:flex; gap:8px;} .lrow{display:flex; gap:8px;}
.lrow .tbtn{flex:1;} .lrow .tbtn{flex:1;}
.lrow input{flex:1; min-width:0; margin-bottom:0;} .lrow input{flex:1; min-width:0; margin-bottom:0;}
.lrow .pwtoggle{flex:0 0 auto; white-space:nowrap; padding-left:10px; padding-right:10px;} .lrow .pwtoggle{flex:0 0 auto; white-space:nowrap; padding-left:10px; padding-right:10px;}
#loginMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;} .formMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;}
#loginMsg.ok{color:var(--green);} .formMsg.ok{color:var(--green);}
.guestnote{font-size:13px; color:var(--muted); margin-top:12px; line-height:1.45;} .guestnote{font-size:13px; color:var(--muted); margin-top:12px; line-height:1.45;}
#loggedBox{display:none;} #settingsModal .hello{font-size:18px; font-weight:800; margin-bottom:14px;}
#loggedBox .hello{font-size:18px; font-weight:800; margin-bottom:14px;}
/* ========================================================= /* =========================================================
Letter game widget Letter game widget
==========================================================*/ ==========================================================*/
@ -966,10 +966,9 @@
width:100%; border:2px solid #cdd9e8; border-radius:12px; padding:11px 12px; width:100%; border:2px solid #cdd9e8; border-radius:12px; padding:11px 12px;
font:inherit; font-size:15px; outline:none; margin-bottom:10px; background:#fff; font:inherit; font-size:15px; outline:none; margin-bottom:10px; background:#fff;
} }
.lidiv{height:1.5px; background:#e3eaf3; margin:14px 0;}
.lisub{font-size:13px; font-weight:700; color:var(--muted); margin-bottom:8px;} .lisub{font-size:13px; font-weight:700; color:var(--muted); margin-bottom:8px;}
#ownPwRow{margin-bottom:10px;} #ownPwBlock .lrow{margin-bottom:10px;}
#ownPwRow input{ #ownPwBlock input{
flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:12px; flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:12px;
padding:10px 12px; font:inherit; font-size:15px; outline:none; padding:10px 12px; font:inherit; font-size:15px; outline:none;
} }

View file

@ -69,16 +69,21 @@
<div class="modalwrap" id="loginWrap"> <div class="modalwrap" id="loginWrap">
<div class="modal" id="loginModal"> <div class="modal" id="loginModal">
<h2 data-i18n="login"></h2>
<div class="ed-tabs">
<button type="button" class="active" data-tab="login" data-i18n="tabLogin"></button>
<button type="button" data-tab="link" data-i18n="tabLinkCode"></button>
</div>
<div id="loginBox"> <div id="loginBox">
<h2 data-i18n="login"></h2>
<select id="liSchool"></select> <select id="liSchool"></select>
<input type="text" id="liName" maxlength="30" autocomplete="username"> <input type="text" id="liName" maxlength="30" autocomplete="username">
<input type="password" id="liPass" maxlength="64" autocomplete="current-password"> <input type="password" id="liPass" maxlength="64" autocomplete="current-password">
<div id="loginMsg"></div> <div id="loginMsg" class="formMsg"></div>
<div class="lrow"> <div class="lrow">
<button class="tbtn" id="btnLogin" data-i18n="loginBtn"></button> <button class="tbtn" id="btnLogin" data-i18n="loginBtn"></button>
</div> </div>
<div class="lidiv"></div> </div>
<div id="linkBox" style="display:none;">
<div class="lisub" data-i18n="linkIntro"></div> <div class="lisub" data-i18n="linkIntro"></div>
<input type="text" id="liCode" maxlength="9" style="text-transform:uppercase;"> <input type="text" id="liCode" maxlength="9" style="text-transform:uppercase;">
<div class="lrow"> <div class="lrow">
@ -89,11 +94,17 @@
<div class="lrow"> <div class="lrow">
<button class="tbtn ghost" id="btnLink" data-i18n="linkBtn"></button> <button class="tbtn ghost" id="btnLink" data-i18n="linkBtn"></button>
</div> </div>
<div class="guestnote" data-i18n="guestNote"></div>
</div> </div>
<div id="loggedBox"> <div class="guestnote" data-i18n="guestNote"></div>
<div class="hello" id="helloLbl"></div> </div>
<div class="lrow" id="ownPwRow"> </div>
<div class="modalwrap" id="settingsWrap">
<div class="modal" id="settingsModal">
<h2 data-i18n="settingsTitle"></h2>
<div class="hello" id="helloLbl"></div>
<div id="settingsMsg" class="formMsg"></div>
<div id="ownPwBlock">
<div class="lrow">
<input type="password" id="ownPw" maxlength="64" autocomplete="new-password"> <input type="password" id="ownPw" maxlength="64" autocomplete="new-password">
<button class="tbtn ghost pwtoggle" type="button" id="btnOwnPwToggle" data-i18n="pwShow"></button> <button class="tbtn ghost pwtoggle" type="button" id="btnOwnPwToggle" data-i18n="pwShow"></button>
</div> </div>
@ -101,11 +112,11 @@
<input type="password" id="ownPw2" maxlength="64" autocomplete="new-password"> <input type="password" id="ownPw2" maxlength="64" autocomplete="new-password">
<button class="tbtn ghost" id="btnOwnPw" data-i18n="pwChange"></button> <button class="tbtn ghost" id="btnOwnPw" data-i18n="pwChange"></button>
</div> </div>
<div class="lrow">
<button class="tbtn ghost" id="btnLogout" data-i18n="logout"></button>
</div>
<div class="guestnote" data-i18n="storedNote"></div>
</div> </div>
<div class="lrow">
<button class="tbtn ghost" id="btnLogout" data-i18n="logout"></button>
</div>
<div class="guestnote" data-i18n="storedNote"></div>
</div> </div>
</div> </div>
<div class="modalwrap" id="adminWrap"> <div class="modalwrap" id="adminWrap">
@ -113,6 +124,7 @@
</div> </div>
<script src="js/core.js"></script> <script src="js/core.js"></script>
<script src="js/permissions.js"></script>
<script src="js/data.js"></script> <script src="js/data.js"></script>
<script src="js/widgets/letters.js"></script> <script src="js/widgets/letters.js"></script>
<script src="js/widgets/write.js"></script> <script src="js/widgets/write.js"></script>

View file

@ -50,7 +50,7 @@
}; };
btn.addEventListener("click", async ()=>{ btn.addEventListener("click", async ()=>{
if(!currentUser || currentUser.role==="pupil") return; if(!can("admin.access")) return;
selSchool = currentUser.role==="super" ? (selSchool ?? "") : currentUser.schoolId; selSchool = currentUser.role==="super" ? (selSchool ?? "") : currentUser.schoolId;
await reload(); await reload();
wrap.classList.add("open"); wrap.classList.add("open");
@ -84,7 +84,19 @@
tussen de andere rij-elementen te wrappen */ tussen de andere rij-elementen te wrappen */
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})` : ""))); row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : "")));
row.appendChild(h("span","am-role", T(roleKey(u.role)))); if(u.role!=="pupil" && can("users.role.change") && u.id!==currentUser.id){
/* super mag de rol van andere stafaccounts wijzigen - leerlingen blijven altijd leerling */
const rs = h("select","am-sel");
["teacher","admin","super"].forEach(r=>rs.appendChild(new Option(T(roleKey(r)), r)));
rs.value = u.role;
rs.addEventListener("change", async ()=>{
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{role: rs.value}}); reload(); }
catch(e){ msg(e.message); }
});
row.appendChild(rs);
}else{
row.appendChild(h("span","am-role", T(roleKey(u.role))));
}
if(u.role==="pupil"){ if(u.role==="pupil"){
/* klas (uitwisselen met andere klassen) */ /* klas (uitwisselen met andere klassen) */
const cs = classSel(u.classId); const cs = classSel(u.classId);
@ -130,7 +142,7 @@
}else if(u.pending){ }else if(u.pending){
row.appendChild(h("span","am-code", T("amPending"))); row.appendChild(h("span","am-code", T("amPending")));
} }
if(["super","admin"].includes(currentUser.role) && u.role!=="pupil" && u.id!==currentUser.id){ if(can("users.staffCredentials") && u.role!=="pupil" && u.id!==currentUser.id){
const nc = h("button","am-btn", T("amNewCode")); const nc = h("button","am-btn", T("amNewCode"));
nc.addEventListener("click", async ()=>{ nc.addEventListener("click", async ()=>{
try{ try{
@ -293,7 +305,7 @@
/* klassen (admin/super binnen een school) */ /* klassen (admin/super binnen een school) */
const schoolChosen = currentUser.role!=="super" || !!selSchool; const schoolChosen = currentUser.role!=="super" || !!selSchool;
const canResetPw = ["super","admin"].includes(currentUser.role); const canResetPw = can("schools.resetPasswords");
if(schoolChosen){ if(schoolChosen){
modal.appendChild(h("h3","am-h3", T("amClasses"))); modal.appendChild(h("h3","am-h3", T("amClasses")));
const cwrapEl = h("div","am-classes"); const cwrapEl = h("div","am-classes");
@ -369,14 +381,11 @@
}); });
modal.appendChild(list); modal.appendChild(list);
/* toevoegen */ /* toevoegen - welke rollen deze gebruiker mag aanmaken (CREATABLE_ROLES) */
const creatable = CREATABLE_ROLES[currentUser.role];
if(schoolChosen){ if(schoolChosen){
if(["super","admin","teacher"].includes(currentUser.role)) modal.appendChild(addForm("pupil")); ["pupil","teacher","admin"].forEach(r=>{ if(creatable.includes(r)) modal.appendChild(addForm(r)); });
if(["super","admin"].includes(currentUser.role)){
modal.appendChild(addForm("teacher"));
modal.appendChild(addForm("admin"));
}
} }
if(currentUser.role==="super") modal.appendChild(addForm("super")); if(creatable.includes("super")) modal.appendChild(addForm("super"));
} }
})(); })();

View file

@ -108,6 +108,8 @@ const I18N = {
tmDigits:"Cijfers", tmVisual:"Kijkklok", tmBoth:"Allebei", tmSound:"Geluid aan/uit", tmDigits:"Cijfers", tmVisual:"Kijkklok", tmBoth:"Allebei", tmSound:"Geluid aan/uit",
tmHint:"Sleep aan de rode schijf om de tijd in te stellen.", tmHint:"Sleep aan de rode schijf om de tijd in te stellen.",
login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen", login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen",
settingsTitle:"Instellingen", tabLogin:"Inloggen", tabLinkCode:"Koppelcode", busy:"Bezig…",
pwChanged:"Wachtwoord gewijzigd ✓",
namePh:"naam", passPh:"wachtwoord", namePh:"naam", passPh:"wachtwoord",
guestNote:"Zonder inloggen wordt niets bewaard. Log in met je schoolaccount om alles op de server te bewaren.", guestNote:"Zonder inloggen wordt niets bewaard. Log in met je schoolaccount om alles op de server te bewaren.",
storedNote:"Je werk staat op de school-server en is op elk digibord beschikbaar.", storedNote:"Je werk staat op de school-server en is op elk digibord beschikbaar.",
@ -231,6 +233,8 @@ const I18N = {
tmDigits:"Digits", tmVisual:"Visual clock", tmBoth:"Both", tmSound:"Sound on/off", tmDigits:"Digits", tmVisual:"Visual clock", tmBoth:"Both", tmSound:"Sound on/off",
tmHint:"Drag the red disc to set the time.", tmHint:"Drag the red disc to set the time.",
login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out", login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out",
settingsTitle:"Settings", tabLogin:"Log in", tabLinkCode:"Link code", busy:"Please wait…",
pwChanged:"Password changed ✓",
namePh:"name", passPh:"password", namePh:"name", passPh:"password",
guestNote:"Without logging in nothing is saved. Log in with your school account to keep everything on the server.", guestNote:"Without logging in nothing is saved. Log in with your school account to keep everything on the server.",
storedNote:"Your work is stored on the school server and available on any board.", storedNote:"Your work is stored on the school server and available on any board.",
@ -360,21 +364,30 @@ function resetToGuest(){
/* login UI */ /* login UI */
const loginWrap = document.getElementById("loginWrap"); const loginWrap = document.getElementById("loginWrap");
const loginMsg = document.getElementById("loginMsg"); const loginMsg = document.getElementById("loginMsg");
const settingsWrap = document.getElementById("settingsWrap");
const settingsMsg = document.getElementById("settingsMsg");
const roleKey = r => r==="super" ? "roleSuper" : r==="admin" ? "roleAdmin" : r==="teacher" ? "roleTeacher" : "rolePupil"; const roleKey = r => r==="super" ? "roleSuper" : r==="admin" ? "roleAdmin" : r==="teacher" ? "roleTeacher" : "rolePupil";
function updateUserUI(){ function updateUserUI(){
document.getElementById("userLbl").textContent = currentUser ? currentUser.displayName : T("login"); document.getElementById("userLbl").textContent = currentUser ? currentUser.displayName : T("login");
document.getElementById("btnUser").classList.toggle("on", !!currentUser); document.getElementById("btnUser").classList.toggle("on", !!currentUser);
document.getElementById("loginBox").style.display = currentUser ? "none" : "block";
document.getElementById("loggedBox").style.display = currentUser ? "block" : "none";
const adminBtn = document.getElementById("btnAdmin"); const adminBtn = document.getElementById("btnAdmin");
adminBtn.style.display = currentUser && currentUser.role !== "pupil" ? "" : "none"; adminBtn.style.display = can("admin.access") ? "" : "none";
adminBtn.title = T("beheer"); adminBtn.title = T("beheer");
if(currentUser){ if(currentUser){
document.getElementById("helloLbl").textContent = document.getElementById("helloLbl").textContent =
`${T("hello")} ${currentUser.displayName}! 👋 · ${T(roleKey(currentUser.role))}`; `${T("hello")} ${currentUser.displayName}! 👋 · ${T(roleKey(currentUser.role))}`;
document.getElementById("ownPwRow").style.display = currentUser.role==="pupil" ? "none" : "flex"; document.getElementById("ownPwBlock").style.display = can("password.own.change") ? "block" : "none";
} }
} }
/* login-modal tabs: "inloggen" vs "koppelcode activeren" */
function switchLoginTab(tab){
document.querySelectorAll("#loginModal .ed-tabs button").forEach(b=>b.classList.toggle("active", b.dataset.tab===tab));
document.getElementById("loginBox").style.display = tab==="login" ? "block" : "none";
document.getElementById("linkBox").style.display = tab==="link" ? "block" : "none";
}
document.querySelectorAll("#loginModal .ed-tabs button").forEach(b=>{
b.addEventListener("click", ()=>switchLoginTab(b.dataset.tab));
});
async function loadSchools(){ async function loadSchools(){
const sel = document.getElementById("liSchool"); const sel = document.getElementById("liSchool");
try{ try{
@ -384,11 +397,18 @@ async function loadSchools(){
}catch(e){ sel.innerHTML = `<option value="">${T("schoolSel")}</option>`; } }catch(e){ sel.innerHTML = `<option value="">${T("schoolSel")}</option>`; }
} }
document.getElementById("btnUser").addEventListener("click", ()=>{ document.getElementById("btnUser").addEventListener("click", ()=>{
if(currentUser){
settingsMsg.textContent = ""; settingsMsg.classList.remove("ok");
settingsWrap.classList.add("open");
return;
}
loginMsg.textContent = ""; loginMsg.classList.remove("ok"); loginMsg.textContent = ""; loginMsg.classList.remove("ok");
switchLoginTab("login");
loadSchools(); loadSchools();
loginWrap.classList.add("open"); loginWrap.classList.add("open");
}); });
loginWrap.addEventListener("click", e=>{ if(e.target===loginWrap) loginWrap.classList.remove("open"); }); loginWrap.addEventListener("click", e=>{ if(e.target===loginWrap) loginWrap.classList.remove("open"); });
settingsWrap.addEventListener("click", e=>{ if(e.target===settingsWrap) settingsWrap.classList.remove("open"); });
async function afterLogin(res){ async function afterLogin(res){
TOKEN = res.token; TOKEN = res.token;
@ -398,15 +418,25 @@ async function afterLogin(res){
loginWrap.classList.remove("open"); loginWrap.classList.remove("open");
await hydrateFromServer(); await hydrateFromServer();
} }
/* laadstatus op een knop tijdens een async actie, altijd hersteld na afloop */
async function withBusy(btn, fn){
const orig = btn.textContent;
btn.disabled = true; btn.textContent = T("busy");
try{ await fn(); }
finally{ btn.disabled = false; btn.textContent = orig; }
}
document.getElementById("btnLogin").addEventListener("click", async ()=>{ document.getElementById("btnLogin").addEventListener("click", async ()=>{
const btn = document.getElementById("btnLogin");
const schoolId = document.getElementById("liSchool").value || null; const schoolId = document.getElementById("liSchool").value || null;
const username = document.getElementById("liName").value.trim(); const username = document.getElementById("liName").value.trim();
const password = document.getElementById("liPass").value; const password = document.getElementById("liPass").value;
loginMsg.classList.remove("ok"); loginMsg.classList.remove("ok");
try{ await withBusy(btn, async ()=>{
await afterLogin(await api("/auth/login", { body:{ schoolId: schoolId ? +schoolId : null, username, password } })); try{
document.getElementById("liPass").value = ""; await afterLogin(await api("/auth/login", { body:{ schoolId: schoolId ? +schoolId : null, username, password } }));
}catch(e){ loginMsg.textContent = e.message || T("errLogin"); } document.getElementById("liPass").value = "";
}catch(e){ loginMsg.textContent = e.message || T("errLogin"); }
});
}); });
document.getElementById("liPass").addEventListener("keydown", e=>{ document.getElementById("liPass").addEventListener("keydown", e=>{
if(e.key==="Enter") document.getElementById("btnLogin").click(); if(e.key==="Enter") document.getElementById("btnLogin").click();
@ -426,33 +456,42 @@ pwToggle("btnOwnPwToggle", "ownPw", "ownPw2");
/* account activeren met een koppelcode */ /* account activeren met een koppelcode */
document.getElementById("btnLink").addEventListener("click", async ()=>{ document.getElementById("btnLink").addEventListener("click", async ()=>{
const btn = document.getElementById("btnLink");
const code = document.getElementById("liCode").value.trim().toUpperCase(); const code = document.getElementById("liCode").value.trim().toUpperCase();
const password = document.getElementById("liPassNew").value; const password = document.getElementById("liPassNew").value;
const password2 = document.getElementById("liPassNew2").value; const password2 = document.getElementById("liPassNew2").value;
loginMsg.classList.remove("ok"); loginMsg.classList.remove("ok");
if(password !== password2){ loginMsg.textContent = T("errPwMatch"); return; } if(password !== password2){ loginMsg.textContent = T("errPwMatch"); return; }
try{ await withBusy(btn, async ()=>{
await afterLogin(await api("/auth/link", { body:{ code, password } })); try{
document.getElementById("liCode").value = ""; await afterLogin(await api("/auth/link", { body:{ code, password } }));
document.getElementById("liPassNew").value = ""; document.getElementById("liCode").value = "";
document.getElementById("liPassNew2").value = ""; document.getElementById("liPassNew").value = "";
}catch(e){ loginMsg.textContent = e.message; } document.getElementById("liPassNew2").value = "";
}catch(e){ loginMsg.textContent = e.message; }
});
}); });
document.getElementById("btnLogout").addEventListener("click", async ()=>{ document.getElementById("btnLogout").addEventListener("click", async ()=>{
try{ await api("/auth/logout", { method:"POST", body:{} }); }catch(e){} try{ await api("/auth/logout", { method:"POST", body:{} }); }catch(e){}
resetToGuest(); resetToGuest();
loginWrap.classList.remove("open"); loginWrap.classList.remove("open");
settingsWrap.classList.remove("open");
}); });
/* eigen wachtwoord wijzigen (niet voor leerlingen) */ /* eigen wachtwoord wijzigen (niet voor leerlingen) */
document.getElementById("btnOwnPw").addEventListener("click", async ()=>{ document.getElementById("btnOwnPw").addEventListener("click", async ()=>{
const btn = document.getElementById("btnOwnPw");
const pw = document.getElementById("ownPw").value; const pw = document.getElementById("ownPw").value;
const pw2 = document.getElementById("ownPw2").value; const pw2 = document.getElementById("ownPw2").value;
if(pw !== pw2){ alert(T("errPwMatch")); return; } settingsMsg.classList.remove("ok");
try{ if(pw !== pw2){ settingsMsg.textContent = T("errPwMatch"); return; }
await api("/auth/password", { method:"PATCH", body:{ password: pw } }); await withBusy(btn, async ()=>{
document.getElementById("ownPw").value = ""; try{
document.getElementById("ownPw2").value = ""; await api("/auth/password", { method:"PATCH", body:{ password: pw } });
}catch(e){ alert(e.message); } document.getElementById("ownPw").value = "";
document.getElementById("ownPw2").value = "";
settingsMsg.textContent = T("pwChanged"); settingsMsg.classList.add("ok");
}catch(e){ settingsMsg.textContent = e.message; }
});
}); });
/* save board */ /* save board */

26
public/js/permissions.js Normal file
View file

@ -0,0 +1,26 @@
/* teach - centrale permissiematrix voor de 4 vaste rollen (super/admin/teacher/pupil).
Gespiegeld in src/permissions.js (geen bundler in dit project, dus twee bestanden
die dezelfde waarden hand-in-hand bijhouden). */
const PERMISSIONS = {
'admin.access': ['super', 'admin', 'teacher'],
'password.own.change': ['super', 'admin', 'teacher'],
'schools.manage': ['super'],
'schools.resetPasswords': ['super', 'admin'],
'classes.view': ['super', 'admin', 'teacher'],
'classes.manage': ['super', 'admin'],
'users.manage': ['super', 'admin', 'teacher'],
'users.staffCredentials': ['super', 'admin'],
'users.genpw': ['super', 'admin', 'teacher'],
'users.role.change': ['super'],
};
const CREATABLE_ROLES = {
super: ['super', 'admin', 'teacher', 'pupil'],
admin: ['admin', 'teacher', 'pupil'],
teacher: ['pupil'],
};
/* can(action) gebruikt de ingelogde gebruiker (currentUser, uit core.js) */
function can(action){
return !!currentUser && (PERMISSIONS[action] || []).includes(currentUser.role);
}

View file

@ -13,6 +13,7 @@ import {
hashPassword, verifyPassword, createSession, userFromRequest, hashPassword, verifyPassword, createSession, userFromRequest,
newLinkCode, pupilPassword, staffPassword, checkPassword, publicUser, newLinkCode, pupilPassword, staffPassword, checkPassword, publicUser,
} from './auth.js'; } from './auth.js';
import { PERMISSIONS, CREATABLE_ROLES, can } from './permissions.js';
export default async function api(app) { export default async function api(app) {
const pool = app.pg; const pool = app.pg;
@ -93,7 +94,7 @@ export default async function api(app) {
// Eigen wachtwoord wijzigen - niet voor leerlingen // Eigen wachtwoord wijzigen - niet voor leerlingen
app.patch('/auth/password', async (req, reply) => { app.patch('/auth/password', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']); need(req, reply, PERMISSIONS['password.own.change']);
const { password } = req.body ?? {}; const { password } = req.body ?? {};
const err = checkPassword(password, req.user.role); const err = checkPassword(password, req.user.role);
if (err) return fail(reply, 400, err); if (err) return fail(reply, 400, err);
@ -116,14 +117,14 @@ export default async function api(app) {
// ---- scholen (super) --------------------------------------------------------- // ---- scholen (super) ---------------------------------------------------------
app.post('/admin/schools', async (req, reply) => { app.post('/admin/schools', async (req, reply) => {
need(req, reply, ['super']); need(req, reply, PERMISSIONS['schools.manage']);
const { name } = req.body ?? {}; const { name } = req.body ?? {};
if (!name) return fail(reply, 400, 'naam verplicht'); if (!name) return fail(reply, 400, 'naam verplicht');
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.delete('/admin/schools/:id', async (req, reply) => { app.delete('/admin/schools/:id', async (req, reply) => {
need(req, reply, ['super']); 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]);
return { ok: true }; return { ok: true };
}); });
@ -131,7 +132,7 @@ export default async function api(app) {
// Multireset: alle leerlingwachtwoorden van een hele school in één keer // Multireset: alle leerlingwachtwoorden van een hele school in één keer
// vernieuwen. Alleen schoolbeheerder (eigen school) en overkoepelend beheerder. // vernieuwen. Alleen schoolbeheerder (eigen school) en overkoepelend beheerder.
app.post('/admin/schools/:id/reset-passwords', async (req, reply) => { app.post('/admin/schools/:id/reset-passwords', async (req, reply) => {
need(req, reply, ['super', 'admin']); need(req, reply, PERMISSIONS['schools.resetPasswords']);
const schoolId = req.params.id; const schoolId = req.params.id;
if (req.user.role !== 'super' && Number(schoolId) !== Number(req.user.school_id)) if (req.user.role !== 'super' && Number(schoolId) !== Number(req.user.school_id))
return fail(reply, 403, 'geen rechten'); return fail(reply, 403, 'geen rechten');
@ -149,7 +150,7 @@ export default async function api(app) {
// ---- klassen ----------------------------------------------------------------- // ---- klassen -----------------------------------------------------------------
app.get('/admin/classes', async (req, reply) => { app.get('/admin/classes', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']); need(req, reply, PERMISSIONS['classes.view']);
const schoolId = req.user.role === 'super' ? req.query.school : req.user.school_id; const schoolId = req.user.role === 'super' ? req.query.school : req.user.school_id;
if (!schoolId) return { classes: [] }; if (!schoolId) return { classes: [] };
const r = await pool.query( const r = await pool.query(
@ -160,7 +161,7 @@ export default async function api(app) {
return { classes: r.rows.map((c) => ({ id: Number(c.id), name: c.name, teacherIds: c.teacher_ids.map(Number) })) }; return { classes: r.rows.map((c) => ({ id: Number(c.id), name: c.name, teacherIds: c.teacher_ids.map(Number) })) };
}); });
app.post('/admin/classes', async (req, reply) => { app.post('/admin/classes', async (req, reply) => {
need(req, reply, ['super', 'admin']); need(req, reply, PERMISSIONS['classes.manage']);
const { name, school } = req.body ?? {}; const { name, school } = req.body ?? {};
const schoolId = req.user.role === 'super' ? school : req.user.school_id; const schoolId = req.user.role === 'super' ? school : req.user.school_id;
if (!name || !schoolId) return fail(reply, 400, 'naam en school verplicht'); if (!name || !schoolId) return fail(reply, 400, 'naam en school verplicht');
@ -168,7 +169,7 @@ export default async function api(app) {
return { class: { id: Number(r.rows[0].id), name: r.rows[0].name } }; return { class: { id: Number(r.rows[0].id), name: r.rows[0].name } };
}); });
app.delete('/admin/classes/:id', async (req, reply) => { app.delete('/admin/classes/:id', async (req, reply) => {
need(req, reply, ['super', 'admin']); need(req, reply, PERMISSIONS['classes.manage']);
const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]); const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]);
if (!r.rows[0]) return fail(reply, 404, 'klas onbekend'); if (!r.rows[0]) return fail(reply, 404, 'klas onbekend');
if (req.user.role !== 'super' && Number(r.rows[0].school_id) !== Number(req.user.school_id)) if (req.user.role !== 'super' && Number(r.rows[0].school_id) !== Number(req.user.school_id))
@ -178,7 +179,7 @@ export default async function api(app) {
}); });
// groepsleiding aan klas koppelen/loskoppelen // groepsleiding aan klas koppelen/loskoppelen
app.post('/admin/classes/:id/teachers', async (req, reply) => { app.post('/admin/classes/:id/teachers', async (req, reply) => {
need(req, reply, ['super', 'admin']); need(req, reply, PERMISSIONS['classes.manage']);
const { userId, remove } = req.body ?? {}; const { userId, remove } = req.body ?? {};
if (remove) await pool.query('DELETE FROM class_teachers WHERE class_id = $1 AND user_id = $2', [req.params.id, userId]); if (remove) await pool.query('DELETE FROM class_teachers WHERE class_id = $1 AND user_id = $2', [req.params.id, userId]);
else await pool.query('INSERT INTO class_teachers (class_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [req.params.id, userId]); else await pool.query('INSERT INTO class_teachers (class_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [req.params.id, userId]);
@ -189,7 +190,7 @@ export default async function api(app) {
// Alleen schoolbeheerder (eigen school) en overkoepelend beheerder - niet // Alleen schoolbeheerder (eigen school) en overkoepelend beheerder - niet
// de groepsleiding, om per ongeluk een hele klas resetten te voorkomen. // de groepsleiding, om per ongeluk een hele klas resetten te voorkomen.
app.post('/admin/classes/:id/reset-passwords', async (req, reply) => { app.post('/admin/classes/:id/reset-passwords', async (req, reply) => {
need(req, reply, ['super', 'admin']); need(req, reply, PERMISSIONS['classes.manage']);
const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]); const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]);
const c = r.rows[0]; const c = r.rows[0];
if (!c) return fail(reply, 404, 'klas onbekend'); if (!c) return fail(reply, 404, 'klas onbekend');
@ -209,7 +210,7 @@ export default async function api(app) {
// Genereer een voorstel-wachtwoord (niet opgeslagen) voor in het aanmaakformulier. // Genereer een voorstel-wachtwoord (niet opgeslagen) voor in het aanmaakformulier.
app.get('/admin/genpw', async (req, reply) => { app.get('/admin/genpw', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']); need(req, reply, PERMISSIONS['users.genpw']);
const role = req.query.role === 'pupil' ? 'pupil' : 'staff'; const role = req.query.role === 'pupil' ? 'pupil' : 'staff';
return { password: role === 'pupil' ? pupilPassword() : staffPassword() }; return { password: role === 'pupil' ? pupilPassword() : staffPassword() };
}); });
@ -217,7 +218,7 @@ export default async function api(app) {
// ---- gebruikers --------------------------------------------------------------- // ---- gebruikers ---------------------------------------------------------------
// Lijst: super ziet alles (optioneel per school), admin/teacher de eigen school. // Lijst: super ziet alles (optioneel per school), admin/teacher de eigen school.
app.get('/admin/users', async (req, reply) => { app.get('/admin/users', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']); need(req, reply, PERMISSIONS['users.manage']);
let rows; let rows;
if (req.user.role === 'super') { if (req.user.role === 'super') {
rows = (req.query.school rows = (req.query.school
@ -226,7 +227,7 @@ export default async function api(app) {
} else { } else {
rows = (await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.user.school_id])).rows; rows = (await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.user.school_id])).rows;
} }
const canSeePw = ['super', 'admin', 'teacher'].includes(req.user.role); const canSeePw = can(req.user.role, 'users.manage');
return { users: rows.map((u) => ({ return { users: rows.map((u) => ({
...publicUser(u), ...publicUser(u),
linkCode: u.link_code || null, linkCode: u.link_code || null,
@ -240,11 +241,10 @@ export default async function api(app) {
// Aanmaken. super: alles (ook supers). admin: admin/teacher/pupil in eigen school. // Aanmaken. super: alles (ook supers). admin: admin/teacher/pupil in eigen school.
// teacher: alleen leerlingen in de eigen school. // teacher: alleen leerlingen in de eigen school.
app.post('/admin/users', async (req, reply) => { app.post('/admin/users', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']); need(req, reply, PERMISSIONS['users.manage']);
const b = req.body ?? {}; const b = req.body ?? {};
const role = b.role; const role = b.role;
const allowed = { super: ['super', 'admin', 'teacher', 'pupil'], admin: ['admin', 'teacher', 'pupil'], teacher: ['pupil'] }; if (!CREATABLE_ROLES[req.user.role].includes(role)) return fail(reply, 403, 'geen rechten voor deze rol');
if (!allowed[req.user.role].includes(role)) return fail(reply, 403, 'geen rechten voor deze rol');
const schoolId = req.user.role === 'super' ? (role === 'super' ? null : b.school) : req.user.school_id; const schoolId = req.user.role === 'super' ? (role === 'super' ? null : b.school) : req.user.school_id;
if (role !== 'super' && !schoolId) return fail(reply, 400, 'school verplicht'); if (role !== 'super' && !schoolId) return fail(reply, 400, 'school verplicht');
const username = (b.username || '').trim(); const username = (b.username || '').trim();
@ -286,7 +286,7 @@ export default async function api(app) {
// Aanpassen: naam, klas (uitwisselen), leerlingwachtwoord, nieuwe koppelcode, rol (super). // Aanpassen: naam, klas (uitwisselen), leerlingwachtwoord, nieuwe koppelcode, rol (super).
app.patch('/admin/users/:id', async (req, reply) => { app.patch('/admin/users/:id', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']); need(req, reply, PERMISSIONS['users.manage']);
const r = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]); const r = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
const u = r.rows[0]; const u = r.rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend'); if (!u) return fail(reply, 404, 'gebruiker onbekend');
@ -305,26 +305,26 @@ export default async function api(app) {
} }
// groepsleiding/beheer: direct een nieuw wachtwoord zetten (zelf gekozen // groepsleiding/beheer: direct een nieuw wachtwoord zetten (zelf gekozen
// of gegenereerd) zonder de koppelcode-activatie opnieuw te doorlopen. // of gegenereerd) zonder de koppelcode-activatie opnieuw te doorlopen.
if ((b.password || b.generatePassword) && u.role !== 'pupil' && ['super', 'admin'].includes(req.user.role)) { if ((b.password || b.generatePassword) && u.role !== 'pupil' && can(req.user.role, 'users.staffCredentials')) {
const pw = b.password || staffPassword(); const pw = b.password || staffPassword();
const err = checkPassword(pw, u.role); const err = checkPassword(pw, u.role);
if (err) return fail(reply, 400, err); if (err) return fail(reply, 400, err);
await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [await hashPassword(pw), u.id]); await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [await hashPassword(pw), u.id]);
return { ok: true, password: pw }; return { ok: true, password: pw };
} }
if (b.newLinkCode && u.role !== 'pupil' && ['super', 'admin'].includes(req.user.role)) { if (b.newLinkCode && u.role !== 'pupil' && can(req.user.role, 'users.staffCredentials')) {
const code = newLinkCode(); const code = newLinkCode();
await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]); await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]);
return { ok: true, linkCode: code }; return { ok: true, linkCode: code };
} }
if (b.role && req.user.role === 'super' && ['super', 'admin', 'teacher'].includes(b.role) && u.role !== 'pupil') if (b.role && can(req.user.role, 'users.role.change') && ['super', 'admin', 'teacher'].includes(b.role) && u.role !== 'pupil')
await pool.query('UPDATE users SET role = $1, school_id = $2 WHERE id = $3', await pool.query('UPDATE users SET role = $1, school_id = $2 WHERE id = $3',
[b.role, b.role === 'super' ? null : u.school_id, u.id]); [b.role, b.role === 'super' ? null : u.school_id, u.id]);
return { ok: true }; return { ok: true };
}); });
app.delete('/admin/users/:id', async (req, reply) => { app.delete('/admin/users/:id', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']); need(req, reply, PERMISSIONS['users.manage']);
const r = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]); const r = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
const u = r.rows[0]; const u = r.rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend'); if (!u) return fail(reply, 404, 'gebruiker onbekend');

26
src/permissions.js Normal file
View file

@ -0,0 +1,26 @@
// teach: centrale permissiematrix voor de 4 vaste rollen (super/admin/teacher/pupil).
// Gespiegeld in public/js/permissions.js (geen bundler in dit project, dus twee
// bestanden die dezelfde waarden hand-in-hand bijhouden). Dit is geen database-
// gestuurd/rechten-per-gebruiker-configureerbaar systeem, maar één benoemde bron
// i.p.v. dezelfde rol-arrays los verspreid over de routes.
export const PERMISSIONS = {
'admin.access': ['super', 'admin', 'teacher'],
'password.own.change': ['super', 'admin', 'teacher'],
'schools.manage': ['super'],
'schools.resetPasswords': ['super', 'admin'],
'classes.view': ['super', 'admin', 'teacher'],
'classes.manage': ['super', 'admin'],
'users.manage': ['super', 'admin', 'teacher'],
'users.staffCredentials': ['super', 'admin'],
'users.genpw': ['super', 'admin', 'teacher'],
'users.role.change': ['super'],
};
// Welke rol een account met welke rol mag aanmaken.
export const CREATABLE_ROLES = {
super: ['super', 'admin', 'teacher', 'pupil'],
admin: ['admin', 'teacher', 'pupil'],
teacher: ['pupil'],
};
export const can = (role, action) => (PERMISSIONS[action] || []).includes(role);