v0.2.00: schoolsysteem met rollen (super/admin/groepsleiding/leerling), koppelcodes, server-API en beheerpaneel
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 13s

This commit is contained in:
bes-r 2026-07-09 22:26:01 +02:00
parent d1d3265c6c
commit 352e22a64b
12 changed files with 874 additions and 109 deletions

View file

@ -13,3 +13,8 @@ POSTGRES_PASSWORD=change-me-locally
# Connectiestring die de app gebruikt.
# Hostnaam 'db' verwijst naar de postgres-service binnen het compose-netwerk.
DATABASE_URL=postgres://teach:change-me-locally@db:5432/teach
# Eerste overkoepelende beheerder (wordt eenmalig aangemaakt als er nog geen bestaat).
# Laat SUPER_PASS leeg om een willekeurig wachtwoord in de serverlog te krijgen.
SUPER_USER=beheerder
SUPER_PASS=

View file

@ -16,6 +16,8 @@ services:
PORT: 3000
DATABASE_URL: ${DATABASE_URL}
APP_VERSION: dev
SUPER_USER: ${SUPER_USER:-beheerder}
SUPER_PASS: ${SUPER_PASS:-}
ports:
- "3000:3000"
restart: unless-stopped

52
db/002_schools_auth.sql Normal file
View file

@ -0,0 +1,52 @@
-- v0.2.00: scholen, klassen, rollen en sessies.
-- Rollen: super (overkoepelend beheerder), admin (schoolbeheerder),
-- teacher (groepsleiding), pupil (leerling).
CREATE TABLE IF NOT EXISTS schools (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS classes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
school_id BIGINT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE users
ADD COLUMN IF NOT EXISTS school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS display_name TEXT,
ADD COLUMN IF NOT EXISTS password_hash TEXT,
-- alleen voor leerlingen: wachtwoord dat groepsleiding/beheer mag inzien
ADD COLUMN IF NOT EXISTS password_plain TEXT,
-- eenmalige koppelcode waarmee nieuwe gebruikers hun account activeren
ADD COLUMN IF NOT EXISTS link_code TEXT UNIQUE,
ADD COLUMN IF NOT EXISTS class_id BIGINT REFERENCES classes(id) ON DELETE SET NULL,
-- alle app-gegevens van de gebruiker (borden, thema's, eigen woorden)
ADD COLUMN IF NOT EXISTS data JSONB NOT NULL DEFAULT '{}'::jsonb;
-- gebruikersnaam uniek binnen een school; supers (school_id IS NULL) landelijk uniek
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_school_username
ON users (school_id, lower(username)) WHERE school_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_super_username
ON users (lower(username)) WHERE school_id IS NULL;
-- welke groepsleiding hoort bij welke klas
CREATE TABLE IF NOT EXISTS class_teachers (
class_id BIGINT NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (class_id, user_id)
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_users_school ON users(school_id);
CREATE INDEX IF NOT EXISTS idx_users_class ON users(class_id);

View file

@ -20,6 +20,8 @@ services:
PORT: 3000
DATABASE_URL: ${DATABASE_URL}
APP_VERSION: ${IMAGE}
SUPER_USER: ${SUPER_USER:-beheerder}
SUPER_PASS: ${SUPER_PASS:-}
ports:
# Alleen op localhost van de VM; nginx zit ervoor als reverse proxy
- "127.0.0.1:${APP_PORT:-3000}:3000"

View file

@ -944,3 +944,59 @@
.thint{font-size:11.5px; color:var(--muted); text-align:center; line-height:1.4;}
.timer.flash{animation:tflash .5s 4;}
@keyframes tflash{50%{background:#fdeaa0;}}
/* ---------- login-extra's (school, koppelcode) ---------- */
#liSchool{
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{
flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:12px;
padding:10px 12px; font:inherit; font-size:15px; outline:none;
}
/* ---------- beheerpaneel ---------- */
#adminModal{width:min(760px,94vw); max-height:86vh; overflow:auto;}
.am-head{display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;}
.am-head h2{margin:0; font-size:20px;}
.am-h3{margin:14px 0 6px; font-size:14px; color:var(--muted); text-transform:uppercase; letter-spacing:.5px;}
.am-msg{min-height:20px; font-size:13.5px; font-weight:800; color:var(--green); margin:4px 0;}
.am-add{display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0;}
.am-inp,.am-sel{
border:2px solid #cdd9e8; border-radius:10px; padding:8px 10px; font:inherit;
font-size:14px; outline:none; background:#fff; min-width:0;
}
.am-inp{flex:1; min-width:140px;}
.am-list{display:flex; flex-direction:column; gap:5px;}
.am-group{font-weight:900; font-size:13px; margin-top:10px; color:var(--ink);}
.am-row{
display:flex; gap:8px; align-items:center; background:#f7fafd;
border-radius:10px; padding:6px 10px; flex-wrap:wrap;
}
.am-name{font-weight:800; font-size:14px; flex:1; min-width:120px;}
.am-role{font-size:12px; font-weight:700; color:var(--muted);}
.am-pw{
border:2px solid #cdd9e8; border-radius:8px; padding:5px 8px; font:inherit;
font-size:13px; width:110px; outline:none;
}
.am-code{font-size:12.5px; font-weight:800; color:var(--orange);}
.am-btn{
border:none; background:#e8eef6; border-radius:8px; padding:6px 10px;
font:inherit; font-weight:800; font-size:12px; cursor:pointer; color:var(--ink);
}
.am-btn:hover{background:#dbe5f1;}
.am-del{
border:none; background:#fbe3e1; border-radius:8px; width:28px; height:28px;
cursor:pointer; font-size:13px;
}
.am-del:hover{background:var(--red);}
.am-classes{display:flex; gap:6px; flex-wrap:wrap;}
.am-chip{
display:inline-flex; gap:6px; align-items:center; background:#fff;
border:2px solid #e3eaf3; border-radius:999px; padding:5px 12px;
font-weight:800; font-size:13px;
}
.am-chipx{border:none; background:transparent; color:var(--red); font-weight:900; cursor:pointer; padding:0; font-size:11px;}
body.hc .am-row,body.hc .am-chip{border:1.5px solid #000; background:#fff;}

View file

@ -24,6 +24,7 @@
<button class="tbtn ghost" id="btnFolders">📁</button>
<button class="tbtn ghost" id="btnSave">💾 <span data-i18n="saveBoard"></span></button>
<button class="tbtn ghost" id="btnUser">👤 <span id="userLbl"></span></button>
<button class="tbtn ghost" id="btnAdmin" style="display:none;">👥</button>
<div id="langToggle">
<button data-lang="nl">NL</button>
<button data-lang="en">EN</button>
@ -70,17 +71,28 @@
<div class="modal" id="loginModal">
<div id="loginBox">
<h2 data-i18n="login"></h2>
<input type="text" id="liName" maxlength="20" autocomplete="username">
<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 class="lrow">
<button class="tbtn" id="btnLogin" data-i18n="loginBtn"></button>
<button class="tbtn ghost" id="btnRegister" data-i18n="registerBtn"></button>
</div>
<div class="lidiv"></div>
<div class="lisub" data-i18n="linkIntro"></div>
<input type="text" id="liCode" maxlength="9" style="text-transform:uppercase;">
<input type="password" id="liPassNew" maxlength="64" autocomplete="new-password">
<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">
<input type="password" id="ownPw" 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>
@ -88,6 +100,9 @@
</div>
</div>
</div>
<div class="modalwrap" id="adminWrap">
<div class="modal" id="adminModal"></div>
</div>
<script src="js/core.js"></script>
<script src="js/data.js"></script>
@ -106,6 +121,7 @@
<script src="js/widgets/names.js"></script>
<script src="js/widgets/mind.js"></script>
<script src="js/board.js"></script>
<script src="js/admin.js"></script>
<script src="js/app.js"></script>
</body>
</html>

223
public/js/admin.js Normal file
View file

@ -0,0 +1,223 @@
/* teach - beheerpaneel (👥): gebruikers, klassen en scholen.
Zichtbaar voor super (alles), admin (eigen school) en teacher (leerlingen). */
"use strict";
(function(){
const wrap = document.getElementById("adminWrap");
const modal = document.getElementById("adminModal");
const btn = document.getElementById("btnAdmin");
let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null;
const esc = s => String(s ?? "").replace(/&/g,"&amp;").replace(/</g,"&lt;");
const h = (tag, cls, text)=>{
const el = document.createElement(tag);
if(cls) el.className = cls;
if(text != null) el.textContent = text;
return el;
};
const msg = t => { const m = modal.querySelector(".am-msg"); if(m) m.textContent = t; };
btn.addEventListener("click", async ()=>{
if(!currentUser || currentUser.role==="pupil") return;
selSchool = currentUser.role==="super" ? (selSchool ?? "") : currentUser.schoolId;
await reload();
wrap.classList.add("open");
});
wrap.addEventListener("click", e=>{ if(e.target===wrap) wrap.classList.remove("open"); });
document.addEventListener("userchange", ()=>{ if(!currentUser) wrap.classList.remove("open"); });
async function reload(){
try{
if(currentUser.role==="super") SCHOOLS = await api("/schools");
const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : "";
USERS = (await api("/admin/users"+q)).users;
const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=0") : "";
CLASSES = (await api("/admin/classes"+cq)).classes || [];
}catch(e){ USERS = []; CLASSES = []; }
render();
}
function classSel(current){
const sel = h("select","am-sel");
sel.appendChild(new Option(T("amNoClass"), ""));
CLASSES.forEach(c=>sel.appendChild(new Option(c.name, c.id)));
sel.value = current ?? "";
return sel;
}
function userRow(u){
const row = h("div","am-row");
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"){
/* klas (uitwisselen met andere klassen) */
const cs = classSel(u.classId);
cs.addEventListener("change", async ()=>{
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{classId: cs.value ? +cs.value : null}}); }
catch(e){ msg(e.message); }
});
row.appendChild(cs);
/* wachtwoord inzien en aanpassen */
const pw = h("input","am-pw");
pw.value = u.password || "";
pw.title = T("amPw");
pw.addEventListener("keydown", ev=>ev.stopPropagation());
pw.addEventListener("change", async ()=>{
if(pw.value.length < 3) return;
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); }
catch(e){ msg(e.message); }
});
row.appendChild(pw);
}else{
if(u.pending && u.linkCode){
row.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode));
}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){
const nc = h("button","am-btn", T("amNewCode"));
nc.addEventListener("click", async ()=>{
try{
const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}});
msg(`${T("amCreated")} ${r.linkCode}`);
reload();
}catch(e){ msg(e.message); }
});
row.appendChild(nc);
}
}
if(u.id !== currentUser.id){
const del = h("button","am-del","🗑");
del.title = T("amDelete") || "";
del.addEventListener("click", async ()=>{
if(!confirm(T("amConfirmDel"))) return;
try{ await api("/admin/users/"+u.id, {method:"DELETE"}); reload(); }
catch(e){ msg(e.message); }
});
row.appendChild(del);
}
return row;
}
function addForm(role){
const f = h("div","am-add");
const name = h("input","am-inp");
name.placeholder = T("amUserPh");
name.addEventListener("keydown", ev=>ev.stopPropagation());
f.appendChild(name);
let cs = null;
if(role==="pupil"){ cs = classSel(""); f.appendChild(cs); }
const go = h("button","tbtn", T(role==="pupil" ? "amNewPupil" : role==="teacher" ? "amNewTeacher" : role==="admin" ? "amNewAdmin" : "amNewSuper"));
go.addEventListener("click", async ()=>{
const body = { role, username: name.value.trim(), displayName: name.value.trim() };
if(role==="pupil" && cs && cs.value) body.classId = +cs.value;
if(currentUser.role==="super" && role!=="super") body.school = +selSchool;
try{
const r = await api("/admin/users", { body });
msg(role==="pupil"
? `${T("amPupilMade")} ${r.user.password}`
: `${T("amCreated")} ${r.user.linkCode}`);
name.value = "";
reload();
}catch(e){ msg(e.message); }
});
f.appendChild(go);
return f;
}
function render(){
modal.innerHTML = "";
const head = h("div","am-head");
head.appendChild(h("h2", null, T("beheer")));
const close = h("button","wclose","✕");
close.addEventListener("click", ()=>wrap.classList.remove("open"));
head.appendChild(close);
modal.appendChild(head);
/* super: schoolkiezer + nieuwe school */
if(currentUser.role==="super"){
const bar = h("div","am-add");
const sel = h("select","am-sel");
sel.appendChild(new Option(T("amAllSchools"), ""));
SCHOOLS.forEach(s=>sel.appendChild(new Option(s.name, s.id)));
sel.value = selSchool ?? "";
sel.addEventListener("change", ()=>{ selSchool = sel.value; reload(); });
bar.appendChild(sel);
const inp = h("input","am-inp");
inp.placeholder = T("amNamePh");
inp.addEventListener("keydown", ev=>ev.stopPropagation());
bar.appendChild(inp);
const add = h("button","tbtn", T("amNewSchool"));
add.addEventListener("click", async ()=>{
if(!inp.value.trim()) return;
try{
const r = await api("/admin/schools", { body:{ name: inp.value.trim() } });
selSchool = r.school.id; inp.value = "";
reload();
}catch(e){ msg(e.message); }
});
bar.appendChild(add);
modal.appendChild(bar);
}
modal.appendChild(h("div","am-msg"));
/* klassen (admin/super binnen een school) */
const schoolChosen = currentUser.role!=="super" || !!selSchool;
if(schoolChosen){
modal.appendChild(h("h3","am-h3", T("amClasses")));
const cwrapEl = h("div","am-classes");
CLASSES.forEach(c=>{
const chip = h("span","am-chip", c.name);
if(["super","admin"].includes(currentUser.role)){
const x = h("button","am-chipx","✕");
x.addEventListener("click", async ()=>{
try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); }
catch(e){ msg(e.message); }
});
chip.appendChild(x);
}
cwrapEl.appendChild(chip);
});
modal.appendChild(cwrapEl);
if(["super","admin"].includes(currentUser.role)){
const f = h("div","am-add");
const inp = h("input","am-inp");
inp.placeholder = T("amNamePh");
inp.addEventListener("keydown", ev=>ev.stopPropagation());
f.appendChild(inp);
const go = h("button","tbtn", T("amNewClass"));
go.addEventListener("click", async ()=>{
if(!inp.value.trim()) return;
try{
await api("/admin/classes", { body:{ name: inp.value.trim(), school: selSchool ? +selSchool : undefined } });
reload();
}catch(e){ msg(e.message); }
});
f.appendChild(go);
modal.appendChild(f);
}
}
/* gebruikers */
modal.appendChild(h("h3","am-h3", T("amUsers")));
const list = h("div","am-list");
const groups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")], ["pupil", T("rolePupil")]];
groups.forEach(([role, label])=>{
const us = USERS.filter(u=>u.role===role);
if(!us.length) return;
list.appendChild(h("div","am-group", label));
us.forEach(u=>list.appendChild(userRow(u)));
});
modal.appendChild(list);
/* toevoegen */
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"));
}
}
if(currentUser.role==="super") modal.appendChild(addForm("super"));
}
})();

View file

@ -37,19 +37,16 @@ try{ if(localStorage.getItem("teach.hc")==="1") setHC(true); }catch(e){}
applyI18n();
updateEmptyHint();
/* restore session (same browser tab session only) */
(function(){
let name = null;
try{ name = sessionStorage.getItem("teach.session"); }catch(e){}
const users = loadUsers();
if(name && users[name]){
currentUser = { name, data: users[name].data };
THEMES = themesFromData(currentUser.data);
GENERAL_EXTRA = generalExtraFromData(currentUser.data);
/* sessie herstellen: geldig servertoken? dan opnieuw inloggen zonder wachtwoord */
(async function(){
updateBoardsUI();
if(!TOKEN) return;
try{
const me = await api("/auth/me");
currentUser = me.user;
updateUserUI();
BS = boardsFromData(currentUser.data);
loadCurrentSlot();
}else{
updateBoardsUI();
await hydrateFromServer();
}catch(e){
resetToGuest();
}
})();

View file

@ -1,7 +1,7 @@
/* teach - kern: versie, i18n, accounts, opslag, geluid */
"use strict";
/* version — bump on every change (form 0.0.00) */
const VERSION = "0.1.02";
const VERSION = "0.2.00";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@ -109,9 +109,25 @@ const I18N = {
tmHint:"Sleep aan de rode schijf om de tijd in te stellen.",
login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen",
namePh:"naam", passPh:"wachtwoord",
guestNote:"Zonder inloggen wordt niets bewaard: eigen woorden en het bord zijn weg als je de pagina sluit. Log in om alles op dit apparaat te bewaren.",
storedNote:"Je eigen woorden worden automatisch bewaard. Met “Bord opslaan” bewaar je ook de widgets op het bord.",
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.",
errLogin:"Onbekende naam of verkeerd wachtwoord.",
schoolSel:"— kies je school (leeg = overkoepelend) —",
linkIntro:"Nieuw account? Activeer het met je koppelcode:",
linkBtn:"Account activeren", pwChange:"Wijzig", pwNewPh:"nieuw wachtwoord (min. 6)",
codePh:"koppelcode", beheer:"Beheer",
roleSuper:"Overkoepelend beheerder", roleAdmin:"Schoolbeheerder",
roleTeacher:"Groepsleiding", rolePupil:"Leerling",
amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen",
amNewSchool:" School", amNewClass:" Klas", amNewTeacher:" Groepsleiding",
amNewAdmin:" Schoolbeheerder", amNewSuper:" Overkoepelend beheerder",
amNewPupil:" Leerling", amUserPh:"gebruikersnaam…", amNamePh:"naam…",
amCode:"koppelcode", amNewCode:"nieuwe code", amPw:"wachtwoord",
amNoClass:"— geen klas —", amPending:"nog niet geactiveerd",
amCouple:"koppel leerkracht", amAllSchools:"— alle scholen —", amSuperGroup:"Overkoepelend",
amCreated:"Aangemaakt! Geef deze koppelcode door:",
amPupilMade:"Leerling aangemaakt. Wachtwoord:",
amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?",
errExists:"Deze naam bestaat al.",
errName:"Naam: 220 letters of cijfers.",
errPass:"Wachtwoord: minimaal 4 tekens.",
@ -206,9 +222,25 @@ const I18N = {
tmHint:"Drag the red disc to set the time.",
login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out",
namePh:"name", passPh:"password",
guestNote:"Without logging in nothing is saved: custom words and the board are gone when you close the page. Log in to keep everything on this device.",
storedNote:"Your custom words are saved automatically. “Save board” also stores the widgets on the board.",
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.",
errLogin:"Unknown name or wrong password.",
schoolSel:"— choose your school (empty = overarching) —",
linkIntro:"New account? Activate it with your link code:",
linkBtn:"Activate account", pwChange:"Change", pwNewPh:"new password (min. 6)",
codePh:"link code", beheer:"Management",
roleSuper:"Overarching administrator", roleAdmin:"School administrator",
roleTeacher:"Group leader", rolePupil:"Pupil",
amUsers:"Users", amClasses:"Classes", amSchools:"Schools",
amNewSchool:" School", amNewClass:" Class", amNewTeacher:" Group leader",
amNewAdmin:" School admin", amNewSuper:" Overarching admin",
amNewPupil:" Pupil", amUserPh:"username…", amNamePh:"name…",
amCode:"link code", amNewCode:"new code", amPw:"password",
amNoClass:"— no class —", amPending:"not yet activated",
amCouple:"link teacher", amAllSchools:"— all schools —", amSuperGroup:"Overarching",
amCreated:"Created! Pass on this link code:",
amPupilMade:"Pupil created. Password:",
amConfirmDel:"Are you sure you want to delete this user?",
errExists:"This name already exists.",
errName:"Name: 220 letters or digits.",
errPass:"Password: at least 4 characters.",
@ -227,6 +259,9 @@ function applyI18n(){
document.querySelectorAll("#langToggle button").forEach(b=>b.classList.toggle("active", b.dataset.lang===LANG));
document.getElementById("liName").placeholder = T("namePh");
document.getElementById("liPass").placeholder = T("passPh");
document.getElementById("liCode").placeholder = T("codePh");
document.getElementById("liPassNew").placeholder = T("pwNewPh");
document.getElementById("ownPw").placeholder = T("pwNewPh");
document.getElementById("btnMute").title = T("muteAll");
updateUserUI();
document.dispatchEvent(new CustomEvent("langchange"));
@ -239,126 +274,157 @@ document.getElementById("btnFs").addEventListener("click", ()=>{
});
/* =========================================================
Accounts & storage
- Guest: everything in memory only, gone on close.
- Logged in: PBKDF2-SHA256 hashed password (Web Crypto),
per-user data (custom words + saved board) in localStorage.
Note: this protects the password itself; anyone with physical
access to this device/browser profile can still clear or read
stored data. For real multi-device security a server is needed.
Accounts & sessies via de server-API (src/api.js)
Rollen: super (overkoepelend), admin (school), teacher (groepsleiding),
pupil (leerling). Zonder inloggen wordt niets bewaard.
==========================================================*/
const USERS_KEY = "teach.users";
let currentUser = null; // {name, data}
let TOKEN = null;
try{ TOKEN = localStorage.getItem("teach.token"); }catch(e){}
let currentUser = null; /* {id, username, displayName, role, schoolId, classId} */
function loadUsers(){ try{ return JSON.parse(localStorage.getItem(USERS_KEY)) || {}; }catch(e){ return {}; } }
function saveUsers(u){ try{ localStorage.setItem(USERS_KEY, JSON.stringify(u)); }catch(e){} }
const bufToHex = b => [...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,"0")).join("");
const hexToBuf = h => new Uint8Array(h.match(/.{2}/g).map(x=>parseInt(x,16)));
async function hashPw(pw, saltHex, iterations){
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(pw), "PBKDF2", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits(
{name:"PBKDF2", hash:"SHA-256", salt:hexToBuf(saltHex), iterations}, key, 256);
return bufToHex(bits);
async function api(path, opts={}){
const headers = { "Content-Type": "application/json" };
if(TOKEN) headers.Authorization = "Bearer " + TOKEN;
const r = await fetch("/api" + path, {
method: opts.method || (opts.body ? "POST" : "GET"),
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined
});
const j = await r.json().catch(()=>({}));
if(!r.ok) throw new Error(j.error || ("fout " + r.status));
return j;
}
const cryptoOK = !!(window.crypto && crypto.subtle && crypto.getRandomValues);
/* alles van de gebruiker (borden, thema's, eigen woorden) staat op de server */
let saveTimer = null;
function persistUser(){
if(!currentUser) return;
const users = loadUsers();
if(users[currentUser.name]){ users[currentUser.name].data = currentUser.data; saveUsers(users); }
if(!currentUser) return Promise.resolve();
return api("/me/data", { method:"PUT", body:{
themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS
}}).catch(()=>{});
}
function persistWords(){
if(currentUser){
currentUser.data.themes = THEMES;
currentUser.data.generalExtra = GENERAL_EXTRA;
persistUser();
}
if(!currentUser) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(persistUser, 800);
}
function generalExtraFromData(data){
return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]};
}
async function hydrateFromServer(){
const d = (await api("/me/data")).data || {};
THEMES = themesFromData(d);
GENERAL_EXTRA = generalExtraFromData(d);
BS = boardsFromData(d);
document.dispatchEvent(new CustomEvent("wordschange"));
loadCurrentSlot();
document.dispatchEvent(new CustomEvent("userchange"));
}
function resetToGuest(){
currentUser = null;
TOKEN = null;
try{ localStorage.removeItem("teach.token"); }catch(e){}
THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]};
BS = freshBS();
loadCurrentSlot();
updateUserUI();
document.dispatchEvent(new CustomEvent("wordschange"));
document.dispatchEvent(new CustomEvent("userchange"));
}
/* login UI */
const loginWrap = document.getElementById("loginWrap");
const loginMsg = document.getElementById("loginMsg");
const roleKey = r => r==="super" ? "roleSuper" : r==="admin" ? "roleAdmin" : r==="teacher" ? "roleTeacher" : "rolePupil";
function updateUserUI(){
document.getElementById("userLbl").textContent = currentUser ? currentUser.name : T("login");
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";
if(currentUser) document.getElementById("helloLbl").textContent = `${T("hello")} ${currentUser.name}! 👋`;
const adminBtn = document.getElementById("btnAdmin");
adminBtn.style.display = currentUser && currentUser.role !== "pupil" ? "" : "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";
}
}
async function loadSchools(){
const sel = document.getElementById("liSchool");
try{
const list = await api("/schools");
sel.innerHTML = `<option value="">${T("schoolSel")}</option>` +
list.map(x=>`<option value="${x.id}">${x.name.replace(/</g,"&lt;")}</option>`).join("");
}catch(e){ sel.innerHTML = `<option value="">${T("schoolSel")}</option>`; }
}
document.getElementById("btnUser").addEventListener("click", ()=>{
loginMsg.textContent = ""; loginMsg.classList.remove("ok");
loadSchools();
loginWrap.classList.add("open");
});
loginWrap.addEventListener("click", e=>{ if(e.target===loginWrap) loginWrap.classList.remove("open"); });
async function doLogin(register){
const name = document.getElementById("liName").value.trim();
const pass = document.getElementById("liPass").value;
loginMsg.classList.remove("ok");
if(!cryptoOK){ loginMsg.textContent = T("errCrypto"); return; }
if(!/^[a-zA-Z0-9_-]{2,20}$/.test(name)){ loginMsg.textContent = T("errName"); return; }
if(pass.length < 4){ loginMsg.textContent = T("errPass"); return; }
const users = loadUsers();
try{
if(register){
if(users[name]){ loginMsg.textContent = T("errExists"); return; }
const salt = bufToHex(crypto.getRandomValues(new Uint8Array(16)));
const iterations = 150000;
const hash = await hashPw(pass, salt, iterations);
users[name] = { salt, iterations, hash, data:{ themes:{nl:[],en:[]}, board:null } };
saveUsers(users);
}else{
const u = users[name];
if(!u){ loginMsg.textContent = T("errLogin"); return; }
const hash = await hashPw(pass, u.salt, u.iterations);
if(hash !== u.hash){ loginMsg.textContent = T("errLogin"); return; }
}
}catch(e){ loginMsg.textContent = T("errCrypto"); return; }
currentUser = { name, data: loadUsers()[name].data };
THEMES = themesFromData(currentUser.data);
GENERAL_EXTRA = generalExtraFromData(currentUser.data);
document.getElementById("liPass").value = "";
try{ sessionStorage.setItem("teach.session", name); }catch(e){}
async function afterLogin(res){
TOKEN = res.token;
try{ localStorage.setItem("teach.token", TOKEN); }catch(e){}
currentUser = res.user;
updateUserUI();
loginWrap.classList.remove("open");
document.dispatchEvent(new CustomEvent("wordschange"));
BS = boardsFromData(currentUser.data);
loadCurrentSlot();
await hydrateFromServer();
}
document.getElementById("btnLogin").addEventListener("click", ()=>doLogin(false));
document.getElementById("btnRegister").addEventListener("click", ()=>doLogin(true));
document.getElementById("liPass").addEventListener("keydown", e=>{ if(e.key==="Enter") doLogin(false); });
document.getElementById("btnLogout").addEventListener("click", ()=>{
persistUser();
currentUser = null;
THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]};
try{ sessionStorage.removeItem("teach.session"); }catch(e){}
updateUserUI();
document.getElementById("btnLogin").addEventListener("click", async ()=>{
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"); }
});
document.getElementById("liPass").addEventListener("keydown", e=>{
if(e.key==="Enter") document.getElementById("btnLogin").click();
});
/* account activeren met een koppelcode */
document.getElementById("btnLink").addEventListener("click", async ()=>{
const code = document.getElementById("liCode").value.trim().toUpperCase();
const password = document.getElementById("liPassNew").value;
loginMsg.classList.remove("ok");
try{
await afterLogin(await api("/auth/link", { body:{ code, password } }));
document.getElementById("liCode").value = "";
document.getElementById("liPassNew").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");
document.dispatchEvent(new CustomEvent("wordschange"));
BS = freshBS();
loadCurrentSlot();
});
/* eigen wachtwoord wijzigen (niet voor leerlingen) */
document.getElementById("btnOwnPw").addEventListener("click", async ()=>{
const pw = document.getElementById("ownPw").value;
try{
await api("/auth/password", { method:"PATCH", body:{ password: pw } });
document.getElementById("ownPw").value = "";
}catch(e){ alert(e.message); }
});
/* save board */
document.getElementById("btnSave").addEventListener("click", ()=>{
document.getElementById("btnSave").addEventListener("click", async ()=>{
const btn = document.getElementById("btnSave");
const lbl = btn.querySelector("span");
if(!currentUser){
lbl.textContent = T("loginFirst");
setTimeout(()=>{ lbl.textContent = T("saveBoard"); }, 2200);
loadSchools();
loginWrap.classList.add("open");
return;
}
stashCurrent();
currentUser.data.boards = BS;
delete currentUser.data.board;
persistUser();
await persistUser();
lbl.textContent = T("boardSaved");
btn.classList.add("ok");
setTimeout(()=>{ lbl.textContent = T("saveBoard"); btn.classList.remove("ok"); }, 1800);

271
src/api.js Normal file
View file

@ -0,0 +1,271 @@
// teach API: inloggen, rollen en gebruikersbeheer.
//
// Rollen en rechten:
// super - overkoepelend beheerder: alles, over alle scholen heen,
// kan andere supers aanwijzen.
// admin - schoolbeheerder: alle gebruikers en klassen van de eigen school.
// teacher - groepsleiding: leerlingen aanmaken in eigen klas, leerlingen
// verplaatsen naar andere klassen (uitwisselen), leerling-
// wachtwoorden inzien en aanpassen.
// pupil - leerling: inloggen en eigen borden; kan het eigen wachtwoord
// NIET inzien of wijzigen.
import {
hashPassword, verifyPassword, createSession, userFromRequest,
newLinkCode, pupilPassword, publicUser,
} from './auth.js';
export default async function api(app) {
const pool = app.pg;
// ---- helpers --------------------------------------------------------------
const fail = (reply, code, msg) => { reply.code(code); return { error: msg }; };
app.decorateRequest('user', null);
app.addHook('preHandler', async (req) => {
req.user = await userFromRequest(pool, req);
});
const need = (req, reply, roles) => {
if (!req.user) { reply.code(401); throw new Error('niet ingelogd'); }
if (roles && !roles.includes(req.user.role)) { reply.code(403); throw new Error('geen rechten'); }
};
const sameSchool = (req, u) =>
req.user.role === 'super' || Number(u.school_id) === Number(req.user.school_id);
app.setErrorHandler((err, req, reply) => {
if (reply.statusCode >= 400 && reply.statusCode < 500) return reply.send({ error: err.message });
req.log.error(err);
reply.code(500).send({ error: 'serverfout' });
});
// ---- version --------------------------------------------------------------
app.get('/version', async () => ({
name: 'teach',
version: process.env.APP_VERSION ?? 'dev',
}));
// ---- publiek: scholenlijst voor het inlogscherm ----------------------------
app.get('/schools', async () => {
const r = await pool.query('SELECT id, name FROM schools ORDER BY name');
return r.rows.map((s) => ({ id: Number(s.id), name: s.name }));
});
// ---- auth -------------------------------------------------------------------
app.post('/auth/login', async (req, reply) => {
const { schoolId, username, password } = req.body ?? {};
if (!username || !password) return fail(reply, 400, 'naam en wachtwoord verplicht');
const r = schoolId
? await pool.query('SELECT * FROM users WHERE school_id = $1 AND lower(username) = lower($2)', [schoolId, username])
: await pool.query('SELECT * FROM users WHERE school_id IS NULL AND lower(username) = lower($1)', [username]);
const u = r.rows[0];
if (!u || !(await verifyPassword(password, u.password_hash)))
return fail(reply, 401, 'onbekende naam of verkeerd wachtwoord');
const token = await createSession(pool, u.id);
return { token, user: publicUser(u) };
});
// Account activeren met een koppelcode (groepsleiding/beheerders)
app.post('/auth/link', async (req, reply) => {
const { code, password } = req.body ?? {};
if (!code || !password || password.length < 6)
return fail(reply, 400, 'koppelcode en wachtwoord (min. 6 tekens) verplicht');
const r = await pool.query('SELECT * FROM users WHERE link_code = $1', [code.trim().toUpperCase()]);
const u = r.rows[0];
if (!u) return fail(reply, 404, 'koppelcode onbekend of al gebruikt');
const hash = await hashPassword(password);
await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [hash, u.id]);
const token = await createSession(pool, u.id);
return { token, user: publicUser({ ...u, password_hash: hash, link_code: null }) };
});
app.post('/auth/logout', async (req) => {
const h = req.headers.authorization || '';
if (h.startsWith('Bearer ')) await pool.query('DELETE FROM sessions WHERE token = $1', [h.slice(7)]);
return { ok: true };
});
app.get('/auth/me', async (req, reply) => {
if (!req.user) return fail(reply, 401, 'niet ingelogd');
return { user: publicUser(req.user) };
});
// Eigen wachtwoord wijzigen - niet voor leerlingen
app.patch('/auth/password', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']);
const { password } = req.body ?? {};
if (!password || password.length < 6) return fail(reply, 400, 'minimaal 6 tekens');
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2',
[await hashPassword(password), req.user.id]);
return { ok: true };
});
// ---- eigen app-data (borden, thema's, woorden) ------------------------------
app.get('/me/data', async (req, reply) => {
need(req, reply);
const r = await pool.query('SELECT data FROM users WHERE id = $1', [req.user.id]);
return { data: r.rows[0]?.data ?? {} };
});
app.put('/me/data', async (req, reply) => {
need(req, reply);
await pool.query('UPDATE users SET data = $1 WHERE id = $2', [req.body ?? {}, req.user.id]);
return { ok: true };
});
// ---- scholen (super) ---------------------------------------------------------
app.post('/admin/schools', async (req, reply) => {
need(req, reply, ['super']);
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']);
await pool.query('DELETE FROM schools WHERE id = $1', [req.params.id]);
return { ok: true };
});
// ---- klassen -----------------------------------------------------------------
app.get('/admin/classes', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']);
const schoolId = req.user.role === 'super' ? req.query.school : req.user.school_id;
if (!schoolId) return { classes: [] };
const r = await pool.query(
`SELECT c.id, c.name,
COALESCE(json_agg(ct.user_id) FILTER (WHERE ct.user_id IS NOT NULL), '[]') AS teacher_ids
FROM classes c LEFT JOIN class_teachers ct ON ct.class_id = c.id
WHERE c.school_id = $1 GROUP BY c.id ORDER BY c.name`, [schoolId]);
return { classes: r.rows.map((c) => ({ id: Number(c.id), name: c.name, teacherIds: c.teacher_ids.map(Number) })) };
});
app.post('/admin/classes', async (req, reply) => {
need(req, reply, ['super', 'admin']);
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');
const r = await pool.query('INSERT INTO classes (school_id, name) VALUES ($1,$2) RETURNING id, name', [schoolId, name.trim()]);
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']);
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))
return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM classes WHERE id = $1', [req.params.id]);
return { ok: true };
});
// groepsleiding aan klas koppelen/loskoppelen
app.post('/admin/classes/:id/teachers', async (req, reply) => {
need(req, reply, ['super', 'admin']);
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]);
return { ok: true };
});
// ---- 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']);
let rows;
if (req.user.role === 'super') {
rows = (req.query.school
? await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.query.school])
: await pool.query('SELECT * FROM users ORDER BY school_id NULLS FIRST, role, lower(username)')).rows;
} 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);
return { users: rows.map((u) => ({
...publicUser(u),
linkCode: u.link_code || null,
// leerlingwachtwoorden zijn inzichtelijk voor groepsleiding en beheer
password: canSeePw && u.role === 'pupil' ? u.password_plain : undefined,
})) };
});
// 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']);
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');
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();
if (!/^[a-zA-Z0-9_.-]{2,30}$/.test(username)) return fail(reply, 400, 'ongeldige gebruikersnaam');
try {
if (role === 'pupil') {
const pw = b.password || pupilPassword();
const r = await pool.query(
`INSERT INTO users (username, role, school_id, class_id, display_name, password_hash, password_plain)
VALUES ($1,'pupil',$2,$3,$4,$5,$6) RETURNING *`,
[username, schoolId, b.classId ?? null, b.displayName || username, await hashPassword(pw), pw]);
return { user: { ...publicUser(r.rows[0]), password: pw } };
}
const code = newLinkCode();
const r = await pool.query(
`INSERT INTO users (username, role, school_id, display_name, link_code)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[username, role, schoolId, b.displayName || username, code]);
return { user: { ...publicUser(r.rows[0]), linkCode: code } };
} catch (e) {
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al');
throw e;
}
});
// Aanpassen: naam, klas (uitwisselen), leerlingwachtwoord, nieuwe koppelcode, rol (super).
app.patch('/admin/users/:id', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']);
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');
if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten');
if (req.user.role === 'teacher' && u.role !== 'pupil') return fail(reply, 403, 'groepsleiding beheert alleen leerlingen');
if (u.role === 'super' && req.user.role !== 'super') return fail(reply, 403, 'geen rechten');
const b = req.body ?? {};
if (b.displayName) await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName, u.id]);
if (b.classId !== undefined && u.role === 'pupil')
await pool.query('UPDATE users SET class_id = $1 WHERE id = $2', [b.classId, u.id]);
if (b.password && u.role === 'pupil')
await pool.query('UPDATE users SET password_plain = $1, password_hash = $2 WHERE id = $3',
[b.password, await hashPassword(b.password), u.id]);
if (b.newLinkCode && u.role !== 'pupil' && ['super', 'admin'].includes(req.user.role)) {
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')
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']);
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');
if (Number(u.id) === Number(req.user.id)) return fail(reply, 400, 'je kunt jezelf niet verwijderen');
if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten');
if (req.user.role === 'teacher' && u.role !== 'pupil') return fail(reply, 403, 'geen rechten');
if (u.role === 'super' && req.user.role !== 'super') return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM users WHERE id = $1', [u.id]);
return { ok: true };
});
}
// Eerste super-beheerder aanmaken als die nog niet bestaat.
export async function bootstrapSuper(pool, log) {
const r = await pool.query("SELECT count(*)::int AS n FROM users WHERE role = 'super'");
if (r.rows[0].n > 0) return;
const username = process.env.SUPER_USER || 'beheerder';
const password = process.env.SUPER_PASS || newLinkCode();
await pool.query(
`INSERT INTO users (username, role, display_name, password_hash)
VALUES ($1, 'super', $1, $2)`,
[username, await hashPassword(password)]);
log.warn(`Eerste super-beheerder aangemaakt: ${username} / ${password} - wijzig dit wachtwoord direct!`);
}

82
src/auth.js Normal file
View file

@ -0,0 +1,82 @@
// Auth-helpers: wachtwoord-hashing (scrypt, ingebouwd in Node), sessietokens
// en koppelcodes. Geen externe dependencies nodig.
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
export function hashPassword(password) {
return new Promise((resolve, reject) => {
const salt = randomBytes(16).toString('hex');
scrypt(password, salt, 32, (err, buf) => {
if (err) return reject(err);
resolve(`${salt}:${buf.toString('hex')}`);
});
});
}
export function verifyPassword(password, stored) {
return new Promise((resolve) => {
if (!stored || !stored.includes(':')) return resolve(false);
const [salt, hex] = stored.split(':');
scrypt(password, salt, 32, (err, buf) => {
if (err) return resolve(false);
const a = Buffer.from(hex, 'hex');
resolve(a.length === buf.length && timingSafeEqual(a, buf));
});
});
}
export function newToken() {
return randomBytes(32).toString('hex');
}
// Leesbare eenmalige koppelcode, bv. "K7FP-3RZM"
export function newLinkCode() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const pick = (n) => Array.from(randomBytes(n)).map((b) => chars[b % chars.length]).join('');
return `${pick(4)}-${pick(4)}`;
}
// Eenvoudig leerlingwachtwoord dat een juf kan voorlezen, bv. "vis73"
export function pupilPassword() {
const words = ['vis','kat','zon','maan','boot','ster','boom','bal','kip','roos'];
const w = words[randomBytes(1)[0] % words.length];
const n = 10 + (randomBytes(1)[0] % 90);
return `${w}${n}`;
}
const SESSION_DAYS = 30;
export async function createSession(pool, userId) {
const token = newToken();
await pool.query(
`INSERT INTO sessions (token, user_id, expires_at)
VALUES ($1, $2, now() + interval '${SESSION_DAYS} days')`,
[token, userId],
);
return token;
}
export async function userFromRequest(pool, req) {
const h = req.headers.authorization || '';
const token = h.startsWith('Bearer ') ? h.slice(7) : null;
if (!token) return null;
const r = await pool.query(
`SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = $1 AND s.expires_at > now()`,
[token],
);
return r.rows[0] ?? null;
}
// Publiek profiel (zonder wachtwoordvelden)
export function publicUser(u) {
if (!u) return null;
return {
id: Number(u.id),
username: u.username,
displayName: u.display_name || u.username,
role: u.role,
schoolId: u.school_id == null ? null : Number(u.school_id),
classId: u.class_id == null ? null : Number(u.class_id),
pending: !u.password_hash && !!u.link_code,
};
}

View file

@ -3,6 +3,7 @@ import { dirname, join } from 'node:path';
import Fastify from 'fastify';
import fastifyStatic from '@fastify/static';
import pg from 'pg';
import api, { bootstrapSuper } from './api.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -39,17 +40,8 @@ app.get('/readyz', async (req, reply) => {
});
// --- API ---------------------------------------------------------------------
// Voorbeeld-endpoint. Hier komt straks de board/user-API die nu nog in
// localStorage zit. Prefix /api houdt het netjes gescheiden van de static app.
app.register(
async (api) => {
api.get('/version', async () => ({
name: 'teach',
version: process.env.APP_VERSION ?? 'dev',
}));
},
{ prefix: '/api' },
);
// Inloggen, rollen, gebruikersbeheer en per-gebruiker data. Zie src/api.js.
app.register(api, { prefix: '/api' });
// --- Static frontend ---------------------------------------------------------
// Serveert public/index.html (de digibord-app) op /
@ -61,6 +53,7 @@ app.register(fastifyStatic, {
// --- Start -------------------------------------------------------------------
const start = async () => {
try {
await bootstrapSuper(pool, app.log);
await app.listen({ port: PORT, host: HOST });
} catch (err) {
app.log.error(err);