Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4
7 changed files with 177 additions and 15 deletions
5
db/003_password_state.sql
Normal file
5
db/003_password_state.sql
Normal 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;
|
||||||
|
|
@ -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;}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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: 2–20 letters of cijfers.",
|
errName:"Naam: 2–20 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: 2–20 letters or digits.",
|
errName:"Name: 2–20 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); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
55
src/api.js
55
src/api.js
|
|
@ -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]);
|
||||||
|
|
|
||||||
24
src/auth.js
24
src/auth.js
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue