diff --git a/public/js/admin.js b/public/js/admin.js
index 1c96378..b18bc6e 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -50,7 +50,7 @@
/* welke rollen de ingelogde gebruiker mag aanmaken: unie over hoofdrol + extra rollen */
const creatableRoles = () =>
- [...new Set([currentUser.role, ...(currentUser.extraRoles||[])].flatMap(r => CREATABLE_ROLES[r] || []))];
+ [...new Set(activeRoles().flatMap(r => CREATABLE_ROLES[r] || []))];
/* wie er als groepsleiding aan een klas gekoppeld kan staan - zelfde rollenset
als de server accepteert in POST /admin/classes/:id/teachers (niet alleen
@@ -238,7 +238,7 @@
keywords:()=>[T("amLevelYearGroup"), T("amLevelLevel"), "niveau","level","groep","niveaugroep",
"leerjaar","year group","differentiatie"] },
{ tab:"opslag", icon:"🖼", group:"system", order:32, labelKey:"stOpslag", subKey:"stOpslagSub",
- show:()=>currentUser.role==="super",
+ show:()=>activeRole()==="super",
keywords:()=>["opslag","storage","afbeeldingen","images","quota"] },
];
const adminTabDefs = ()=>ADMIN_SECTIONS.filter(d=>d.show()).map(d=>[d.tab, T(d.labelKey)]);
@@ -360,7 +360,7 @@
/* school koppelen/losmaken (systeemmanager): losse accounts terugzetten
naar hun school, of bewust losmaken - super/ouder zijn per definitie
schoolloos en vallen hierbuiten */
- if(currentUser.role==="super" && ["teacher","admin","pupil"].includes(u.role)){
+ if(activeRole()==="super" && ["teacher","admin","pupil"].includes(u.role)){
const ss = h("select","am-sel");
ss.appendChild(new Option(T("amNoSchool"), ""));
SCHOOLS.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id)));
@@ -1202,7 +1202,7 @@
(alleen systeemmanager), wachtwoorden resetten, verwijderen. */
function classActionsPanel(c, otherSchools){
const box = h("div","am-kebab-panel");
- if(currentUser.role==="super" && otherSchools.length){
+ if(activeRole()==="super" && otherSchools.length){
const ss = h("select","am-sel");
ss.appendChild(new Option(T("amMoveToSchool"), ""));
otherSchools.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id)));
@@ -1425,7 +1425,7 @@
const school = SCHOOLS.find(s=>String(s.id)===String(selSchool));
const canManageClasses = can("classes.manage");
- if(currentUser.role==="super" && school){
+ if(activeRole()==="super" && school){
const titleRow = h("div","am-kebab-row");
titleRow.appendChild(h("h2","am-nav-title", school.name));
const kebab = h("button","am-kebab","⋯");
@@ -1602,7 +1602,7 @@
validateSheet();
panel.classList.add("am-nav-panel");
- if(currentUser.role==="teacher"){
+ if(activeRole()==="teacher"){
const header = h("div","am-nav-header");
const searchInp = h("input","am-inp am-filter");
searchInp.type = "search";
diff --git a/public/js/core.js b/public/js/core.js
index ec01380..fc77f79 100644
--- a/public/js/core.js
+++ b/public/js/core.js
@@ -2,7 +2,7 @@
"use strict";
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */
-const VERSION = "0.4.38-beta";
+const VERSION = "0.4.39-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@@ -233,7 +233,7 @@ const I18N = {
stOpenSection:"open dit onderdeel",
stGroupPersonal:"Persoonlijk", stGroupSchool:"School", stGroupSystem:"Systeem",
stAccountSub:"Je gegevens, wachtwoord en reservekopieën.",
- stViewSub:"Weergave op dit apparaat: thema, taal en menubalk.",
+ stViewSub:"Weergave op dit apparaat: rolvoorbeeld, thema, taal en menubalk.",
wikiTitle:"Curriculumwiki",
wikiSub:"Nieuwe SLO-kerndoelen voor het primair onderwijs, per leergebied.",
wikiKicker:"Primair onderwijs · juli 2026",
@@ -284,6 +284,11 @@ const I18N = {
vwContrast:"Hoog contrast", vwOn:"Aan", vwOff:"Uit", vwLang:"Taal",
vwTbar:"Menubalk", vwTop:"Boven", vwLeft:"Links", vwRight:"Rechts",
vwMotion:"Animaties", vwGlass:"Glas-effect",
+ vwRole:"Weergave als", vwRoleSystem:"Systeem", vwRoleAdmin:"Schoolbeheerder", vwRoleTeacher:"Leerkracht", vwRoleParent:"Ouder", vwRolePupil:"Leerling",
+ vwRoleNote:"Alleen de interface verandert. Je blijft ingelogd als systeemmanager; serverrechten en gegevens wijzigen niet.",
+ vwPreview:"Voorbeeldweergave", vwPreviewReset:"Terug naar systeem",
+ vwParentPreview:"Voorbeeld van het ouderportaal. Echte kindgegevens en acties zijn in deze weergave uitgeschakeld.",
+ vwPupilPreview:"Voorbeeld van de leerlingomgeving met de taal- en rekenwidgets van het huidige bord. Bediening en voortgang zijn uitgeschakeld.",
vwNote:"Deze voorkeuren gelden voor dit apparaat.",
pwChanged:"Wachtwoord gewijzigd ✓", pwCurrentPh:"huidig wachtwoord",
namePh:"naam", passPh:"wachtwoord",
@@ -606,7 +611,7 @@ const I18N = {
stOpenSection:"open this section",
stGroupPersonal:"Personal", stGroupSchool:"School", stGroupSystem:"System",
stAccountSub:"Your details, password and backups.",
- stViewSub:"Display on this device: theme, language and toolbar.",
+ stViewSub:"Display on this device: role preview, theme, language and toolbar.",
wikiTitle:"Curriculum wiki",
wikiSub:"New SLO attainment targets for Dutch primary education, grouped by learning area.",
wikiKicker:"Primary education · July 2026",
@@ -657,6 +662,11 @@ const I18N = {
vwContrast:"High contrast", vwOn:"On", vwOff:"Off", vwLang:"Language",
vwTbar:"Menu bar", vwTop:"Top", vwLeft:"Left", vwRight:"Right",
vwMotion:"Animations", vwGlass:"Glass effect",
+ vwRole:"View as", vwRoleSystem:"System", vwRoleAdmin:"School administrator", vwRoleTeacher:"Teacher", vwRoleParent:"Parent", vwRolePupil:"Pupil",
+ vwRoleNote:"Only the interface changes. You remain signed in as system manager; server permissions and data do not change.",
+ vwPreview:"Preview mode", vwPreviewReset:"Return to system",
+ vwParentPreview:"Preview of the parent portal. Real child data and actions are disabled in this view.",
+ vwPupilPreview:"Preview of the pupil environment using language and maths widgets from the current board. Controls and progress are disabled.",
vwNote:"These preferences apply to this device.",
pwChanged:"Password changed ✓", pwCurrentPh:"current password",
namePh:"name", passPh:"password",
@@ -863,8 +873,10 @@ function canPersist(){
kind-accounts); schoolleerlingen ook niet (zij zien de toewijzing van de
leerkracht). Thuiskinderen (leerling zónder school, ouderportaal) hebben
wél een eigen whiteboard met eigen opslag. */
- if(!currentUser || currentUser.role === "parent") return false;
- if(currentUser.role === "pupil") return !currentUser.schoolId;
+ if(!currentUser) return false;
+ const role=typeof activeRole==="function" ? activeRole() : currentUser.role;
+ if(role==="parent") return false;
+ if(role==="pupil") return currentUser.role==="pupil" && !currentUser.schoolId;
return true;
}
/* welke secties er sinds de laatste geslaagde save zijn gewijzigd; een save
diff --git a/public/js/explorer.js b/public/js/explorer.js
index 862f79a..8beaf7a 100644
--- a/public/js/explorer.js
+++ b/public/js/explorer.js
@@ -425,7 +425,7 @@ function withBranch(rootLabel, main, sub, opts){
==========================================================*/
function withSharedBranches(kind, own, hooks){
/* gasten en leerlingen zien alleen de eigen boom */
- if(!currentUser || currentUser.role === "pupil") return own;
+ if(!currentUser || activeRole() === "pupil") return own;
const GLOBAL = "🌐 " + T("shGlobal");
const SCHOOL = "🏫 " + T("shSchool");
const branches = [GLOBAL];
@@ -629,7 +629,7 @@ function withSharedBranches(kind, own, hooks){
zonder selectie op de map waar je nu in staat (alle items eronder) */
lockLabel(path){
if(!isShared(path) || scopeOf(path[0]) !== "school") return null;
- const roles = [currentUser.role, ...(currentUser.extraRoles||[])];
+ const roles = activeRoles();
if(!roles.includes("admin") && !roles.includes("super")) return null;
return "🔒 " + T("exLock");
},
diff --git a/public/js/permissions.js b/public/js/permissions.js
index b5790fc..31ab48d 100644
--- a/public/js/permissions.js
+++ b/public/js/permissions.js
@@ -26,10 +26,24 @@ const CREATABLE_ROLES = {
teacher: ['pupil'],
};
-/* can(action) gebruikt de ingelogde gebruiker (currentUser, uit core.js) - hoofdrol
- plus eventuele extra stafrollen (currentUser.extraRoles) */
-function can(action){
- if(!currentUser) return false;
- const roles = [currentUser.role, ...(currentUser.extraRoles||[])];
- return (PERMISSIONS[action] || []).some(r => roles.includes(r));
+/* Een systeemmanager kan uitsluitend de clientweergave van een andere rol
+ simuleren. Het HttpOnly-account en alle serverautorisatie blijven ongewijzigd. */
+const VIEW_ROLES = ["super","admin","teacher","parent","pupil"];
+let VIEW_AS_ROLE = "super";
+function setActiveViewRole(role){
+ VIEW_AS_ROLE = VIEW_ROLES.includes(role) ? role : "super";
+}
+function activeRole(){
+ if(!currentUser) return null;
+ return currentUser.role==="super" ? VIEW_AS_ROLE : currentUser.role;
+}
+function activeRoles(){
+ if(!currentUser) return [];
+ const role=activeRole();
+ return role===currentUser.role ? [currentUser.role,...(currentUser.extraRoles||[])] : [role];
+}
+/* can(action) gebruikt het actieve weergaveprofiel. De server blijft iedere
+ aanvraag vanzelf tegen het echte account autoriseren. */
+function can(action){
+ return (PERMISSIONS[action] || []).some(r => activeRoles().includes(r));
}
diff --git a/public/js/pupil.js b/public/js/pupil.js
index 1c34223..ce44286 100644
--- a/public/js/pupil.js
+++ b/public/js/pupil.js
@@ -18,7 +18,7 @@ function pupilAllowedDefs(){
return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat));
}
-async function renderPupilWidgets(widgets, mode){
+async function renderPupilWidgets(widgets, mode, preview=false){
pupilView.innerHTML = "";
const defs = pupilAllowedDefs();
const watch = mode === "kijken";
@@ -38,7 +38,7 @@ async function renderPupilWidgets(widgets, mode){
- één keer per vakgebied dat hier daadwerkelijk voorkomt, niet per widget. */
const subjects = [...new Set(usable.map(x => x.def.cat))];
const levels = {};
- await Promise.all(subjects.map(async subject=>{
+ if(!preview)await Promise.all(subjects.map(async subject=>{
try{ levels[subject] = await api("/my/level?subject="+subject); }
catch(e){ levels[subject] = null; }
}));
diff --git a/public/js/role-preview.js b/public/js/role-preview.js
new file mode 100644
index 0000000..4e0609a
--- /dev/null
+++ b/public/js/role-preview.js
@@ -0,0 +1,108 @@
+/* teach - veilige rolvoorbeeldweergave voor systeemmanagers.
+ Alleen de clientinterface en zichtbare menu's veranderen. De sessiecookie,
+ currentUser en serverautorisatie blijven altijd van de systeemmanager. */
+"use strict";
+
+(function(){
+ const roleRow=document.getElementById("superViewRole");
+ const roleSeg=document.getElementById("segViewRole");
+ const banner=document.getElementById("rolePreviewBanner");
+ const bannerLabel=document.getElementById("rolePreviewLabel");
+ const resetButton=banner.querySelector("button");
+ const ROLE_KEYS={
+ super:"vwRoleSystem",admin:"vwRoleAdmin",teacher:"vwRoleTeacher",
+ parent:"vwRoleParent",pupil:"vwRolePupil"
+ };
+ let renderSequence=0;
+
+ const isSystemManager=()=>!!currentUser&¤tUser.role==="super";
+ const roleLabel=role=>T(ROLE_KEYS[role]||ROLE_KEYS.super);
+
+ function storedRole(){
+ try{
+ const value=localStorage.getItem("teach.superViewRole");
+ return VIEW_ROLES.includes(value)?value:"super";
+ }catch(e){return"super";}
+ }
+ function storeRole(role){
+ try{localStorage.setItem("teach.superViewRole",role);}catch(e){}
+ }
+ function previewNotice(text){
+ const note=document.createElement("aside");
+ note.className="role-preview-note";
+ const strong=document.createElement("strong");
+ strong.textContent=T("vwPreview")+": ";
+ note.append(strong,document.createTextNode(text));
+ return note;
+ }
+ function clearPreviewSurface(){
+ renderSequence++;
+ document.body.classList.remove("role-preview-active","role-preview-parent","role-preview-pupil");
+ banner.hidden=true;
+ if(isSystemManager()){
+ stopPupilView();
+ stopParentView();
+ }
+ }
+ function syncControls(){
+ const enabled=isSystemManager();
+ roleRow.hidden=!enabled;
+ const role=enabled?activeRole():"super";
+ roleSeg.querySelectorAll("button").forEach(button=>{
+ const selected=button.dataset.role===role;
+ button.classList.toggle("active",selected);
+ button.setAttribute("aria-pressed",String(selected));
+ });
+ banner.hidden=!enabled||role==="super";
+ bannerLabel.textContent=enabled&&role!=="super"?roleLabel(role):"";
+ }
+ async function renderPreviewSurface(){
+ const sequence=++renderSequence;
+ if(!isSystemManager()){clearPreviewSurface();syncControls();return;}
+ const role=activeRole();
+ document.body.classList.remove("role-preview-active","role-preview-parent","role-preview-pupil");
+ stopPupilView();
+ stopParentView();
+ if(role==="parent"){
+ document.body.classList.add("role-preview-active","role-preview-parent","parent-mode");
+ renderParentView([]);
+ const inner=parentView.querySelector(".parent-inner");
+ if(inner)inner.prepend(previewNotice(T("vwParentPreview")));
+ parentView.querySelectorAll("button,input,select,textarea").forEach(control=>{control.disabled=true;});
+ }else if(role==="pupil"){
+ document.body.classList.add("role-preview-active","role-preview-pupil","pupil-mode");
+ const snapshot=serializeBoard();
+ await renderPupilWidgets(snapshot.widgets||[],"kijken",true);
+ if(sequence!==renderSequence)return;
+ pupilView.prepend(previewNotice(T("vwPupilPreview")));
+ }
+ syncControls();
+ }
+ function chooseRole(role){
+ if(!isSystemManager()||!VIEW_ROLES.includes(role))return;
+ setActiveViewRole(role);
+ storeRole(role);
+ updateUserUI();
+ document.dispatchEvent(new CustomEvent("userchange"));
+ }
+
+ roleSeg.querySelectorAll("button").forEach(button=>{
+ button.addEventListener("click",()=>chooseRole(button.dataset.role));
+ });
+ resetButton.addEventListener("click",()=>chooseRole("super"));
+ document.addEventListener("userchange",()=>{
+ if(isSystemManager()){
+ const saved=storedRole();
+ if(activeRole()!==saved)setActiveViewRole(saved);
+ }else{
+ setActiveViewRole("super");
+ }
+ syncControls();
+ renderPreviewSurface();
+ });
+ document.addEventListener("langchange",()=>{
+ syncControls();
+ if(isSystemManager()&&["parent","pupil"].includes(activeRole()))renderPreviewSurface();
+ });
+ syncControls();
+})();
diff --git a/public/js/settings.js b/public/js/settings.js
index f9e4f3d..1e0e0ef 100644
--- a/public/js/settings.js
+++ b/public/js/settings.js
@@ -44,7 +44,7 @@ function registerSettingsSection(sec){ ST_SECTIONS.push(sec); }
label:()=>T("stView"), sub:()=>T("stViewSub"),
visible:()=>true,
activate(){ showDomSection("view"); },
- keywords:()=>[T("vwTheme"), T("vwContrast"), T("vwLang"), T("vwTbar"), T("vwMotion"), T("vwGlass"),
+ keywords:()=>[T("vwRole"), T("vwRoleSystem"), T("vwRoleAdmin"), T("vwRoleTeacher"), T("vwRoleParent"), T("vwRolePupil"), T("vwTheme"), T("vwContrast"), T("vwLang"), T("vwTbar"), T("vwMotion"), T("vwGlass"),
"thema","theme","donker","dark","licht","light","contrast","taal","language",
"menubalk","toolbar","animaties","animations","glas","glass"],
});
diff --git a/test/role-preview.test.js b/test/role-preview.test.js
new file mode 100644
index 0000000..6767712
--- /dev/null
+++ b/test/role-preview.test.js
@@ -0,0 +1,64 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {runInNewContext} from 'node:vm';
+
+test('alleen een systeemmanager kan een geldig actief weergaveprofiel projecteren',async()=>{
+ const source=await readFile('public/js/permissions.js','utf8');
+ const context={currentUser:{role:'super',extraRoles:[]}};
+ runInNewContext(source+';globalThis.api={setActiveViewRole,activeRole,activeRoles,can,VIEW_ROLES}',context);
+ const api=context.api;
+ assert.deepEqual(Array.from(api.VIEW_ROLES),['super','admin','teacher','parent','pupil']);
+ api.setActiveViewRole('parent');
+ assert.equal(api.activeRole(),'parent');
+ assert.equal(api.can('curriculum.view'),true);
+ assert.equal(api.can('admin.access'),false);
+ api.setActiveViewRole('pupil');
+ assert.equal(api.activeRole(),'pupil');
+ assert.equal(api.can('curriculum.view'),false);
+ api.setActiveViewRole('onbekend');
+ assert.equal(api.activeRole(),'super');
+ context.currentUser={role:'teacher',extraRoles:[]};
+ api.setActiveViewRole('parent');
+ assert.equal(api.activeRole(),'teacher');
+});
+
+test('Weergave bevat de vijf rollen en laadt de veilige voorbeeldmodule vóór de appstart',async()=>{
+ const [html,preview,core,css,version]=await Promise.all([
+ readFile('public/index.html','utf8'),
+ readFile('public/js/role-preview.js','utf8'),
+ readFile('public/js/core.js','utf8'),
+ readFile('public/css/teach.css','utf8'),
+ readFile('VERSION','utf8')
+ ]);
+ for(const role of ['super','admin','teacher','parent','pupil'])assert.match(html,new RegExp('data-role="'+role+'"'));
+ assert.match(html,/id="superViewRole" hidden/);
+ assert.ok(html.indexOf('js/role-preview.js'){
+ const [client,server,settings,admin,explorer]=await Promise.all([
+ readFile('public/js/permissions.js','utf8'),
+ readFile('src/permissions.js','utf8'),
+ readFile('public/js/settings.js','utf8'),
+ readFile('public/js/admin.js','utf8'),
+ readFile('public/js/explorer.js','utf8')
+ ]);
+ assert.match(client,/function activeRoles\(\)/);
+ assert.doesNotMatch(server,/VIEW_AS_ROLE|activeRole/);
+ assert.match(settings,/T\("vwRole"\)/);
+ assert.match(admin,/activeRoles\(\)\.flatMap/);
+ assert.match(admin,/show:\(\)=>activeRole\(\)==="super"/);
+ assert.match(explorer,/activeRole\(\) === "pupil"/);
+});