Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-66 into main AGit
7 changed files with 177 additions and 15 deletions
Showing only changes of commit bd7c7e07e7 - Show all commits

View file

@ -0,0 +1,5 @@
-- v0.3.00: bijhouden of een account al gebruikt is, zodat leerling-
-- wachtwoorden na de eerste keer inloggen niet langer inzichtelijk zijn.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;

View file

@ -407,6 +407,8 @@
#loginModal input:focus{border-color:var(--blue);} #loginModal input:focus{border-color:var(--blue);}
.lrow{display:flex; gap:8px;} .lrow{display:flex; gap:8px;}
.lrow .tbtn{flex:1;} .lrow .tbtn{flex:1;}
.lrow input{flex:1; min-width:0; margin-bottom:0;}
.lrow .pwtoggle{flex:0 0 auto; white-space:nowrap; padding-left:10px; padding-right:10px;}
#loginMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;} #loginMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;}
#loginMsg.ok{color:var(--green);} #loginMsg.ok{color:var(--green);}
.guestnote{font-size:13px; color:var(--muted); margin-top:12px; line-height:1.45;} .guestnote{font-size:13px; color:var(--muted); margin-top:12px; line-height:1.45;}

View file

@ -81,7 +81,11 @@
<div class="lidiv"></div> <div class="lidiv"></div>
<div class="lisub" data-i18n="linkIntro"></div> <div class="lisub" data-i18n="linkIntro"></div>
<input type="text" id="liCode" maxlength="9" style="text-transform:uppercase;"> <input type="text" id="liCode" maxlength="9" style="text-transform:uppercase;">
<div class="lrow">
<input type="password" id="liPassNew" maxlength="64" autocomplete="new-password"> <input type="password" id="liPassNew" maxlength="64" autocomplete="new-password">
<button class="tbtn ghost pwtoggle" type="button" id="btnLiPassToggle" data-i18n="pwShow"></button>
</div>
<input type="password" id="liPassNew2" maxlength="64" autocomplete="new-password">
<div class="lrow"> <div class="lrow">
<button class="tbtn ghost" id="btnLink" data-i18n="linkBtn"></button> <button class="tbtn ghost" id="btnLink" data-i18n="linkBtn"></button>
</div> </div>
@ -91,6 +95,10 @@
<div class="hello" id="helloLbl"></div> <div class="hello" id="helloLbl"></div>
<div class="lrow" id="ownPwRow"> <div class="lrow" id="ownPwRow">
<input type="password" id="ownPw" maxlength="64" autocomplete="new-password"> <input type="password" id="ownPw" maxlength="64" autocomplete="new-password">
<button class="tbtn ghost pwtoggle" type="button" id="btnOwnPwToggle" data-i18n="pwShow"></button>
</div>
<div class="lrow">
<input type="password" id="ownPw2" maxlength="64" autocomplete="new-password">
<button class="tbtn ghost" id="btnOwnPw" data-i18n="pwChange"></button> <button class="tbtn ghost" id="btnOwnPw" data-i18n="pwChange"></button>
</div> </div>
<div class="lrow"> <div class="lrow">

View file

@ -56,17 +56,32 @@
catch(e){ msg(e.message); } catch(e){ msg(e.message); }
}); });
row.appendChild(cs); row.appendChild(cs);
/* wachtwoord inzien en aanpassen */ /* wachtwoord inzien en aanpassen - alleen zichtbaar zolang de leerling
nog niet is ingelogd; daarna kan alleen een nieuw wachtwoord gezet worden */
const pw = h("input","am-pw"); const pw = h("input","am-pw");
pw.value = u.password || ""; pw.value = u.password || "";
pw.placeholder = u.everLoggedIn ? T("amPwHidden") : "";
pw.title = T("amPw"); pw.title = T("amPw");
pw.addEventListener("keydown", ev=>ev.stopPropagation()); pw.addEventListener("keydown", ev=>ev.stopPropagation());
pw.addEventListener("change", async ()=>{ pw.addEventListener("change", async ()=>{
if(pw.value.length < 3) return; if(pw.value.length < 4) return;
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); } try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); reload(); }
catch(e){ msg(e.message); } catch(e){ msg(e.message); }
}); });
row.appendChild(pw); row.appendChild(pw);
if(u.everLoggedIn){
const gen = h("button","am-btn","🎲");
gen.title = T("amGenerate");
gen.addEventListener("click", async ()=>{
try{
const { password } = await api("/admin/genpw?role=pupil");
await api("/admin/users/"+u.id, {method:"PATCH", body:{password}});
msg(`${T("amPwSet")} ${password}`);
reload();
}catch(e){ msg(e.message); }
});
row.appendChild(gen);
}
}else{ }else{
if(u.pending && u.linkCode){ if(u.pending && u.linkCode){
row.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode)); row.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode));
@ -83,6 +98,16 @@
}catch(e){ msg(e.message); } }catch(e){ msg(e.message); }
}); });
row.appendChild(nc); row.appendChild(nc);
/* direct een nieuw wachtwoord zetten, zonder de koppelcode-activatie */
const np = h("button","am-btn", T("amNewPw"));
np.addEventListener("click", async ()=>{
try{
const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}});
msg(`${T("amPwSet")} ${r.password}`);
reload();
}catch(e){ msg(e.message); }
});
row.appendChild(np);
} }
} }
if(u.id !== currentUser.id){ if(u.id !== currentUser.id){
@ -106,17 +131,43 @@
f.appendChild(name); f.appendChild(name);
let cs = null; let cs = null;
if(role==="pupil"){ cs = classSel(""); f.appendChild(cs); } if(role==="pupil"){ cs = classSel(""); f.appendChild(cs); }
let pwInp = null;
if(role!=="pupil"){
/* optioneel: meteen zelf een wachtwoord instellen i.p.v. een koppelcode
(die de gebruiker normaal zelf gebruikt om een wachtwoord te kiezen) */
pwInp = h("input","am-inp am-pwinp");
pwInp.type = "password";
pwInp.autocomplete = "new-password";
pwInp.placeholder = T("amPwOptional");
pwInp.addEventListener("keydown", ev=>ev.stopPropagation());
f.appendChild(pwInp);
const gen = h("button","am-btn","🎲");
gen.type = "button";
gen.title = T("amGenerate");
gen.addEventListener("click", async ()=>{
try{
const { password } = await api("/admin/genpw");
pwInp.type = "text";
pwInp.value = password;
}catch(e){ msg(e.message); }
});
f.appendChild(gen);
}
const go = h("button","tbtn", T(role==="pupil" ? "amNewPupil" : role==="teacher" ? "amNewTeacher" : role==="admin" ? "amNewAdmin" : "amNewSuper")); const go = h("button","tbtn", T(role==="pupil" ? "amNewPupil" : role==="teacher" ? "amNewTeacher" : role==="admin" ? "amNewAdmin" : "amNewSuper"));
go.addEventListener("click", async ()=>{ go.addEventListener("click", async ()=>{
const body = { role, username: name.value.trim(), displayName: name.value.trim() }; const body = { role, username: name.value.trim(), displayName: name.value.trim() };
if(role==="pupil" && cs && cs.value) body.classId = +cs.value; if(role==="pupil" && cs && cs.value) body.classId = +cs.value;
if(pwInp && pwInp.value.trim()) body.password = pwInp.value.trim();
if(currentUser.role==="super" && role!=="super") body.school = +selSchool; if(currentUser.role==="super" && role!=="super") body.school = +selSchool;
try{ try{
const r = await api("/admin/users", { body }); const r = await api("/admin/users", { body });
msg(role==="pupil" msg(role==="pupil"
? `${T("amPupilMade")} ${r.user.password}` ? `${T("amPupilMade")} ${r.user.password}`
: r.user.password
? `${T("amPwSet")} ${r.user.password}`
: `${T("amCreated")} ${r.user.linkCode}`); : `${T("amCreated")} ${r.user.linkCode}`);
name.value = ""; name.value = "";
if(pwInp) pwInp.value = "";
reload(); reload();
}catch(e){ msg(e.message); } }catch(e){ msg(e.message); }
}); });

View file

@ -114,7 +114,9 @@ const I18N = {
errLogin:"Onbekende naam of verkeerd wachtwoord.", errLogin:"Onbekende naam of verkeerd wachtwoord.",
schoolSel:"— kies je school (leeg = overkoepelend) —", schoolSel:"— kies je school (leeg = overkoepelend) —",
linkIntro:"Nieuw account? Activeer het met je koppelcode:", linkIntro:"Nieuw account? Activeer het met je koppelcode:",
linkBtn:"Account activeren", pwChange:"Wijzig", pwNewPh:"nieuw wachtwoord (min. 6)", linkBtn:"Account activeren", pwChange:"Wijzig", pwNewPh:"nieuw wachtwoord (min. 8, met cijfer)",
pwConfirmPh:"herhaal wachtwoord", pwShow:"👁 Toon", pwHide:"🙈 Verberg",
errPwMatch:"Wachtwoorden komen niet overeen.",
codePh:"koppelcode", beheer:"Beheer", codePh:"koppelcode", beheer:"Beheer",
roleSuper:"Overkoepelend beheerder", roleAdmin:"Schoolbeheerder", roleSuper:"Overkoepelend beheerder", roleAdmin:"Schoolbeheerder",
roleTeacher:"Groepsleiding", rolePupil:"Leerling", roleTeacher:"Groepsleiding", rolePupil:"Leerling",
@ -127,6 +129,9 @@ const I18N = {
amCouple:"koppel leerkracht", amAllSchools:"— alle scholen —", amSuperGroup:"Overkoepelend", amCouple:"koppel leerkracht", amAllSchools:"— alle scholen —", amSuperGroup:"Overkoepelend",
amCreated:"Aangemaakt! Geef deze koppelcode door:", amCreated:"Aangemaakt! Geef deze koppelcode door:",
amPupilMade:"Leerling aangemaakt. Wachtwoord:", amPupilMade:"Leerling aangemaakt. Wachtwoord:",
amPwHidden:"al gebruikt", amGenerate:"genereer wachtwoord",
amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:",
amPwOptional:"wachtwoord (leeg = koppelcode)",
amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?",
errExists:"Deze naam bestaat al.", errExists:"Deze naam bestaat al.",
errName:"Naam: 220 letters of cijfers.", errName:"Naam: 220 letters of cijfers.",
@ -227,7 +232,9 @@ const I18N = {
errLogin:"Unknown name or wrong password.", errLogin:"Unknown name or wrong password.",
schoolSel:"— choose your school (empty = overarching) —", schoolSel:"— choose your school (empty = overarching) —",
linkIntro:"New account? Activate it with your link code:", linkIntro:"New account? Activate it with your link code:",
linkBtn:"Activate account", pwChange:"Change", pwNewPh:"new password (min. 6)", linkBtn:"Activate account", pwChange:"Change", pwNewPh:"new password (min. 8, incl. number)",
pwConfirmPh:"repeat password", pwShow:"👁 Show", pwHide:"🙈 Hide",
errPwMatch:"Passwords don't match.",
codePh:"link code", beheer:"Management", codePh:"link code", beheer:"Management",
roleSuper:"Overarching administrator", roleAdmin:"School administrator", roleSuper:"Overarching administrator", roleAdmin:"School administrator",
roleTeacher:"Group leader", rolePupil:"Pupil", roleTeacher:"Group leader", rolePupil:"Pupil",
@ -240,6 +247,9 @@ const I18N = {
amCouple:"link teacher", amAllSchools:"— all schools —", amSuperGroup:"Overarching", amCouple:"link teacher", amAllSchools:"— all schools —", amSuperGroup:"Overarching",
amCreated:"Created! Pass on this link code:", amCreated:"Created! Pass on this link code:",
amPupilMade:"Pupil created. Password:", amPupilMade:"Pupil created. Password:",
amPwHidden:"already used", amGenerate:"generate password",
amNewPw:"new password", amPwSet:"Password set:",
amPwOptional:"password (empty = link code)",
amConfirmDel:"Are you sure you want to delete this user?", amConfirmDel:"Are you sure you want to delete this user?",
errExists:"This name already exists.", errExists:"This name already exists.",
errName:"Name: 220 letters or digits.", errName:"Name: 220 letters or digits.",
@ -261,7 +271,11 @@ function applyI18n(){
document.getElementById("liPass").placeholder = T("passPh"); document.getElementById("liPass").placeholder = T("passPh");
document.getElementById("liCode").placeholder = T("codePh"); document.getElementById("liCode").placeholder = T("codePh");
document.getElementById("liPassNew").placeholder = T("pwNewPh"); document.getElementById("liPassNew").placeholder = T("pwNewPh");
document.getElementById("liPassNew2").placeholder = T("pwConfirmPh");
document.getElementById("ownPw").placeholder = T("pwNewPh"); document.getElementById("ownPw").placeholder = T("pwNewPh");
document.getElementById("ownPw2").placeholder = T("pwConfirmPh");
document.getElementById("btnLiPassToggle").textContent = T("pwShow");
document.getElementById("btnOwnPwToggle").textContent = T("pwShow");
document.getElementById("btnMute").title = T("muteAll"); document.getElementById("btnMute").title = T("muteAll");
updateUserUI(); updateUserUI();
document.dispatchEvent(new CustomEvent("langchange")); document.dispatchEvent(new CustomEvent("langchange"));
@ -387,15 +401,31 @@ document.getElementById("btnLogin").addEventListener("click", async ()=>{
document.getElementById("liPass").addEventListener("keydown", e=>{ document.getElementById("liPass").addEventListener("keydown", e=>{
if(e.key==="Enter") document.getElementById("btnLogin").click(); if(e.key==="Enter") document.getElementById("btnLogin").click();
}); });
/* wachtwoord tonen/verbergen bij activeren en eigen wachtwoord wijzigen */
function pwToggle(btnId, ...fieldIds){
const btn = document.getElementById(btnId);
let shown = false;
btn.addEventListener("click", ()=>{
shown = !shown;
fieldIds.forEach(id=>{ document.getElementById(id).type = shown ? "text" : "password"; });
btn.textContent = T(shown ? "pwHide" : "pwShow");
});
}
pwToggle("btnLiPassToggle", "liPassNew", "liPassNew2");
pwToggle("btnOwnPwToggle", "ownPw", "ownPw2");
/* account activeren met een koppelcode */ /* account activeren met een koppelcode */
document.getElementById("btnLink").addEventListener("click", async ()=>{ document.getElementById("btnLink").addEventListener("click", async ()=>{
const code = document.getElementById("liCode").value.trim().toUpperCase(); const code = document.getElementById("liCode").value.trim().toUpperCase();
const password = document.getElementById("liPassNew").value; const password = document.getElementById("liPassNew").value;
const password2 = document.getElementById("liPassNew2").value;
loginMsg.classList.remove("ok"); loginMsg.classList.remove("ok");
if(password !== password2){ loginMsg.textContent = T("errPwMatch"); return; }
try{ try{
await afterLogin(await api("/auth/link", { body:{ code, password } })); await afterLogin(await api("/auth/link", { body:{ code, password } }));
document.getElementById("liCode").value = ""; document.getElementById("liCode").value = "";
document.getElementById("liPassNew").value = ""; document.getElementById("liPassNew").value = "";
document.getElementById("liPassNew2").value = "";
}catch(e){ loginMsg.textContent = e.message; } }catch(e){ loginMsg.textContent = e.message; }
}); });
document.getElementById("btnLogout").addEventListener("click", async ()=>{ document.getElementById("btnLogout").addEventListener("click", async ()=>{
@ -406,9 +436,12 @@ document.getElementById("btnLogout").addEventListener("click", async ()=>{
/* eigen wachtwoord wijzigen (niet voor leerlingen) */ /* eigen wachtwoord wijzigen (niet voor leerlingen) */
document.getElementById("btnOwnPw").addEventListener("click", async ()=>{ document.getElementById("btnOwnPw").addEventListener("click", async ()=>{
const pw = document.getElementById("ownPw").value; const pw = document.getElementById("ownPw").value;
const pw2 = document.getElementById("ownPw2").value;
if(pw !== pw2){ alert(T("errPwMatch")); return; }
try{ try{
await api("/auth/password", { method:"PATCH", body:{ password: pw } }); await api("/auth/password", { method:"PATCH", body:{ password: pw } });
document.getElementById("ownPw").value = ""; document.getElementById("ownPw").value = "";
document.getElementById("ownPw2").value = "";
}catch(e){ alert(e.message); } }catch(e){ alert(e.message); }
}); });

View file

@ -11,7 +11,7 @@
// NIET inzien of wijzigen. // NIET inzien of wijzigen.
import { import {
hashPassword, verifyPassword, createSession, userFromRequest, hashPassword, verifyPassword, createSession, userFromRequest,
newLinkCode, pupilPassword, publicUser, newLinkCode, pupilPassword, staffPassword, checkPassword, publicUser,
} from './auth.js'; } from './auth.js';
export default async function api(app) { export default async function api(app) {
@ -59,6 +59,8 @@ export default async function api(app) {
const u = r.rows[0]; const u = r.rows[0];
if (!u || !(await verifyPassword(password, u.password_hash))) if (!u || !(await verifyPassword(password, u.password_hash)))
return fail(reply, 401, 'onbekende naam of verkeerd wachtwoord'); return fail(reply, 401, 'onbekende naam of verkeerd wachtwoord');
// account is nu "in gebruik": leerling-wachtwoord niet langer inzichtelijk
await pool.query('UPDATE users SET last_login_at = now(), password_plain = NULL WHERE id = $1', [u.id]);
const token = await createSession(pool, u.id); const token = await createSession(pool, u.id);
return { token, user: publicUser(u) }; return { token, user: publicUser(u) };
}); });
@ -66,13 +68,14 @@ export default async function api(app) {
// Account activeren met een koppelcode (groepsleiding/beheerders) // Account activeren met een koppelcode (groepsleiding/beheerders)
app.post('/auth/link', async (req, reply) => { app.post('/auth/link', async (req, reply) => {
const { code, password } = req.body ?? {}; const { code, password } = req.body ?? {};
if (!code || !password || password.length < 6) if (!code) return fail(reply, 400, 'koppelcode verplicht');
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 r = await pool.query('SELECT * FROM users WHERE link_code = $1', [code.trim().toUpperCase()]);
const u = r.rows[0]; const u = r.rows[0];
if (!u) return fail(reply, 404, 'koppelcode onbekend of al gebruikt'); if (!u) return fail(reply, 404, 'koppelcode onbekend of al gebruikt');
const err = checkPassword(password, u.role);
if (err) return fail(reply, 400, err);
const hash = await hashPassword(password); const hash = await hashPassword(password);
await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [hash, u.id]); await pool.query('UPDATE users SET password_hash = $1, link_code = NULL, last_login_at = now() WHERE id = $2', [hash, u.id]);
const token = await createSession(pool, u.id); const token = await createSession(pool, u.id);
return { token, user: publicUser({ ...u, password_hash: hash, link_code: null }) }; return { token, user: publicUser({ ...u, password_hash: hash, link_code: null }) };
}); });
@ -92,7 +95,8 @@ export default async function api(app) {
app.patch('/auth/password', async (req, reply) => { app.patch('/auth/password', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']); need(req, reply, ['super', 'admin', 'teacher']);
const { password } = req.body ?? {}; const { password } = req.body ?? {};
if (!password || password.length < 6) return fail(reply, 400, 'minimaal 6 tekens'); const err = checkPassword(password, req.user.role);
if (err) return fail(reply, 400, err);
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2',
[await hashPassword(password), req.user.id]); [await hashPassword(password), req.user.id]);
return { ok: true }; return { ok: true };
@ -162,6 +166,13 @@ export default async function api(app) {
return { ok: true }; return { ok: true };
}); });
// Genereer een voorstel-wachtwoord (niet opgeslagen) voor in het aanmaakformulier.
app.get('/admin/genpw', async (req, reply) => {
need(req, reply, ['super', 'admin', 'teacher']);
const role = req.query.role === 'pupil' ? 'pupil' : 'staff';
return { password: role === 'pupil' ? pupilPassword() : staffPassword() };
});
// ---- gebruikers --------------------------------------------------------------- // ---- gebruikers ---------------------------------------------------------------
// Lijst: super ziet alles (optioneel per school), admin/teacher de eigen school. // Lijst: super ziet alles (optioneel per school), admin/teacher de eigen school.
app.get('/admin/users', async (req, reply) => { app.get('/admin/users', async (req, reply) => {
@ -178,8 +189,10 @@ export default async function api(app) {
return { users: rows.map((u) => ({ return { users: rows.map((u) => ({
...publicUser(u), ...publicUser(u),
linkCode: u.link_code || null, linkCode: u.link_code || null,
// leerlingwachtwoorden zijn inzichtelijk voor groepsleiding en beheer // leerlingwachtwoord is alleen inzichtelijk zolang het account nog niet
// gebruikt is (na de eerste keer inloggen wordt password_plain gewist)
password: canSeePw && u.role === 'pupil' ? u.password_plain : undefined, password: canSeePw && u.role === 'pupil' ? u.password_plain : undefined,
everLoggedIn: !!u.last_login_at,
})) }; })) };
}); });
@ -198,12 +211,26 @@ export default async function api(app) {
try { try {
if (role === 'pupil') { if (role === 'pupil') {
const pw = b.password || pupilPassword(); const pw = b.password || pupilPassword();
const err = checkPassword(pw, 'pupil');
if (err) return fail(reply, 400, err);
const r = await pool.query( const r = await pool.query(
`INSERT INTO users (username, role, school_id, class_id, display_name, password_hash, password_plain) `INSERT INTO users (username, role, school_id, class_id, display_name, password_hash, password_plain)
VALUES ($1,'pupil',$2,$3,$4,$5,$6) RETURNING *`, VALUES ($1,'pupil',$2,$3,$4,$5,$6) RETURNING *`,
[username, schoolId, b.classId ?? null, b.displayName || username, await hashPassword(pw), pw]); [username, schoolId, b.classId ?? null, b.displayName || username, await hashPassword(pw), pw]);
return { user: { ...publicUser(r.rows[0]), password: pw } }; return { user: { ...publicUser(r.rows[0]), password: pw } };
} }
// groepsleiding/beheer: ofwel meteen een wachtwoord (zelf gekozen of
// gegenereerd), ofwel de klassieke koppelcode om zelf te activeren.
if (b.password || b.generatePassword) {
const pw = b.password || staffPassword();
const err = checkPassword(pw, role);
if (err) return fail(reply, 400, err);
const r = await pool.query(
`INSERT INTO users (username, role, school_id, display_name, password_hash)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[username, role, schoolId, b.displayName || username, await hashPassword(pw)]);
return { user: { ...publicUser(r.rows[0]), password: pw } };
}
const code = newLinkCode(); const code = newLinkCode();
const r = await pool.query( const r = await pool.query(
`INSERT INTO users (username, role, school_id, display_name, link_code) `INSERT INTO users (username, role, school_id, display_name, link_code)
@ -229,9 +256,21 @@ export default async function api(app) {
if (b.displayName) await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName, u.id]); 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') if (b.classId !== undefined && u.role === 'pupil')
await pool.query('UPDATE users SET class_id = $1 WHERE id = $2', [b.classId, u.id]); await pool.query('UPDATE users SET class_id = $1 WHERE id = $2', [b.classId, u.id]);
if (b.password && u.role === 'pupil') if (b.password && u.role === 'pupil') {
await pool.query('UPDATE users SET password_plain = $1, password_hash = $2 WHERE id = $3', const err = checkPassword(b.password, 'pupil');
if (err) return fail(reply, 400, err);
await pool.query('UPDATE users SET password_plain = $1, password_hash = $2, last_login_at = NULL WHERE id = $3',
[b.password, await hashPassword(b.password), u.id]); [b.password, await hashPassword(b.password), u.id]);
}
// groepsleiding/beheer: direct een nieuw wachtwoord zetten (zelf gekozen
// of gegenereerd) zonder de koppelcode-activatie opnieuw te doorlopen.
if ((b.password || b.generatePassword) && u.role !== 'pupil' && ['super', 'admin'].includes(req.user.role)) {
const pw = b.password || staffPassword();
const err = checkPassword(pw, u.role);
if (err) return fail(reply, 400, err);
await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [await hashPassword(pw), u.id]);
return { ok: true, password: pw };
}
if (b.newLinkCode && u.role !== 'pupil' && ['super', 'admin'].includes(req.user.role)) { if (b.newLinkCode && u.role !== 'pupil' && ['super', 'admin'].includes(req.user.role)) {
const code = newLinkCode(); const code = newLinkCode();
await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]); await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]);

View file

@ -43,6 +43,30 @@ export function pupilPassword() {
return `${w}${n}`; return `${w}${n}`;
} }
// Willekeurig wachtwoord voor groepsleiding/beheerders, altijd met minstens
// 1 letter en 1 cijfer zodat het meteen aan checkPassword() voldoet.
export function staffPassword() {
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const digits = '23456789';
const all = letters + digits;
const pick = (set, n) => Array.from(randomBytes(n)).map((b) => set[b % set.length]).join('');
return `${pick(letters, 1)}${pick(digits, 1)}${pick(all, 6)}`;
}
// Wachtwoordeisen: leerlingen kort en eenvoudig (worden voorgelezen door
// groepsleiding), groepsleiding/beheer strenger (eigen, onthouden wachtwoord).
export function checkPassword(password, role) {
if (!password) return 'wachtwoord verplicht';
if (role === 'pupil') {
if (password.length < 4) return 'wachtwoord: minimaal 4 tekens';
return null;
}
if (password.length < 8) return 'wachtwoord: minimaal 8 tekens';
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password))
return 'wachtwoord: minimaal 1 letter en 1 cijfer';
return null;
}
const SESSION_DAYS = 30; const SESSION_DAYS = 30;
export async function createSession(pool, userId) { export async function createSession(pool, userId) {