+
+
+
+
+
+
@@ -113,6 +124,7 @@
+
diff --git a/public/js/admin.js b/public/js/admin.js
index 5c56965..44f078a 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -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"));
}
})();
diff --git a/public/js/core.js b/public/js/core.js
index 59e059d..d024a64 100644
--- a/public/js/core.js
+++ b/public/js/core.js
@@ -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 = `
`; }
}
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 */
diff --git a/public/js/permissions.js b/public/js/permissions.js
new file mode 100644
index 0000000..149f19d
--- /dev/null
+++ b/public/js/permissions.js
@@ -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);
+}
diff --git a/src/api.js b/src/api.js
index bb35391..d278bb1 100644
--- a/src/api.js
+++ b/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');
diff --git a/src/permissions.js b/src/permissions.js
new file mode 100644
index 0000000..e035d71
--- /dev/null
+++ b/src/permissions.js
@@ -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);