Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5
12 changed files with 874 additions and 109 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
52
db/002_schools_auth.sql
Normal 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);
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
|
|
@ -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
223
public/js/admin.js
Normal 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,"&").replace(/</g,"<");
|
||||
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"));
|
||||
}
|
||||
})();
|
||||
|
|
@ -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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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 = `<option value="">${T("schoolSel")}</option>` +
|
||||
list.map(x=>`<option value="${x.id}">${x.name.replace(/</g,"<")}</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
271
src/api.js
Normal 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
82
src/auth.js
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue