Voeg thematische kleurplaten en creatieve niveaus toe (v0.4.65-beta) #3
7 changed files with 184 additions and 73 deletions
|
|
@ -51,6 +51,7 @@
|
|||
.tbtn:hover{background:var(--blue-d);}
|
||||
.tbtn.ghost{background:#e8eef6; color:var(--ink);}
|
||||
.tbtn.ghost:hover{background:#dbe5f1;}
|
||||
.tbtn:disabled{opacity:.55; cursor:default;}
|
||||
#btnSave{min-width:158px; text-align:center;}
|
||||
#btnSave.ok{background:var(--green); color:#fff;}
|
||||
#btnUser.on{background:var(--green); color:#fff;}
|
||||
|
|
@ -411,22 +412,21 @@
|
|||
.gcard .gi{font-size:38px;}
|
||||
.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;}
|
||||
/* login modal */
|
||||
#loginModal{width:min(400px,92vw);}
|
||||
#loginModal input{
|
||||
/* login + instellingen modals */
|
||||
#loginModal,#settingsModal{width:min(400px,92vw);}
|
||||
#loginModal input,#settingsModal input{
|
||||
width:100%; border:2px solid #cdd9e8; border-radius:12px; padding:11px 12px;
|
||||
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 .tbtn{flex:1;}
|
||||
.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;}
|
||||
#loginMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;}
|
||||
#loginMsg.ok{color:var(--green);}
|
||||
.formMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;}
|
||||
.formMsg.ok{color:var(--green);}
|
||||
.guestnote{font-size:13px; color:var(--muted); margin-top:12px; line-height:1.45;}
|
||||
#loggedBox{display:none;}
|
||||
#loggedBox .hello{font-size:18px; font-weight:800; margin-bottom:14px;}
|
||||
#settingsModal .hello{font-size:18px; font-weight:800; margin-bottom:14px;}
|
||||
/* =========================================================
|
||||
Letter game widget
|
||||
==========================================================*/
|
||||
|
|
@ -966,10 +966,9 @@
|
|||
width:100%; border:2px solid #cdd9e8; border-radius:12px; padding:11px 12px;
|
||||
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;}
|
||||
#ownPwRow{margin-bottom:10px;}
|
||||
#ownPwRow input{
|
||||
#ownPwBlock .lrow{margin-bottom:10px;}
|
||||
#ownPwBlock input{
|
||||
flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:12px;
|
||||
padding:10px 12px; font:inherit; font-size:15px; outline:none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,16 +69,21 @@
|
|||
|
||||
<div class="modalwrap" id="loginWrap">
|
||||
<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">
|
||||
<h2 data-i18n="login"></h2>
|
||||
<select id="liSchool"></select>
|
||||
<input type="text" id="liName" maxlength="30" autocomplete="username">
|
||||
<input type="password" id="liPass" maxlength="64" autocomplete="current-password">
|
||||
<div id="loginMsg"></div>
|
||||
<div id="loginMsg" class="formMsg"></div>
|
||||
<div class="lrow">
|
||||
<button class="tbtn" id="btnLogin" data-i18n="loginBtn"></button>
|
||||
</div>
|
||||
<div class="lidiv"></div>
|
||||
</div>
|
||||
<div id="linkBox" style="display:none;">
|
||||
<div class="lisub" data-i18n="linkIntro"></div>
|
||||
<input type="text" id="liCode" maxlength="9" style="text-transform:uppercase;">
|
||||
<div class="lrow">
|
||||
|
|
@ -89,11 +94,17 @@
|
|||
<div class="lrow">
|
||||
<button class="tbtn ghost" id="btnLink" data-i18n="linkBtn"></button>
|
||||
</div>
|
||||
<div class="guestnote" data-i18n="guestNote"></div>
|
||||
</div>
|
||||
<div id="loggedBox">
|
||||
<div class="hello" id="helloLbl"></div>
|
||||
<div class="lrow" id="ownPwRow">
|
||||
<div class="guestnote" data-i18n="guestNote"></div>
|
||||
</div>
|
||||
</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">
|
||||
<button class="tbtn ghost pwtoggle" type="button" id="btnOwnPwToggle" data-i18n="pwShow"></button>
|
||||
</div>
|
||||
|
|
@ -101,11 +112,11 @@
|
|||
<input type="password" id="ownPw2" maxlength="64" autocomplete="new-password">
|
||||
<button class="tbtn ghost" id="btnOwnPw" data-i18n="pwChange"></button>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<button class="tbtn ghost" id="btnLogout" data-i18n="logout"></button>
|
||||
</div>
|
||||
<div class="guestnote" data-i18n="storedNote"></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="modalwrap" id="adminWrap">
|
||||
|
|
@ -113,6 +124,7 @@
|
|||
</div>
|
||||
|
||||
<script src="js/core.js"></script>
|
||||
<script src="js/permissions.js"></script>
|
||||
<script src="js/data.js"></script>
|
||||
<script src="js/widgets/letters.js"></script>
|
||||
<script src="js/widgets/write.js"></script>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
};
|
||||
|
||||
btn.addEventListener("click", async ()=>{
|
||||
if(!currentUser || currentUser.role==="pupil") return;
|
||||
if(!can("admin.access")) return;
|
||||
selSchool = currentUser.role==="super" ? (selSchool ?? "") : currentUser.schoolId;
|
||||
await reload();
|
||||
wrap.classList.add("open");
|
||||
|
|
@ -84,7 +84,19 @@
|
|||
tussen de andere rij-elementen te wrappen */
|
||||
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-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"){
|
||||
/* klas (uitwisselen met andere klassen) */
|
||||
const cs = classSel(u.classId);
|
||||
|
|
@ -130,7 +142,7 @@
|
|||
}else if(u.pending){
|
||||
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"));
|
||||
nc.addEventListener("click", async ()=>{
|
||||
try{
|
||||
|
|
@ -293,7 +305,7 @@
|
|||
|
||||
/* klassen (admin/super binnen een school) */
|
||||
const schoolChosen = currentUser.role!=="super" || !!selSchool;
|
||||
const canResetPw = ["super","admin"].includes(currentUser.role);
|
||||
const canResetPw = can("schools.resetPasswords");
|
||||
if(schoolChosen){
|
||||
modal.appendChild(h("h3","am-h3", T("amClasses")));
|
||||
const cwrapEl = h("div","am-classes");
|
||||
|
|
@ -369,14 +381,11 @@
|
|||
});
|
||||
modal.appendChild(list);
|
||||
|
||||
/* toevoegen */
|
||||
/* toevoegen - welke rollen deze gebruiker mag aanmaken (CREATABLE_ROLES) */
|
||||
const creatable = CREATABLE_ROLES[currentUser.role];
|
||||
if(schoolChosen){
|
||||
if(["super","admin","teacher"].includes(currentUser.role)) modal.appendChild(addForm("pupil"));
|
||||
if(["super","admin"].includes(currentUser.role)){
|
||||
modal.appendChild(addForm("teacher"));
|
||||
modal.appendChild(addForm("admin"));
|
||||
}
|
||||
["pupil","teacher","admin"].forEach(r=>{ if(creatable.includes(r)) modal.appendChild(addForm(r)); });
|
||||
}
|
||||
if(currentUser.role==="super") modal.appendChild(addForm("super"));
|
||||
if(creatable.includes("super")) modal.appendChild(addForm("super"));
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ const I18N = {
|
|||
tmDigits:"Cijfers", tmVisual:"Kijkklok", tmBoth:"Allebei", tmSound:"Geluid aan/uit",
|
||||
tmHint:"Sleep aan de rode schijf om de tijd in te stellen.",
|
||||
login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen",
|
||||
settingsTitle:"Instellingen", tabLogin:"Inloggen", tabLinkCode:"Koppelcode", busy:"Bezig…",
|
||||
pwChanged:"Wachtwoord gewijzigd ✓",
|
||||
namePh:"naam", passPh:"wachtwoord",
|
||||
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.",
|
||||
|
|
@ -231,6 +233,8 @@ const I18N = {
|
|||
tmDigits:"Digits", tmVisual:"Visual clock", tmBoth:"Both", tmSound:"Sound on/off",
|
||||
tmHint:"Drag the red disc to set the time.",
|
||||
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",
|
||||
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.",
|
||||
|
|
@ -360,21 +364,30 @@ function resetToGuest(){
|
|||
/* login UI */
|
||||
const loginWrap = document.getElementById("loginWrap");
|
||||
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";
|
||||
function updateUserUI(){
|
||||
document.getElementById("userLbl").textContent = currentUser ? currentUser.displayName : T("login");
|
||||
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");
|
||||
adminBtn.style.display = currentUser && currentUser.role !== "pupil" ? "" : "none";
|
||||
adminBtn.style.display = can("admin.access") ? "" : "none";
|
||||
adminBtn.title = T("beheer");
|
||||
if(currentUser){
|
||||
document.getElementById("helloLbl").textContent =
|
||||
`${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(){
|
||||
const sel = document.getElementById("liSchool");
|
||||
try{
|
||||
|
|
@ -384,11 +397,18 @@ async function loadSchools(){
|
|||
}catch(e){ sel.innerHTML = `<option value="">${T("schoolSel")}</option>`; }
|
||||
}
|
||||
document.getElementById("btnUser").addEventListener("click", ()=>{
|
||||
if(currentUser){
|
||||
settingsMsg.textContent = ""; settingsMsg.classList.remove("ok");
|
||||
settingsWrap.classList.add("open");
|
||||
return;
|
||||
}
|
||||
loginMsg.textContent = ""; loginMsg.classList.remove("ok");
|
||||
switchLoginTab("login");
|
||||
loadSchools();
|
||||
loginWrap.classList.add("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){
|
||||
TOKEN = res.token;
|
||||
|
|
@ -398,15 +418,25 @@ async function afterLogin(res){
|
|||
loginWrap.classList.remove("open");
|
||||
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 ()=>{
|
||||
const btn = document.getElementById("btnLogin");
|
||||
const schoolId = document.getElementById("liSchool").value || null;
|
||||
const username = document.getElementById("liName").value.trim();
|
||||
const password = document.getElementById("liPass").value;
|
||||
loginMsg.classList.remove("ok");
|
||||
try{
|
||||
await afterLogin(await api("/auth/login", { body:{ schoolId: schoolId ? +schoolId : null, username, password } }));
|
||||
document.getElementById("liPass").value = "";
|
||||
}catch(e){ loginMsg.textContent = e.message || T("errLogin"); }
|
||||
await withBusy(btn, async ()=>{
|
||||
try{
|
||||
await afterLogin(await api("/auth/login", { body:{ schoolId: schoolId ? +schoolId : null, username, password } }));
|
||||
document.getElementById("liPass").value = "";
|
||||
}catch(e){ loginMsg.textContent = e.message || T("errLogin"); }
|
||||
});
|
||||
});
|
||||
document.getElementById("liPass").addEventListener("keydown", e=>{
|
||||
if(e.key==="Enter") document.getElementById("btnLogin").click();
|
||||
|
|
@ -426,33 +456,42 @@ pwToggle("btnOwnPwToggle", "ownPw", "ownPw2");
|
|||
|
||||
/* account activeren met een koppelcode */
|
||||
document.getElementById("btnLink").addEventListener("click", async ()=>{
|
||||
const btn = document.getElementById("btnLink");
|
||||
const code = document.getElementById("liCode").value.trim().toUpperCase();
|
||||
const password = document.getElementById("liPassNew").value;
|
||||
const password2 = document.getElementById("liPassNew2").value;
|
||||
loginMsg.classList.remove("ok");
|
||||
if(password !== password2){ loginMsg.textContent = T("errPwMatch"); return; }
|
||||
try{
|
||||
await afterLogin(await api("/auth/link", { body:{ code, password } }));
|
||||
document.getElementById("liCode").value = "";
|
||||
document.getElementById("liPassNew").value = "";
|
||||
document.getElementById("liPassNew2").value = "";
|
||||
}catch(e){ loginMsg.textContent = e.message; }
|
||||
await withBusy(btn, async ()=>{
|
||||
try{
|
||||
await afterLogin(await api("/auth/link", { body:{ code, password } }));
|
||||
document.getElementById("liCode").value = "";
|
||||
document.getElementById("liPassNew").value = "";
|
||||
document.getElementById("liPassNew2").value = "";
|
||||
}catch(e){ loginMsg.textContent = e.message; }
|
||||
});
|
||||
});
|
||||
document.getElementById("btnLogout").addEventListener("click", async ()=>{
|
||||
try{ await api("/auth/logout", { method:"POST", body:{} }); }catch(e){}
|
||||
resetToGuest();
|
||||
loginWrap.classList.remove("open");
|
||||
settingsWrap.classList.remove("open");
|
||||
});
|
||||
/* eigen wachtwoord wijzigen (niet voor leerlingen) */
|
||||
document.getElementById("btnOwnPw").addEventListener("click", async ()=>{
|
||||
const btn = document.getElementById("btnOwnPw");
|
||||
const pw = document.getElementById("ownPw").value;
|
||||
const pw2 = document.getElementById("ownPw2").value;
|
||||
if(pw !== pw2){ alert(T("errPwMatch")); return; }
|
||||
try{
|
||||
await api("/auth/password", { method:"PATCH", body:{ password: pw } });
|
||||
document.getElementById("ownPw").value = "";
|
||||
document.getElementById("ownPw2").value = "";
|
||||
}catch(e){ alert(e.message); }
|
||||
settingsMsg.classList.remove("ok");
|
||||
if(pw !== pw2){ settingsMsg.textContent = T("errPwMatch"); return; }
|
||||
await withBusy(btn, async ()=>{
|
||||
try{
|
||||
await api("/auth/password", { method:"PATCH", body:{ password: pw } });
|
||||
document.getElementById("ownPw").value = "";
|
||||
document.getElementById("ownPw2").value = "";
|
||||
settingsMsg.textContent = T("pwChanged"); settingsMsg.classList.add("ok");
|
||||
}catch(e){ settingsMsg.textContent = e.message; }
|
||||
});
|
||||
});
|
||||
|
||||
/* save board */
|
||||
|
|
|
|||
26
public/js/permissions.js
Normal file
26
public/js/permissions.js
Normal 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);
|
||||
}
|
||||
40
src/api.js
40
src/api.js
|
|
@ -13,6 +13,7 @@ import {
|
|||
hashPassword, verifyPassword, createSession, userFromRequest,
|
||||
newLinkCode, pupilPassword, staffPassword, checkPassword, publicUser,
|
||||
} from './auth.js';
|
||||
import { PERMISSIONS, CREATABLE_ROLES, can } from './permissions.js';
|
||||
|
||||
export default async function api(app) {
|
||||
const pool = app.pg;
|
||||
|
|
@ -93,7 +94,7 @@ export default async function api(app) {
|
|||
|
||||
// Eigen wachtwoord wijzigen - niet voor leerlingen
|
||||
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 err = checkPassword(password, req.user.role);
|
||||
if (err) return fail(reply, 400, err);
|
||||
|
|
@ -116,14 +117,14 @@ export default async function api(app) {
|
|||
|
||||
// ---- scholen (super) ---------------------------------------------------------
|
||||
app.post('/admin/schools', async (req, reply) => {
|
||||
need(req, reply, ['super']);
|
||||
need(req, reply, PERMISSIONS['schools.manage']);
|
||||
const { name } = req.body ?? {};
|
||||
if (!name) return fail(reply, 400, 'naam verplicht');
|
||||
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.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]);
|
||||
return { ok: true };
|
||||
});
|
||||
|
|
@ -131,7 +132,7 @@ export default async function api(app) {
|
|||
// Multireset: alle leerlingwachtwoorden van een hele school in één keer
|
||||
// vernieuwen. Alleen schoolbeheerder (eigen school) en overkoepelend beheerder.
|
||||
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;
|
||||
if (req.user.role !== 'super' && Number(schoolId) !== Number(req.user.school_id))
|
||||
return fail(reply, 403, 'geen rechten');
|
||||
|
|
@ -149,7 +150,7 @@ export default async function api(app) {
|
|||
|
||||
// ---- klassen -----------------------------------------------------------------
|
||||
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;
|
||||
if (!schoolId) return { classes: [] };
|
||||
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) })) };
|
||||
});
|
||||
app.post('/admin/classes', async (req, reply) => {
|
||||
need(req, reply, ['super', 'admin']);
|
||||
need(req, reply, PERMISSIONS['classes.manage']);
|
||||
const { name, school } = req.body ?? {};
|
||||
const schoolId = req.user.role === 'super' ? school : req.user.school_id;
|
||||
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 } };
|
||||
});
|
||||
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]);
|
||||
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))
|
||||
|
|
@ -178,7 +179,7 @@ export default async function api(app) {
|
|||
});
|
||||
// groepsleiding aan klas koppelen/loskoppelen
|
||||
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 ?? {};
|
||||
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]);
|
||||
|
|
@ -189,7 +190,7 @@ export default async function api(app) {
|
|||
// Alleen schoolbeheerder (eigen school) en overkoepelend beheerder - niet
|
||||
// de groepsleiding, om per ongeluk een hele klas resetten te voorkomen.
|
||||
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 c = r.rows[0];
|
||||
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.
|
||||
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';
|
||||
return { password: role === 'pupil' ? pupilPassword() : staffPassword() };
|
||||
});
|
||||
|
|
@ -217,7 +218,7 @@ export default async function api(app) {
|
|||
// ---- gebruikers ---------------------------------------------------------------
|
||||
// Lijst: super ziet alles (optioneel per school), admin/teacher de eigen school.
|
||||
app.get('/admin/users', async (req, reply) => {
|
||||
need(req, reply, ['super', 'admin', 'teacher']);
|
||||
need(req, reply, PERMISSIONS['users.manage']);
|
||||
let rows;
|
||||
if (req.user.role === 'super') {
|
||||
rows = (req.query.school
|
||||
|
|
@ -226,7 +227,7 @@ export default async function api(app) {
|
|||
} else {
|
||||
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) => ({
|
||||
...publicUser(u),
|
||||
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.
|
||||
// teacher: alleen leerlingen in de eigen school.
|
||||
app.post('/admin/users', async (req, reply) => {
|
||||
need(req, reply, ['super', 'admin', 'teacher']);
|
||||
need(req, reply, PERMISSIONS['users.manage']);
|
||||
const b = req.body ?? {};
|
||||
const role = b.role;
|
||||
const allowed = { super: ['super', 'admin', 'teacher', 'pupil'], admin: ['admin', 'teacher', 'pupil'], teacher: ['pupil'] };
|
||||
if (!allowed[req.user.role].includes(role)) return fail(reply, 403, 'geen rechten voor deze rol');
|
||||
if (!CREATABLE_ROLES[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;
|
||||
if (role !== 'super' && !schoolId) return fail(reply, 400, 'school verplicht');
|
||||
const username = (b.username || '').trim();
|
||||
|
|
@ -286,7 +286,7 @@ export default async function api(app) {
|
|||
|
||||
// Aanpassen: naam, klas (uitwisselen), leerlingwachtwoord, nieuwe koppelcode, rol (super).
|
||||
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 u = r.rows[0];
|
||||
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
|
||||
// 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 err = checkPassword(pw, u.role);
|
||||
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]);
|
||||
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();
|
||||
await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]);
|
||||
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',
|
||||
[b.role, b.role === 'super' ? null : u.school_id, u.id]);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
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 u = r.rows[0];
|
||||
if (!u) return fail(reply, 404, 'gebruiker onbekend');
|
||||
|
|
|
|||
26
src/permissions.js
Normal file
26
src/permissions.js
Normal 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);
|
||||
Loading…
Reference in a new issue