Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4
4 changed files with 189 additions and 15 deletions
|
|
@ -964,7 +964,10 @@
|
|||
.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-msg{
|
||||
min-height:20px; font-size:13.5px; font-weight:800; color:var(--green); margin:4px 0;
|
||||
display:flex; align-items:center; gap:8px; flex-wrap:wrap;
|
||||
}
|
||||
.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;
|
||||
|
|
@ -1001,4 +1004,21 @@
|
|||
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;}
|
||||
.am-copy{
|
||||
border:none; background:#e8eef6; border-radius:8px; padding:5px 8px;
|
||||
font:inherit; font-size:13px; cursor:pointer; color:var(--ink); line-height:1;
|
||||
}
|
||||
.am-copy:hover{background:#dbe5f1;}
|
||||
.am-reset{
|
||||
background:#f7fafd; border:2px solid #cdd9e8; border-radius:12px;
|
||||
padding:10px 12px; margin:6px 0; display:flex; flex-direction:column; gap:8px;
|
||||
}
|
||||
.am-reset-head{display:flex; align-items:center; justify-content:space-between; gap:8px;}
|
||||
.am-reset-list{display:flex; flex-direction:column; gap:5px; max-height:260px; overflow:auto;}
|
||||
.am-reset-row{
|
||||
display:flex; gap:8px; align-items:center; background:#fff;
|
||||
border-radius:8px; padding:5px 8px; flex-wrap:wrap;
|
||||
}
|
||||
.am-reset-pw{font-weight:800; font-family:monospace; font-size:13px; color:var(--green);}
|
||||
body.hc .am-row,body.hc .am-chip{border:1.5px solid #000; background:#fff;}
|
||||
body.hc .am-reset,body.hc .am-reset-row{border:1.5px solid #000; background:#fff;}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
const modal = document.getElementById("adminModal");
|
||||
const btn = document.getElementById("btnAdmin");
|
||||
let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null;
|
||||
/* resultaat van een laatste multireset (klas/school): blijft zichtbaar
|
||||
totdat de beheerder het venster zelf sluit, zodat wachtwoorden niet
|
||||
meteen weer verdwijnen na een reload() */
|
||||
let resetResults = null;
|
||||
|
||||
const esc = s => String(s ?? "").replace(/&/g,"&").replace(/</g,"<");
|
||||
const h = (tag, cls, text)=>{
|
||||
|
|
@ -14,7 +18,36 @@
|
|||
if(text != null) el.textContent = text;
|
||||
return el;
|
||||
};
|
||||
const msg = t => { const m = modal.querySelector(".am-msg"); if(m) m.textContent = t; };
|
||||
/* wachtwoorden/koppelcodes zijn maar eenmalig zichtbaar - een kopieerknop
|
||||
zorgt dat ze veilig bewaard kunnen worden voordat het scherm ververst */
|
||||
async function copyText(btn, text){
|
||||
try{ await navigator.clipboard.writeText(text); }
|
||||
catch(e){
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
|
||||
document.body.appendChild(ta); ta.select();
|
||||
try{ document.execCommand("copy"); }catch(_){ /* niets meer te doen */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = "✅";
|
||||
setTimeout(()=>{ btn.textContent = orig; }, 1200);
|
||||
}
|
||||
function copyBtn(text, cls){
|
||||
const b = h("button", cls || "am-copy", "📋");
|
||||
b.type = "button";
|
||||
b.title = T("amCopy");
|
||||
b.addEventListener("click", e=>{ e.preventDefault(); if(text) copyText(b, text); });
|
||||
return b;
|
||||
}
|
||||
const msg = (t, copyValue) => {
|
||||
const m = modal.querySelector(".am-msg");
|
||||
if(!m) return;
|
||||
m.innerHTML = "";
|
||||
if(!t) return;
|
||||
m.appendChild(document.createTextNode(t));
|
||||
if(copyValue) m.appendChild(copyBtn(copyValue));
|
||||
};
|
||||
|
||||
btn.addEventListener("click", async ()=>{
|
||||
if(!currentUser || currentUser.role==="pupil") return;
|
||||
|
|
@ -23,7 +56,7 @@
|
|||
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"); });
|
||||
document.addEventListener("userchange", ()=>{ if(!currentUser){ wrap.classList.remove("open"); resetResults = null; } });
|
||||
|
||||
async function reload(){
|
||||
try{
|
||||
|
|
@ -69,6 +102,11 @@
|
|||
catch(e){ msg(e.message); }
|
||||
});
|
||||
row.appendChild(pw);
|
||||
const pwCopy = h("button","am-copy","📋");
|
||||
pwCopy.type = "button";
|
||||
pwCopy.title = T("amCopy");
|
||||
pwCopy.addEventListener("click", e=>{ e.preventDefault(); if(pw.value) copyText(pwCopy, pw.value); });
|
||||
row.appendChild(pwCopy);
|
||||
if(u.everLoggedIn){
|
||||
const gen = h("button","am-btn","🎲");
|
||||
gen.title = T("amGenerate");
|
||||
|
|
@ -76,8 +114,8 @@
|
|||
try{
|
||||
const { password } = await api("/admin/genpw?role=pupil");
|
||||
await api("/admin/users/"+u.id, {method:"PATCH", body:{password}});
|
||||
msg(`${T("amPwSet")} ${password}`);
|
||||
reload();
|
||||
await reload();
|
||||
msg(`${T("amPwSet")} ${password}`, password);
|
||||
}catch(e){ msg(e.message); }
|
||||
});
|
||||
row.appendChild(gen);
|
||||
|
|
@ -93,8 +131,8 @@
|
|||
nc.addEventListener("click", async ()=>{
|
||||
try{
|
||||
const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}});
|
||||
msg(`${T("amCreated")} ${r.linkCode}`);
|
||||
reload();
|
||||
await reload();
|
||||
msg(`${T("amCreated")} ${r.linkCode}`, r.linkCode);
|
||||
}catch(e){ msg(e.message); }
|
||||
});
|
||||
row.appendChild(nc);
|
||||
|
|
@ -103,8 +141,8 @@
|
|||
np.addEventListener("click", async ()=>{
|
||||
try{
|
||||
const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}});
|
||||
msg(`${T("amPwSet")} ${r.password}`);
|
||||
reload();
|
||||
await reload();
|
||||
msg(`${T("amPwSet")} ${r.password}`, r.password);
|
||||
}catch(e){ msg(e.message); }
|
||||
});
|
||||
row.appendChild(np);
|
||||
|
|
@ -161,20 +199,54 @@
|
|||
if(currentUser.role==="super" && role!=="super") body.school = +selSchool;
|
||||
try{
|
||||
const r = await api("/admin/users", { body });
|
||||
name.value = "";
|
||||
if(pwInp) pwInp.value = "";
|
||||
await reload();
|
||||
msg(role==="pupil"
|
||||
? `${T("amPupilMade")} ${r.user.password}`
|
||||
: r.user.password
|
||||
? `${T("amPwSet")} ${r.user.password}`
|
||||
: `${T("amCreated")} ${r.user.linkCode}`);
|
||||
name.value = "";
|
||||
if(pwInp) pwInp.value = "";
|
||||
reload();
|
||||
: `${T("amCreated")} ${r.user.linkCode}`,
|
||||
r.user.password || r.user.linkCode);
|
||||
}catch(e){ msg(e.message); }
|
||||
});
|
||||
f.appendChild(go);
|
||||
return f;
|
||||
}
|
||||
|
||||
/* paneel met het resultaat van een multireset (klas/school): een lijst
|
||||
nieuwe leerlingwachtwoorden die stuk voor stuk of allemaal ineens
|
||||
gekopieerd kunnen worden. Blijft staan tot de beheerder ✕ klikt. */
|
||||
function resetPanel(){
|
||||
if(!resetResults) return null;
|
||||
const box = h("div","am-reset");
|
||||
const head = h("div","am-reset-head");
|
||||
head.appendChild(h("strong",null, resetResults.title));
|
||||
const close = h("button","wclose","✕");
|
||||
close.addEventListener("click", ()=>{ resetResults = null; render(); });
|
||||
head.appendChild(close);
|
||||
box.appendChild(head);
|
||||
if(!resetResults.entries.length){
|
||||
box.appendChild(h("div","am-msg", T("amResetNone")));
|
||||
return box;
|
||||
}
|
||||
const all = resetResults.entries.map(en=>`${en.displayName}\t${en.password}`).join("\n");
|
||||
const copyAll = h("button","am-btn","📋 "+T("amCopyAll"));
|
||||
copyAll.type = "button";
|
||||
copyAll.addEventListener("click", e=>{ e.preventDefault(); copyText(copyAll, all); });
|
||||
box.appendChild(copyAll);
|
||||
const list = h("div","am-reset-list");
|
||||
resetResults.entries.forEach(en=>{
|
||||
const row = h("div","am-reset-row");
|
||||
row.appendChild(h("span","am-name", en.displayName));
|
||||
row.appendChild(h("span","am-reset-pw", en.password));
|
||||
row.appendChild(copyBtn(`${en.displayName}: ${en.password}`));
|
||||
list.appendChild(row);
|
||||
});
|
||||
box.appendChild(list);
|
||||
return box;
|
||||
}
|
||||
|
||||
function render(){
|
||||
modal.innerHTML = "";
|
||||
const head = h("div","am-head");
|
||||
|
|
@ -211,15 +283,30 @@
|
|||
}
|
||||
|
||||
modal.appendChild(h("div","am-msg"));
|
||||
const rp = resetPanel();
|
||||
if(rp) modal.appendChild(rp);
|
||||
|
||||
/* klassen (admin/super binnen een school) */
|
||||
const schoolChosen = currentUser.role!=="super" || !!selSchool;
|
||||
const canResetPw = ["super","admin"].includes(currentUser.role);
|
||||
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)){
|
||||
if(canResetPw){
|
||||
const rs = h("button","am-chipx","🔄");
|
||||
rs.title = T("amResetClass");
|
||||
rs.addEventListener("click", async ()=>{
|
||||
if(!confirm(`${T("amConfirmReset")} ${c.name}?`)) return;
|
||||
try{
|
||||
const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"});
|
||||
await reload();
|
||||
resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users };
|
||||
render();
|
||||
}catch(e){ msg(e.message); }
|
||||
});
|
||||
chip.appendChild(rs);
|
||||
const x = h("button","am-chipx","✕");
|
||||
x.addEventListener("click", async ()=>{
|
||||
try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); }
|
||||
|
|
@ -230,7 +317,7 @@
|
|||
cwrapEl.appendChild(chip);
|
||||
});
|
||||
modal.appendChild(cwrapEl);
|
||||
if(["super","admin"].includes(currentUser.role)){
|
||||
if(canResetPw){
|
||||
const f = h("div","am-add");
|
||||
const inp = h("input","am-inp");
|
||||
inp.placeholder = T("amNamePh");
|
||||
|
|
@ -246,6 +333,22 @@
|
|||
});
|
||||
f.appendChild(go);
|
||||
modal.appendChild(f);
|
||||
|
||||
/* multireset: alle leerlingwachtwoorden van de hele school in één keer */
|
||||
const rsAll = h("button","am-btn","🔄 "+T("amResetSchool"));
|
||||
rsAll.type = "button";
|
||||
rsAll.addEventListener("click", async ()=>{
|
||||
const schoolId = currentUser.role==="super" ? selSchool : currentUser.schoolId;
|
||||
if(!schoolId) return;
|
||||
if(!confirm(T("amConfirmResetSchool"))) return;
|
||||
try{
|
||||
const r = await api("/admin/schools/"+schoolId+"/reset-passwords", {method:"POST"});
|
||||
await reload();
|
||||
resetResults = { title: T("amResetSchool"), entries: r.users };
|
||||
render();
|
||||
}catch(e){ msg(e.message); }
|
||||
});
|
||||
modal.appendChild(rsAll);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,11 @@ const I18N = {
|
|||
amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:",
|
||||
amPwOptional:"wachtwoord (leeg = koppelcode)",
|
||||
amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?",
|
||||
amCopy:"kopieer", amCopyAll:"kopieer alles",
|
||||
amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden",
|
||||
amConfirmReset:"Alle leerlingwachtwoorden van deze klas vernieuwen?",
|
||||
amConfirmResetSchool:"Alle leerlingwachtwoorden van deze hele school vernieuwen? Dit kan niet ongedaan worden gemaakt.",
|
||||
amResetNone:"Geen leerlingen om te resetten.",
|
||||
errExists:"Deze naam bestaat al.",
|
||||
errName:"Naam: 2–20 letters of cijfers.",
|
||||
errPass:"Wachtwoord: minimaal 4 tekens.",
|
||||
|
|
@ -251,6 +256,11 @@ const I18N = {
|
|||
amNewPw:"new password", amPwSet:"Password set:",
|
||||
amPwOptional:"password (empty = link code)",
|
||||
amConfirmDel:"Are you sure you want to delete this user?",
|
||||
amCopy:"copy", amCopyAll:"copy all",
|
||||
amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords",
|
||||
amConfirmReset:"Renew all pupil passwords for this class?",
|
||||
amConfirmResetSchool:"Renew all pupil passwords for this whole school? This cannot be undone.",
|
||||
amResetNone:"No pupils to reset.",
|
||||
errExists:"This name already exists.",
|
||||
errName:"Name: 2–20 letters or digits.",
|
||||
errPass:"Password: at least 4 characters.",
|
||||
|
|
|
|||
41
src/api.js
41
src/api.js
|
|
@ -128,6 +128,25 @@ export default async function api(app) {
|
|||
return { ok: true };
|
||||
});
|
||||
|
||||
// Multireset: alle leerlingwachtwoorden van een hele school in één keer
|
||||
// vernieuwen. Alleen schoolbeheerder (eigen school) en overkoepelend beheerder.
|
||||
app.post('/admin/schools/:id/reset-passwords', async (req, reply) => {
|
||||
need(req, reply, ['super', 'admin']);
|
||||
const schoolId = req.params.id;
|
||||
if (req.user.role !== 'super' && Number(schoolId) !== Number(req.user.school_id))
|
||||
return fail(reply, 403, 'geen rechten');
|
||||
const pupils = (await pool.query(
|
||||
"SELECT * FROM users WHERE school_id = $1 AND role = 'pupil' ORDER BY lower(username)", [schoolId])).rows;
|
||||
const users = [];
|
||||
for (const p of pupils) {
|
||||
const pw = pupilPassword();
|
||||
await pool.query('UPDATE users SET password_plain = $1, password_hash = $2, last_login_at = NULL WHERE id = $3',
|
||||
[pw, await hashPassword(pw), p.id]);
|
||||
users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw });
|
||||
}
|
||||
return { users };
|
||||
});
|
||||
|
||||
// ---- klassen -----------------------------------------------------------------
|
||||
app.get('/admin/classes', async (req, reply) => {
|
||||
need(req, reply, ['super', 'admin', 'teacher']);
|
||||
|
|
@ -166,6 +185,28 @@ export default async function api(app) {
|
|||
return { ok: true };
|
||||
});
|
||||
|
||||
// Multireset: alle leerlingwachtwoorden van één klas in één keer vernieuwen.
|
||||
// Alleen schoolbeheerder (eigen school) en overkoepelend beheerder - niet
|
||||
// de groepsleiding, om per ongeluk een hele klas resetten te voorkomen.
|
||||
app.post('/admin/classes/:id/reset-passwords', async (req, reply) => {
|
||||
need(req, reply, ['super', 'admin']);
|
||||
const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]);
|
||||
const c = r.rows[0];
|
||||
if (!c) return fail(reply, 404, 'klas onbekend');
|
||||
if (req.user.role !== 'super' && Number(c.school_id) !== Number(req.user.school_id))
|
||||
return fail(reply, 403, 'geen rechten');
|
||||
const pupils = (await pool.query(
|
||||
"SELECT * FROM users WHERE class_id = $1 AND role = 'pupil' ORDER BY lower(username)", [c.id])).rows;
|
||||
const users = [];
|
||||
for (const p of pupils) {
|
||||
const pw = pupilPassword();
|
||||
await pool.query('UPDATE users SET password_plain = $1, password_hash = $2, last_login_at = NULL WHERE id = $3',
|
||||
[pw, await hashPassword(pw), p.id]);
|
||||
users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw });
|
||||
}
|
||||
return { users };
|
||||
});
|
||||
|
||||
// Genereer een voorstel-wachtwoord (niet opgeslagen) voor in het aanmaakformulier.
|
||||
app.get('/admin/genpw', async (req, reply) => {
|
||||
need(req, reply, ['super', 'admin', 'teacher']);
|
||||
|
|
|
|||
Loading…
Reference in a new issue