From 352e22a64b3f6ee73c81134254a3b89b88cd78c4 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:26:01 +0200 Subject: [PATCH] v0.2.00: schoolsysteem met rollen (super/admin/groepsleiding/leerling), koppelcodes, server-API en beheerpaneel --- .env.example | 5 + compose.yaml | 2 + db/002_schools_auth.sql | 52 +++++++ deploy/compose.deploy.yaml | 2 + public/css/teach.css | 56 ++++++++ public/index.html | 20 ++- public/js/admin.js | 223 ++++++++++++++++++++++++++++++ public/js/app.js | 23 ++-- public/js/core.js | 232 +++++++++++++++++++------------ src/api.js | 271 +++++++++++++++++++++++++++++++++++++ src/auth.js | 82 +++++++++++ src/server.js | 15 +- 12 files changed, 874 insertions(+), 109 deletions(-) create mode 100644 db/002_schools_auth.sql create mode 100644 public/js/admin.js create mode 100644 src/api.js create mode 100644 src/auth.js diff --git a/.env.example b/.env.example index e657648..ab8591b 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/compose.yaml b/compose.yaml index f1f33d9..4881ba6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/db/002_schools_auth.sql b/db/002_schools_auth.sql new file mode 100644 index 0000000..05bb747 --- /dev/null +++ b/db/002_schools_auth.sql @@ -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); diff --git a/deploy/compose.deploy.yaml b/deploy/compose.deploy.yaml index 5520b4e..c92f55a 100644 --- a/deploy/compose.deploy.yaml +++ b/deploy/compose.deploy.yaml @@ -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" diff --git a/public/css/teach.css b/public/css/teach.css index f54678c..575f636 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -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;} diff --git a/public/index.html b/public/index.html index 54028b4..773456a 100644 --- a/public/index.html +++ b/public/index.html @@ -24,6 +24,7 @@ +
@@ -70,17 +71,28 @@
+
+ +
@@ -106,6 +121,7 @@ + diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..89c94b0 --- /dev/null +++ b/public/js/admin.js @@ -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,"&").replace(/{ + 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")); + } +})(); diff --git a/public/js/app.js b/public/js/app.js index 09cd209..3f3ec04 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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(); } })(); \ No newline at end of file diff --git a/public/js/core.js b/public/js/core.js index 1d3be71..4f6d6da 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -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: 2–20 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: 2–20 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 = `` + + list.map(x=>``; } } 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); diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..3077c2d --- /dev/null +++ b/src/api.js @@ -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!`); +} diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..af93090 --- /dev/null +++ b/src/auth.js @@ -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, + }; +} diff --git a/src/server.js b/src/server.js index 14afce0..233f13d 100644 --- a/src/server.js +++ b/src/server.js @@ -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);