diff --git a/pruef-ampel.png b/pruef-ampel.png index 8afa694f..f40f5e1a 100644 Binary files a/pruef-ampel.png and b/pruef-ampel.png differ diff --git a/pruef-buehne.png b/pruef-buehne.png index fc045827..f33340a2 100644 Binary files a/pruef-buehne.png and b/pruef-buehne.png differ diff --git a/pruef-checkliste.png b/pruef-checkliste.png index 676ea241..e2ff793e 100644 Binary files a/pruef-checkliste.png and b/pruef-checkliste.png differ diff --git a/pruef-content-computer.png b/pruef-content-computer.png index 0cddbfee..f89e6c73 100644 Binary files a/pruef-content-computer.png and b/pruef-content-computer.png differ diff --git a/pruef-content-handy.png b/pruef-content-handy.png index f8ac0975..cd976bf5 100644 Binary files a/pruef-content-handy.png and b/pruef-content-handy.png differ diff --git a/pruef-felder.png b/pruef-felder.png index aca985cd..fc60cb67 100644 Binary files a/pruef-felder.png and b/pruef-felder.png differ diff --git a/pruef-handy.png b/pruef-handy.png index 9d2565c9..51ed5bf5 100644 Binary files a/pruef-handy.png and b/pruef-handy.png differ diff --git a/pruef-kalender-computer.png b/pruef-kalender-computer.png index 2ca5b221..889447c6 100644 Binary files a/pruef-kalender-computer.png and b/pruef-kalender-computer.png differ diff --git a/pruef-kalender-handy.png b/pruef-kalender-handy.png index a179eac6..754dd5db 100644 Binary files a/pruef-kalender-handy.png and b/pruef-kalender-handy.png differ diff --git a/pruef-lesbarkeit-scouting.png b/pruef-lesbarkeit-scouting.png new file mode 100644 index 00000000..ab4ab271 Binary files /dev/null and b/pruef-lesbarkeit-scouting.png differ diff --git a/pruef-lesbarkeit-wissen.png b/pruef-lesbarkeit-wissen.png new file mode 100644 index 00000000..95eb76ee Binary files /dev/null and b/pruef-lesbarkeit-wissen.png differ diff --git a/pruef-merkzeichen.png b/pruef-merkzeichen.png index b9c4fd9e..8b531e8b 100644 Binary files a/pruef-merkzeichen.png and b/pruef-merkzeichen.png differ diff --git a/pruef-personen.png b/pruef-personen.png index 6bcf1bb5..87a428b5 100644 Binary files a/pruef-personen.png and b/pruef-personen.png differ diff --git a/pruef-protokoll-auf.png b/pruef-protokoll-auf.png index 8910d30b..afffbce8 100644 Binary files a/pruef-protokoll-auf.png and b/pruef-protokoll-auf.png differ diff --git a/pruef-protokoll-zu.png b/pruef-protokoll-zu.png index a8135111..75a39296 100644 Binary files a/pruef-protokoll-zu.png and b/pruef-protokoll-zu.png differ diff --git a/pruef-schulung.png b/pruef-schulung.png index 0ca184c1..41d9edb3 100644 Binary files a/pruef-schulung.png and b/pruef-schulung.png differ diff --git a/pruef-sprung.png b/pruef-sprung.png index d301de67..6b48e0bc 100644 Binary files a/pruef-sprung.png and b/pruef-sprung.png differ diff --git a/pruef-start-computer.png b/pruef-start-computer.png index 7192ec90..1f8341c4 100644 Binary files a/pruef-start-computer.png and b/pruef-start-computer.png differ diff --git a/pruef-start-handy.png b/pruef-start-handy.png index 35cfab4d..e84160bc 100644 Binary files a/pruef-start-handy.png and b/pruef-start-handy.png differ diff --git a/pruef-steckbrief.png b/pruef-steckbrief.png index 12d898ae..7f99264a 100644 Binary files a/pruef-steckbrief.png and b/pruef-steckbrief.png differ diff --git a/pruef-team.png b/pruef-team.png index 3a4a2d95..408676c2 100644 Binary files a/pruef-team.png and b/pruef-team.png differ diff --git a/pruef-uebersicht-computer.png b/pruef-uebersicht-computer.png index 68fe62fd..fbf2bda3 100644 Binary files a/pruef-uebersicht-computer.png and b/pruef-uebersicht-computer.png differ diff --git a/pruef-uebersicht-creator.png b/pruef-uebersicht-creator.png index 6916c891..a9ed3321 100644 Binary files a/pruef-uebersicht-creator.png and b/pruef-uebersicht-creator.png differ diff --git a/pruef-uebersicht-handy.png b/pruef-uebersicht-handy.png index 464aea70..89c93678 100644 Binary files a/pruef-uebersicht-handy.png and b/pruef-uebersicht-handy.png differ diff --git a/pruef-zustand.png b/pruef-zustand.png index 5e2af34d..41e29612 100644 Binary files a/pruef-zustand.png and b/pruef-zustand.png differ diff --git a/server/index.js b/server/index.js index b5123266..26d97659 100644 --- a/server/index.js +++ b/server/index.js @@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url"; import { gateMiddleware } from "./gate.js"; import { webdesignGate } from "./webdesign-gate.js"; import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js"; -import { workspaceRouter } from "./workspace.js"; +import { workspaceRouter, sichtSetzen } from "./workspace.js"; import { aufgabenRouter } from "./workspace-aufgaben.js"; import { personenRouter } from "./workspace-personen.js"; import { profilRouter } from "./workspace-profil.js"; @@ -89,6 +89,18 @@ app.use(webdesignGate); Es öffnet beim Laden keine Datenbank, und jede Route fängt ihre Fehler selbst ab (Begründung im Kopf von workspace.js). */ app.use(workspaceRouter); + +/* DIE SICHT EINES ANDEREN (01.09.2026) -- setzt req.sicht. + + Steht GLOBAL und vor allen Fachmodulen, damit sie ueberall zur + Verfuegung steht, ohne dass zwoelf Module dieselbe Zeile bekommen. + Sie prueft die Rolle selbst (nur DogFather) und faellt sonst auf die + eigene Sicht zurueck -- eine Middleware, die man vergessen kann, waere + ein Rechteloch mit Ansage. + + Nur fuer /workspace/api: Statische Dateien haben keine Sicht. */ +app.use("/workspace/api", sichtSetzen); + /* MUSS vor aufgabenRouter stehen: Der haengt eine Schranke ueber ALLE Pfade unter /workspace/api. Die Rueckmeldung der Sicherungskopie kommt aber von einem Skript ohne Sitzung und wuerde dort abgewiesen, bevor diff --git a/server/pruef-handy.mjs b/server/pruef-handy.mjs index 9f31fb56..427ba1e1 100644 --- a/server/pruef-handy.mjs +++ b/server/pruef-handy.mjs @@ -193,9 +193,17 @@ const messen = () => ({ return a ? (a.right <= window.innerWidth + 1 && a.left >= -1) : null; })(), /* 6. Schriftgroesse: unter 12 px liest niemand auf einem Handy. */ + /* NUR SICHTBARER TEXT. Ein ausgeblendetes Element hat weiterhin eine + Schriftgroesse -- die Pruefung meldete deshalb ein Schild, das auf + dem Handy per display:none gar nicht erscheint. Eine Pruefung, die + Unsichtbares anmahnt, gewoehnt man sich ab zu lesen. */ winzig: [...document.querySelectorAll("p, span, li, a, label, small")] - .filter((e) => e.children.length === 0 && e.textContent.trim().length > 3 - && parseFloat(getComputedStyle(e).fontSize) < 11.5) + .filter((e) => { + if (e.children.length || e.textContent.trim().length <= 3) return false; + const r = e.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return false; + return parseFloat(getComputedStyle(e).fontSize) < 11.5; + }) .map((e) => `"${e.textContent.trim().slice(0, 18)}" ${getComputedStyle(e).fontSize}`) .slice(0, 3), }); diff --git a/server/pruef-lesbarkeit.mjs b/server/pruef-lesbarkeit.mjs new file mode 100644 index 00000000..e57f352d --- /dev/null +++ b/server/pruef-lesbarkeit.mjs @@ -0,0 +1,331 @@ +/* Misst, ob Text auf den durchscheinenden Flaechen wirklich lesbar ist. + + Wunsch vom 01.09.2026: "diese Kachel soll ganz leicht noch dunkler + werden, so dass man den Text besser gelesen bekommt -- aber auch nicht + zu viel, so dass man den Hasen noch sieht." Und: "die von wichtigen + und neuen PDFs sollen auch staerker sein." + + WARUM GEMESSEN UND NICHT ANGESEHEN. + Hinter allen Flaechen liegt ein Buehnenbild. Wie lesbar ein Text ist, + haengt damit nicht an seiner Farbe, sondern an dem, was an genau + dieser Stelle darunter liegt -- und das wechselt je Seite. Ein Blick + auf einen Screenshot bei Tageslicht sagt darueber nichts. + + Gemessen wird deshalb an ECHTEN PIXELN: Der Text wird kurz + ausgeblendet, die Flaeche an der Stelle abfotografiert, wo er stand, + und daraus mit der WCAG-Formel der Kontrast gerechnet. + + ZWEI GRENZEN, nicht eine. Der Wunsch hat zwei Haelften, und eine + Pruefung, die nur die eine kennt, wuerde die andere kaputtgehen + lassen: + UNTEN der Text muss lesbar sein (mindestens 4,5:1, WCAG AA) + OBEN das Bild muss noch durchkommen (hoechstens 92 % Deckung) + Nur beides zusammen ist der Wunsch. */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-lese-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4214"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4214"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 300_000).unref?.(); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const salt = randomBytes(16).toString("hex"); +const hash = scryptSync("CODE-DOGI-0001", salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); +d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run("Filipe", "admin", hash, salt, 32768, jetzt); + +/* Zwei Anleitungen: eine wichtige, eine frische. Beide muessen in der + Uebersicht oben landen -- genau die aus dem Wunsch. + + OHNE try/catch: Der erste Anlauf schrieb in eine Tabelle "bibliothek", + die es nicht gibt, fing den Fehler weg und lief mit einer leeren Seite + weiter. Die Pruefung meldete daraufhin "nicht gefunden" statt "die + Daten fehlen" -- und haette bei einem echten Fehler genauso ausgesehen. + Schlaegt das Anlegen fehl, soll der Lauf hier abbrechen. */ +d.prepare(`INSERT INTO wissen + (titel, beschreibung, kategorie, stufe, geraet, name_original, name_datei, + groesse, wichtig, veroeffentlicht, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`) + .run("Die wichtigste Anleitung", "Muss jeder gelesen haben.", "hilfe", + "einsteiger", "egal", "wichtig.pdf", "x1.pdf", 1000, 1, jetzt.slice(0, 10), 1); +d.prepare(`INSERT INTO wissen + (titel, beschreibung, kategorie, stufe, geraet, name_original, name_datei, + groesse, wichtig, veroeffentlicht, hochgeladen, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`) + .run("Gerade dazugekommen", "Frisch eingestellt.", "hilfe", + "einsteiger", "egal", "neu.pdf", "x2.pdf", 1000, 0, jetzt.slice(0, 10), jetzt, 1); + +/* EINE GEWOEHNLICHE ANLEITUNG -- aelter als 48 Stunden, damit sie weder + "neu" noch "wichtig" ist. + + Ohne sie findet der Vergleich "Wichtiges darf nie duenner sein als + Gewoehnliches" nichts zum Vergleichen und wird STILL uebersprungen. + Ein uebersprungener Test beweist nichts -- und ausgerechnet dieser + Vergleich ist der, der den urspruenglichen Fehler gefunden haette. */ +const alt = new Date(Date.now() - 30 * 86400_000).toISOString(); +d.prepare(`INSERT INTO wissen + (titel, beschreibung, kategorie, stufe, geraet, name_original, name_datei, + groesse, wichtig, veroeffentlicht, hochgeladen, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`) + .run("Steht schon laenger da", "Ganz gewoehnlich.", "hilfe", + "einsteiger", "egal", "alt.pdf", "x3.pdf", 1000, 0, alt.slice(0, 10), alt, 1); +d.close(); + +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); +const kontext = await browser.newContext({ viewport: { width: 1280, height: 1100 } }); +const seite = await kontext.newPage(); +await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); +await seite.click('.rolle[data-rolle="admin"]'); +await seite.fill("#code", "CODE-DOGI-0001"); +await seite.click("button[type=submit]"); +await seite.waitForURL("**/start.html", { timeout: 15000 }); + +/* Die WCAG-Formel. Steht hier und nicht in der Seite -- eine Pruefung, + die ihre Messlatte aus dem Geprueften bezieht, misst nichts. */ +function leuchtkraft(r, g, b) { + const f = (w) => { + const v = w / 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }; + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); +} +const kontrast = (a, b) => { + const l1 = leuchtkraft(...a); + const l2 = leuchtkraft(...b); + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +}; + +/* Pixel lesen OHNE zusaetzliche Bibliothek: Der Screenshot wird als + Bild in die Seite gegeben und in ein Canvas gezeichnet -- dort laesst + sich jeder Punkt auslesen. Derselbe Weg wie in pruef-buehne.mjs; eine + zweite Methode fuer dieselbe Messung waere der sichere Weg zu zwei + verschiedenen Wahrheiten. */ +async function pixelMittel(bild, breite, hoehe) { + return seite.evaluate(async ({ b64, breite, hoehe }) => { + const bild = new Image(); + await new Promise((r) => { bild.onload = r; bild.src = "data:image/png;base64," + b64; }); + const c = document.createElement("canvas"); + c.width = bild.width; c.height = bild.height; + const g = c.getContext("2d", { willReadFrequently: true }); + g.drawImage(bild, 0, 0); + const daten = g.getImageData(0, 0, c.width, c.height).data; + let r = 0, gr = 0, bl = 0, n = 0; + for (let i = 0; i < daten.length; i += 4) { r += daten[i]; gr += daten[i + 1]; bl += daten[i + 2]; n++; } + return n ? [Math.round(r / n), Math.round(gr / n), Math.round(bl / n), n] : null; + }, { b64: bild.toString("base64"), breite, hoehe }); +} + +/** Misst an der Stelle, an der ein Text steht: + * - die Farbe des Textes (aus dem Stil) + * - die tatsaechliche Farbe DAHINTER (der Text wird kurz unsichtbar + * gemacht und die Flaeche fotografiert) + * + * Ein Mittelwert ueber die ganze Flaeche, nicht ein einzelner Punkt: + * Hinter einer durchscheinenden Flaeche liegt ein Bild, und ein + * einzelner Punkt kann zufaellig hell oder dunkel sein. + * + * Unsichtbar gemacht wird der Text, NICHT entfernt: Ein entferntes + * Element aendert das Layout, und dann wird an der falschen Stelle + * gemessen. */ +async function messen(waehler) { + const stelle = await seite.evaluate((w) => { + const e = document.querySelector(w); + if (!e) return null; + const r = e.getBoundingClientRect(); + if (r.width < 10 || r.height < 6) return null; + if (r.top < 0 || r.bottom > window.innerHeight) e.scrollIntoView({ block: "center" }); + const r2 = e.getBoundingClientRect(); + /* DIE FARBE ZUERST UND IN EIN EIGENES FELD ABSCHREIBEN. + getComputedStyle liefert ein LEBENDES Objekt: Wird der Text gleich + auf "transparent" gesetzt, aendert sich st.color mit -- und die + Messung vergleicht Schwarz gegen den dunklen Grund. Genau so + meldete der erste Lauf 1,17:1 fuer Text, der tadellos lesbar ist. + Ein Messfehler, der schlimmer ist als keine Messung: Er haette + eine Verschlimmbesserung ausgeloest. */ + const farbe = getComputedStyle(e).color.match(/\d+/g).slice(0, 3).map(Number); + e.dataset.messen = "ja"; + e.style.color = "transparent"; + return { x: Math.round(r2.x), y: Math.round(r2.y), + w: Math.round(r2.width), h: Math.round(Math.min(r2.height, 40)), + farbe }; + }, waehler); + if (!stelle || stelle.w < 10 || stelle.h < 6) return null; + + const bild = await seite.screenshot({ + clip: { x: stelle.x, y: stelle.y, width: stelle.w, height: stelle.h }, + }); + await seite.evaluate(() => { + const e = document.querySelector('[data-messen="ja"]'); + if (e) { e.style.color = ""; delete e.dataset.messen; } + }); + + const hinten = await pixelMittel(bild, stelle.w, stelle.h); + if (!hinten) return null; + return { ...stelle, hinten, punkte: hinten[3], + kontrast: kontrast(stelle.farbe, hinten.slice(0, 3)) }; +} + +/* ================================================================ + 1. Der Erklaerkasten im Scouting -- die Kachel aus dem Bild + ================================================================ */ +console.log("\n=== Der Erklaerkasten (Scouting)"); +await seite.goto(BASIS + "/workspace/scouting.html", { waitUntil: "networkidle" }); +await seite.waitForTimeout(1400); + +const kasten = await seite.evaluate(() => { + const k = document.querySelector(".leer-hinweis--erklaerung"); + if (!k) return null; + const st = getComputedStyle(k); + /* Wie dicht ist die unterste Schicht? Aus ihr kommt die Lesbarkeit -- + und ihre Obergrenze ist der zweite Teil des Wunsches. */ + const m = st.backgroundColor.match(/[\d.]+/g) || []; + const bilder = st.backgroundImage; + return { da: true, hintergrund: st.backgroundColor, bilder: bilder.slice(0, 60), teile: m }; +}); +ok(!!kasten, "der Erklaerkasten ist da"); + +for (const [waehler, name, mindestens] of [ + [".erklaerung__satz", "der Einstiegssatz", 4.5], + [".erklaerung__warum", "der Wozu-Absatz", 4.5], + [".erklaerung__was", "die Beschreibung einer Stufe", 4.5], + [".erklaerung__fuss", "der Schlussabsatz", 4.5], +]) { + const m = await messen(waehler); + if (!m) { ok(false, `${name}: nicht gefunden`); continue; } + ok(m.kontrast >= mindestens, + `${name}: ${m.kontrast.toFixed(2)}:1 (mindestens ${mindestens}:1)`); +} + +/* DIE ZWEITE GRENZE -- "so dass man den Hasen noch sieht". + Ohne sie waere die Pruefung mit einer schwarzen Flaeche am + zufriedensten, und genau das wollte niemand. */ +const deckung = await seite.evaluate(() => { + const k = document.querySelector(".leer-hinweis--erklaerung"); + const m = getComputedStyle(k).backgroundColor.match(/[\d.]+/g); + return m && m.length === 4 ? Number(m[3]) : 1; +}); +ok(deckung <= 0.92, + `die Flaeche laesst das Bild noch durch (${Math.round(deckung * 100)} % Deckung, hoechstens 92 %)`); +ok(deckung >= 0.8, + ` und ist dicht genug, damit Text ruhig steht (${Math.round(deckung * 100)} %, mindestens 80 %)`); + +await seite.screenshot({ path: "pruef-lesbarkeit-scouting.png", fullPage: false }); + +/* ================================================================ + 2. Wichtige und neue Anleitungen + ================================================================ */ +console.log("\n=== Wichtige und neue Anleitungen"); +await seite.goto(BASIS + "/workspace/wissen.html", { waitUntil: "networkidle" }); +await seite.waitForTimeout(1600); + +/* Die gewoehnliche Anleitung steht NICHT im Block "Neu & Wichtig" oben, + sondern in ihrer Kategorie -- und die wird erst geladen, wenn man sie + aufmacht. Der erste Anlauf suchte sie auf der Startansicht und fand + nichts; die Pruefung meldete daraufhin "es gab keine zum Vergleichen", + und das war richtig gemeldet, aber am falschen Ort gesucht. + ?k=hilfe oeffnet die Kategorie -- dort stehen alle drei nebeneinander. */ +await seite.goto(BASIS + "/workspace/wissen.html?k=hilfe", { waitUntil: "networkidle" }); +await seite.waitForTimeout(1400); + +const karten = await seite.evaluate(() => { + const wichtig = document.querySelector('.pdf[data-wichtig="ja"]'); + const normal = [...document.querySelectorAll(".pdf")] + .find((p) => p.dataset.wichtig !== "ja" && !p.querySelector(".marke-neu")); + const dichte = (e) => { + if (!e) return null; + const m = getComputedStyle(e).backgroundColor.match(/[\d.]+/g); + return m && m.length === 4 ? Number(m[3]) : 1; + }; + return { + wichtigDa: !!wichtig, + wichtig: dichte(wichtig), + normal: dichte(normal), + normalDa: !!normal, + }; +}); +ok(karten.wichtigDa, "eine als wichtig markierte Anleitung ist da"); + +/* DER EIGENTLICHE FEHLER, den es zu verhindern gilt: Vorher stand dort + `background: rgba(232,192,125,.045)` -- das hat die Flaeche nicht + getoent, sondern ERSETZT. Uebrig blieben viereinhalb Prozent Deckung, + und ausgerechnet die Anleitung, die jeder lesen soll, war die am + schwersten lesbare der Seite. Geprueft wird deshalb im VERGLEICH: + Wichtiges darf nie duenner sein als Gewoehnliches. */ +if (karten.wichtigDa) { + ok(karten.wichtig >= 0.8, + `die wichtige Anleitung steht auf dichtem Grund (${Math.round(karten.wichtig * 100)} %)`); + /* KEIN `if` um diesen Vergleich: Findet er keine gewoehnliche Karte, + ist das ein Fehler der Pruefung und muss auffallen -- nicht ein + stiller Aussetzer. Genau dieser Vergleich haette den + urspruenglichen Fehler gefunden. */ + ok(karten.normalDa && karten.wichtig >= karten.normal, + ` und ist nicht duenner als eine gewoehnliche ` + + (karten.normalDa + ? `(${Math.round(karten.wichtig * 100)} % gegen ${Math.round(karten.normal * 100)} %)` + : "– ABER es gab keine gewoehnliche zum Vergleichen")); +} + +for (const [waehler, name] of [ + ['.pdf[data-wichtig="ja"] .pdf__titel', "ihr Titel"], + ['.pdf[data-wichtig="ja"] .pdf__text', "ihre Beschreibung"], +]) { + const m = await messen(waehler); + if (!m) { ok(false, `${name}: nicht gefunden`); continue; } + /* Die Beschreibung ist kleiner und leiser -- 4,5:1 gilt trotzdem, + sie ist normaler Fliesstext. */ + ok(m.kontrast >= 4.5, `${name}: ${m.kontrast.toFixed(2)}:1`); +} + +await seite.screenshot({ path: "pruef-lesbarkeit-wissen.png", fullPage: false }); + +/* ================================================================ + 3. GEGENPROBE -- kann diese Messung ueberhaupt anschlagen? + Eine Pruefung, die immer bestaetigt, bestaetigt nichts. + ================================================================ */ +console.log("\n=== Gegenprobe"); +await seite.evaluate(() => { + const k = document.querySelector(".pdf__titel"); + if (k) k.style.color = "#141a24"; /* fast so dunkel wie der Grund */ +}); +const schlecht = await messen(".pdf__titel"); +ok(schlecht && schlecht.kontrast < 4.5, + `absichtlich zu dunkler Text wird erkannt (${schlecht?.kontrast?.toFixed(2)}:1)`); +ok((schlecht?.punkte || 0) > 200, + `und dabei wirklich Pixel gelesen (${schlecht?.punkte} Punkte gemittelt)`); + +await kontext.close(); +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-sicht.mjs b/server/pruef-sicht.mjs new file mode 100644 index 00000000..a3161eab --- /dev/null +++ b/server/pruef-sicht.mjs @@ -0,0 +1,382 @@ +/* Prueft "die Sicht eines anderen" — DogFathers Umschalter. + + Wunsch vom 01.09.2026: "ich will alles von den Scouts und Managern + einsehen koennen, auswaehlen, was und von wem ich sehen will. Und das + soll ich als DogFather-Rolle ueberall haben, auf jeder Seite. + NUR WIR BEIDE SOLLEN DIESE OPTION HABEN." + + DAS HIER IST EINE RECHTEPRUEFUNG, keine Bedienpruefung. Wenn etwas + davon nicht stimmt, kann ein Manager oder Scout durch Anhaengen von + ?sicht= in fremde Bereiche sehen. Deshalb steht der Missbrauch + VOR der Funktion: + + 1. Ein Scout haengt ?sicht= an -> muss ignoriert werden + 2. Ein Manager haengt ?sicht= an -> muss ignoriert werden + 3. Ein Creator haengt ?sicht= an -> muss ignoriert werden + 4. DogFather haengt eine erfundene Nummer an -> eigene Sicht + 5. DogFather waehlt einen Scout -> sieht dessen Liste + + Und, genauso wichtig: SCHREIBEN passiert immer im eigenen Namen. Ein + Eintrag, der unter fremdem Namen im Protokoll landet, waere schlimmer + als gar keine Funktion. */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-sicht-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4215"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4215"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 300_000).unref?.(); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const heute = jetzt.slice(0, 10); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idMgr = anlegen("Mara", "manager", "CODE-MANA-0001"); +const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idBen = anlegen("Ben", "scout", "CODE-SCOB-0001"); +const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-LUNA-0001"); +/* Patrick betreut Tili, Ben betreut Luna -- so haben die beiden Scouts + wirklich VERSCHIEDENE Arbeitsplaetze. Ohne das koennte die Pruefung + nicht unterscheiden, ob umgeschaltet wurde oder ob beide dasselbe + sehen. */ +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idPat, jetzt); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idBen, jetzt); + +/* Je zwei Aufgaben mit eindeutigen Titeln -- daran laesst sich ablesen, + WESSEN Liste gerade kommt. */ +const neueAufgabe = (titel, creator, verant) => + d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, + creator_id, verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?)`) + .run(titel, "", "offen", "mittel", creator, verant, jetzt, idDogi); +neueAufgabe("PATRICK-SACHE-1", idTili, idPat); +neueAufgabe("PATRICK-SACHE-2", idTili, idPat); +neueAufgabe("BEN-SACHE-1", idLuna, idBen); +neueAufgabe("NUR-DOGI", null, idDogi); +d.close(); + +/* ---------------------------------------------------------------- */ + +async function anmelden(rolle, code) { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }), + }); + const k = a.headers.getSetCookie?.() || []; + return k.map((x) => x.split(";")[0]).join("; "); +} +const holen = (pfad, keks) => + fetch(BASIS + pfad, { headers: { cookie: keks } }); +const titel = async (pfad, keks) => { + const a = await holen(pfad, keks); + if (!a.ok) return { status: a.status, liste: [] }; + const j = await a.json(); + return { status: a.status, liste: (j.aufgaben || []).map((x) => x.titel).sort() }; +}; + +const kDogi = await anmelden("admin", "CODE-DOGI-0001"); +const kMgr = await anmelden("manager", "CODE-MANA-0001"); +const kPat = await anmelden("scout", "CODE-SCOU-0001"); +const kTili = await anmelden("creator", "CODE-ANNA-0001"); + +/* ================================================================ + 1. ZUERST DER MISSBRAUCH + ================================================================ */ +console.log("\n=== Niemand ausser DogFather darf umschalten"); + +{ + const ohne = await titel("/workspace/api/aufgaben", kPat); + const mit = await titel(`/workspace/api/aufgaben?sicht=${idBen}`, kPat); + ok(JSON.stringify(ohne.liste) === JSON.stringify(mit.liste), + `ein Scout aendert mit ?sicht= nichts (${mit.liste.length} Aufgaben, wie ohne)`); + ok(!mit.liste.includes("BEN-SACHE-1"), + " und sieht die Sachen des anderen Scouts NICHT"); +} +{ + const ohne = await titel("/workspace/api/aufgaben", kMgr); + const mit = await titel(`/workspace/api/aufgaben?sicht=${idTili}`, kMgr); + ok(JSON.stringify(ohne.liste) === JSON.stringify(mit.liste), + `ein Manager aendert mit ?sicht= nichts (${mit.liste.length} Aufgaben)`); +} +{ + const ohne = await titel("/workspace/api/aufgaben", kTili); + const mit = await titel(`/workspace/api/aufgaben?sicht=${idPat}`, kTili); + ok(JSON.stringify(ohne.liste) === JSON.stringify(mit.liste), + `ein Creator aendert mit ?sicht= nichts (${mit.liste.length} Aufgaben)`); + ok(!mit.liste.includes("NUR-DOGI"), + " und kommt so nicht an fremde Aufgaben"); +} + +/* ================================================================ + 2. DOGFATHER SCHALTET UM + ================================================================ */ +console.log("\n=== DogFather sieht durch fremde Augen"); + +const alles = await titel("/workspace/api/aufgaben", kDogi); +ok(alles.liste.length === 4, `ohne Auswahl sieht er alles (${alles.liste.length} Aufgaben)`); + +const alsPat = await titel(`/workspace/api/aufgaben?sicht=${idPat}`, kDogi); +ok(alsPat.liste.length === 2 && + alsPat.liste.every((t) => t.startsWith("PATRICK")), + `als Patrick genau dessen zwei (${alsPat.liste.join(", ")})`); +ok(!alsPat.liste.includes("BEN-SACHE-1") && !alsPat.liste.includes("NUR-DOGI"), + " und wirklich NUR dessen – nicht mehr"); + +const alsBen = await titel(`/workspace/api/aufgaben?sicht=${idBen}`, kDogi); +ok(alsBen.liste.length === 1 && alsBen.liste[0] === "BEN-SACHE-1", + `als Ben dessen eine (${alsBen.liste.join(", ")})`); +ok(JSON.stringify(alsPat.liste) !== JSON.stringify(alsBen.liste), + " zwei Scouts liefern wirklich Verschiedenes – es wird also umgeschaltet"); + +const alsTili = await titel(`/workspace/api/aufgaben?sicht=${idTili}`, kDogi); +ok(alsTili.liste.length === 2, + `als Creator dessen Brett (${alsTili.liste.length})`); + +/* ================================================================ + 3. WAS BEI UNSINN PASSIERT + ================================================================ */ +console.log("\n=== Unsinnige Auswahl"); +for (const [wert, was] of [ + ["999999", "eine Nummer, die es nicht gibt"], + ["0", "die Null"], + ["-5", "eine negative Zahl"], + ["abc", "Buchstaben"], + ["1%20OR%201=1", "ein Einschleusversuch"], +]) { + const r = await titel(`/workspace/api/aufgaben?sicht=${wert}`, kDogi); + ok(r.status === 200 && r.liste.length === 4, + `${was} faellt auf die eigene Sicht zurueck (${r.status}, ${r.liste.length} Aufgaben)`); +} + +/* Eine ABGESCHALTETE Person darf keine Sicht liefern -- sonst waere das + Abschalten eines Kontos halbherzig. */ +{ + const d2 = new DatabaseSync(process.env.WORKSPACE_DB); + d2.prepare("UPDATE personen SET aktiv = 0 WHERE id = ?").run(idBen); + d2.close(); + const r = await titel(`/workspace/api/aufgaben?sicht=${idBen}`, kDogi); + ok(r.liste.length === 4, + `eine abgeschaltete Person liefert keine Sicht mehr (${r.liste.length} Aufgaben = alles)`); + const d3 = new DatabaseSync(process.env.WORKSPACE_DB); + d3.prepare("UPDATE personen SET aktiv = 1 WHERE id = ?").run(idBen); + d3.close(); +} + +/* ================================================================ + 4. GESCHRIEBEN WIRD IMMER IM EIGENEN NAMEN + ================================================================ */ +console.log("\n=== Schreiben bleibt im eigenen Namen"); +{ + const a = await fetch(`${BASIS}/workspace/api/aufgaben?sicht=${idPat}`, { + method: "POST", + headers: { cookie: kDogi, "Content-Type": "application/json" }, + body: JSON.stringify({ titel: "WER-HAT-DAS-ANGELEGT", prioritaet: "mittel" }), + }); + ok(a.ok, `anlegen geht auch bei fremder Sicht (${a.status})`); + const d2 = new DatabaseSync(process.env.WORKSPACE_DB); + const z = d2.prepare("SELECT erstellt_von FROM aufgaben WHERE titel = ?") + .get("WER-HAT-DAS-ANGELEGT"); + d2.close(); + ok(z?.erstellt_von === idDogi, + `und steht unter DogFather, nicht unter dem angesehenen Scout ` + + `(${z?.erstellt_von} statt ${idPat})`); +} + +/* ================================================================ + 5. DIE ANDEREN BEREICHE ZIEHEN MIT + ================================================================ */ +console.log("\n=== Es gilt nicht nur fuer Aufgaben"); +for (const [pfad, name, feld] of [ + ["/workspace/api/hinweise", "Was ist dran", "hinweise"], + ["/workspace/api/termine?von=" + heute + "&tage=7", "Kalender", "termine"], + ["/workspace/api/dateien", "Dateien", "dateien"], + ["/workspace/api/leads", "Scout-Pipeline", "leads"], +]) { + const a = await holen(`${pfad}${pfad.includes("?") ? "&" : "?"}sicht=${idPat}`, kDogi); + const b = await holen(pfad, kDogi); + ok(a.status === 200 && b.status === 200, `${name}: beide Abfragen gehen durch`); +} + +/* Der Kalender ist der klarste Beleg: Patrick sieht nur seine Termine. */ +{ + const d2 = new DatabaseSync(process.env.WORKSPACE_DB); + d2.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?)`) + .run("PATRICK-TERMIN", "call", heute + "T10:00", 30, idTili, jetzt, idPat); + d2.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?)`) + .run("BEN-TERMIN", "call", heute + "T12:00", 30, idLuna, jetzt, idBen); + d2.close(); + + const j = async (k, s) => { + const a = await holen(`/workspace/api/termine?von=${heute}&tage=2${s ? "&sicht=" + s : ""}`, k); + return ((await a.json()).termine || []).map((t) => t.titel).sort(); + }; + const alleT = await j(kDogi); + const patT = await j(kDogi, idPat); + ok(alleT.length === 2, `DogFather sieht beide Termine (${alleT.join(", ")})`); + ok(patT.length === 1 && patT[0] === "PATRICK-TERMIN", + `als Patrick nur dessen (${patT.join(", ")})`); +} + +/* ================================================================ + 6. IM BROWSER: der Umschalter steht da -- und nur bei DogFather + ================================================================ */ +console.log("\n=== Der Umschalter im Seitenkopf"); +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); + +async function browserAls(rolle, code) { + const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); + const konsole = []; + const seite = await kontext.newPage(); + seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + return { kontext, seite, konsole }; +} + +const bDogi = await browserAls("admin", "CODE-DOGI-0001"); +/* AUF JEDER SEITE -- das war der ausdrueckliche Wunsch. Geprueft wird + deshalb nicht eine Seite, sondern alle. */ +const SEITEN = ["start.html", "aufgaben.html", "kalender.html", "dateien.html", + "bereich.html?b=live", "content.html", "calls.html", "scouting.html", + "report.html", "personen.html", "wissen.html", "uebersicht.html", + "profil.html", "startcheck.html", "steckbrief.html", "automation.html"]; +let fehlend = []; +for (const s of SEITEN) { + await bDogi.seite.goto(BASIS + "/workspace/" + s, { waitUntil: "networkidle" }); + await bDogi.seite.waitForTimeout(900); + const da = await bDogi.seite.evaluate(() => { + const f = document.getElementById("sicht-wahl"); + if (!f) return false; + const r = f.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }); + if (!da) fehlend.push(s); +} +ok(fehlend.length === 0, + `der Umschalter steht auf allen ${SEITEN.length} Seiten` + + (fehlend.length ? ` – FEHLT auf: ${fehlend.join(", ")}` : "")); + +const wahl = await bDogi.seite.evaluate(() => { + const s = document.getElementById("sicht-feld"); + return { + gruppen: [...s.querySelectorAll("optgroup")].map((g) => g.label), + anzahl: s.querySelectorAll("option").length, + erste: s.querySelector("option")?.textContent, + ichDrin: [...s.querySelectorAll("option")].some((o) => o.textContent === "Filipe"), + }; +}); +ok(wahl.gruppen.join(", ") === "Manager, Scouts, Creator", + `nach Rollen gruppiert: ${wahl.gruppen.join(", ")}`); +ok(/meine Sicht/i.test(wahl.erste || ""), + `der Rueckweg steht ganz oben ("${wahl.erste}")`); +ok(!wahl.ichDrin, "DogFather selbst steht nicht in der Liste – das waere derselbe Zustand"); + +/* Umschalten im Browser und pruefen, dass die Seite danach wirklich + die andere Liste zeigt. */ +await bDogi.seite.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" }); +await bDogi.seite.waitForTimeout(1100); +const vorher = await bDogi.seite.evaluate(() => + document.querySelectorAll(".karte").length); +await bDogi.seite.selectOption("#sicht-feld", String(idPat)); +await bDogi.seite.waitForTimeout(1800); +const nachher = await bDogi.seite.evaluate(() => ({ + karten: document.querySelectorAll(".karte").length, + titel: [...document.querySelectorAll(".karte__titel")].map((e) => e.textContent).sort(), + rahmen: getComputedStyle(document.body, "::after").borderTopWidth, + hinweis: !!document.querySelector('.sicht[data-fremd="ja"]'), + weg: !!document.querySelector(".sicht__weg"), +})); +ok(nachher.karten < vorher, + `nach dem Umschalten weniger Aufgaben (${vorher} -> ${nachher.karten})`); +ok(nachher.titel.every((t) => t.startsWith("PATRICK")), + `und zwar genau Patricks (${nachher.titel.join(", ")})`); +ok(nachher.hinweis, "der Umschalter zeigt deutlich, dass eine fremde Sicht laeuft"); +ok(nachher.weg, " und bietet den Weg zurueck an"); +ok(parseFloat(nachher.rahmen) >= 1, + `ein Rahmen um die Seite erinnert daran (${nachher.rahmen}) – ` + + "der gefaehrlichste Fall ist, dass man es vergisst"); + +/* Und er BLEIBT beim Seitenwechsel -- sonst muesste man ihn auf jeder + Seite neu setzen, und genau das war der Wunsch ("ueberall"). */ +await bDogi.seite.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" }); +await bDogi.seite.waitForTimeout(1200); +const bleibt = await bDogi.seite.evaluate(() => ({ + wert: document.getElementById("sicht-feld")?.value, + fremd: !!document.querySelector('.sicht[data-fremd="ja"]'), +})); +ok(bleibt.wert === String(idPat) && bleibt.fremd, + `die Auswahl ueberlebt den Seitenwechsel (${bleibt.wert})`); + +/* Zurueck -- und wirklich zurueck. */ +await bDogi.seite.click(".sicht__weg"); +await bDogi.seite.waitForTimeout(1600); +const zurueck = await bDogi.seite.evaluate(() => ({ + wert: document.getElementById("sicht-feld")?.value, + fremd: !!document.querySelector('.sicht[data-fremd="ja"]'), +})); +ok(zurueck.wert === "" && !zurueck.fremd, '"zurück zu mir" stellt die eigene Sicht wieder her'); +ok(bDogi.konsole.length === 0, + `keine Fehler in der Konsole${bDogi.konsole.length ? " -> " + bDogi.konsole.join(" | ") : ""}`); +await bDogi.kontext.close(); + +/* GEGENPROBE IM BROWSER: Die anderen drei Rollen bekommen ihn nicht + einmal zu sehen. */ +for (const [rolle, code, name] of [ + ["manager", "CODE-MANA-0001", "Manager"], + ["scout", "CODE-SCOU-0001", "Scout"], + ["creator", "CODE-ANNA-0001", "Creator"], +]) { + const b = await browserAls(rolle, code); + await b.seite.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" }); + await b.seite.waitForTimeout(900); + const da = await b.seite.evaluate(() => !!document.getElementById("sicht-wahl")); + ok(!da, `Gegenprobe: ein ${name} sieht den Umschalter nicht`); + await b.kontext.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-aufgaben.js b/server/workspace-aufgaben.js index 33da8ffe..a447cf00 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -89,7 +89,7 @@ const VERBUND = ` aufgabenRouter.get("/workspace/api/aufgaben", (req, res) => { try { - const { wo, werte } = sichtbar(req.person); + const { wo, werte } = sichtbar(req.sicht || req.person); const reihen = db().prepare(` SELECT ${SPALTEN}, (SELECT COUNT(*) FROM aufgaben_notizen n WHERE n.aufgabe_id = a.id) AS notizen @@ -108,7 +108,7 @@ aufgabenRouter.get("/workspace/api/aufgaben", (req, res) => { aufgabenRouter.get("/workspace/api/uebersicht", (req, res) => { try { - const { wo, werte } = sichtbar(req.person); + const { wo, werte } = sichtbar(req.sicht || req.person); const zaehle = (zusatz, extra = []) => db() .prepare(`SELECT COUNT(*) AS n ${VERBUND} WHERE ${wo}${zusatz}`) .get(...werte, ...extra).n; diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index 4f29d4f9..e200664d 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -226,7 +226,7 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => { const einstellung = BEREICHE[bereich]; if (!einstellung) return res.status(404).json({ fehler: "nicht_gefunden" }); - const regel = sichtbar(req.person); + const regel = sichtbar(req.sicht || req.person); if (!regel) return res.status(404).json({ fehler: "nicht_gefunden" }); const eintraege = db().prepare(` diff --git a/server/workspace-calls.js b/server/workspace-calls.js index 90bdff71..47122337 100644 --- a/server/workspace-calls.js +++ b/server/workspace-calls.js @@ -101,7 +101,7 @@ function aufgabenZu(protokollIds) { callRouter.get("/workspace/api/calls", (req, res) => { try { - const { wo, werte } = sichtbar(req.person); + const { wo, werte } = sichtbar(req.sicht || req.person); const alle = db().prepare(` SELECT ${SPALTEN} ${VERBUND} WHERE ${wo} AND t.art IN ('call','review') diff --git a/server/workspace-content.js b/server/workspace-content.js index adb60d2a..07be7bb6 100644 --- a/server/workspace-content.js +++ b/server/workspace-content.js @@ -89,7 +89,7 @@ function darfCreatorSehen(person, creatorId) { contentRouter.get("/workspace/api/content/saeulen", (req, res) => { try { - const erlaubt = sichtbareCreator(req.person); + const erlaubt = sichtbareCreator(req.sicht || req.person); let wo = "1=1", werte = []; if (erlaubt !== null) { if (!erlaubt.length) return res.json({ saeulen: [] }); @@ -228,7 +228,7 @@ contentRouter.delete("/workspace/api/content/saeulen/:id", gleicheHerkunft, (req contentRouter.get("/workspace/api/content/kennzahlen", (req, res) => { try { - const erlaubt = sichtbareCreator(req.person); + const erlaubt = sichtbareCreator(req.sicht || req.person); let wo = "e.bereich = 'content'", werte = []; if (erlaubt !== null) { if (!erlaubt.length) return res.json({ leer: true }); diff --git a/server/workspace-dateien.js b/server/workspace-dateien.js index 42f4679a..6677efc7 100644 --- a/server/workspace-dateien.js +++ b/server/workspace-dateien.js @@ -150,7 +150,7 @@ const VERBUND = ` dateienRouter.get("/workspace/api/dateien", (req, res) => { try { - const { wo, werte } = sichtbar(req.person); + const { wo, werte } = sichtbar(req.sicht || req.person); const liste = db().prepare(` SELECT ${SPALTEN} ${VERBUND} WHERE ${wo} diff --git a/server/workspace-hinweise.js b/server/workspace-hinweise.js index 8bcbf387..1f54a5ed 100644 --- a/server/workspace-hinweise.js +++ b/server/workspace-hinweise.js @@ -71,7 +71,11 @@ function zaehle(sql, werte) { hinweisRouter.get("/workspace/api/hinweise", (req, res) => { try { - const person = req.person; + /* Die gewaehlte Sicht (nur DogFather, sonst er selbst). Waeren die + Hinweise davon ausgenommen, staende auf der Startseite die eigene + Lage, waehrend die Kacheln darunter die eines Scouts zeigen -- + zwei Wahrheiten auf einem Bildschirm. */ + const person = req.sicht || req.person; const heute = heuteLokal(); const jetzt = jetztLokal(); const hinweise = []; diff --git a/server/workspace-kalender.js b/server/workspace-kalender.js index 6c2d9116..8a171b3c 100644 --- a/server/workspace-kalender.js +++ b/server/workspace-kalender.js @@ -68,7 +68,7 @@ const VERBUND = ` kalenderRouter.get("/workspace/api/termine", (req, res) => { try { - const { wo, werte } = sichtbar(req.person); + const { wo, werte } = sichtbar(req.sicht || req.person); /* Zeitraum. Ohne Angabe: ab heute 00:00, 90 Tage nach vorn -- das deckt den 90-Tage-Plan aus dem Konzept ab. */ diff --git a/server/workspace-scouts.js b/server/workspace-scouts.js index dd72fcae..511dd594 100644 --- a/server/workspace-scouts.js +++ b/server/workspace-scouts.js @@ -77,7 +77,7 @@ const VERBUND = ` scoutRouter.get("/workspace/api/leads", (req, res) => { try { - const { wo, werte } = sichtbar(req.person); + const { wo, werte } = sichtbar(req.sicht || req.person); const heute = jetzt().slice(0, 10); res.json({ stufen: STUFEN, diff --git a/server/workspace-suche.js b/server/workspace-suche.js index a80be26e..2e7464b5 100644 --- a/server/workspace-suche.js +++ b/server/workspace-suche.js @@ -72,7 +72,9 @@ function ausschnitt(text, suche) { sucheRouter.get("/workspace/api/suche", (req, res) => { try { - const person = req.person; + /* Die gewaehlte Sicht (nur DogFather, sonst er selbst) -- damit die + Suche dasselbe findet, was die Seiten gerade zeigen. */ + const person = req.sicht || req.person; const q = String(req.query.q ?? "").trim().slice(0, 100); if (q.length < MIN) { return res.json({ frage: q, zu_kurz: true, gruppen: [], gesamt: 0 }); diff --git a/server/workspace.js b/server/workspace.js index 4ba4e25f..56151ff7 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -910,6 +910,79 @@ export function betreutWo(person, spalte) { return { wo: `${spalte} IN (${ids.map(() => "?").join(",")})`, werte: ids }; } +/* ===================================================================== + DIE SICHT EINES ANDEREN — nur fuer DogFather. + + Wunsch vom 01.09.2026: "ich will, dass ich alles von den Scouts und + Managern einsehen kann, dass ich auswaehlen kann, was und von wem ich + sehen will. Und das soll ich als DogFather-Rolle ueberall haben, auf + jeder Seite. NUR WIR BEIDE SOLLEN DIESE OPTION HABEN." + + WIE ES GEBAUT IST, und warum genau so. + + Die naheliegende Loesung waere ein neuer Filter je Seite gewesen: + "zeige mir Aufgaben von Person X". Das haette bedeutet, in zwoelf + Modulen eine zweite Rechteregel zu schreiben -- und eine Rechteregel, + die an zwoelf Stellen steht, stimmt irgendwann an elf. + + Stattdessen wird die PERSON ausgetauscht, nicht die Regel: Waehlt + DogFather einen Scout, laeuft jede vorhandene sichtbar()-Funktion + unveraendert mit diesem Scout als Person. Er sieht dann exakt das, + was der Scout sieht -- nicht mehr und nicht weniger. Keine einzige + neue Rechteregel, und was heute richtig ist, bleibt es auch, wenn + sich eine Regel spaeter aendert. + + DREI SICHERUNGEN, die nicht verhandelbar sind: + + 1. NUR ADMIN. Die Rolle wird HIER geprueft, nicht in der Oberflaeche. + Wer kein DogFather ist, kann den Wert anhaengen, so oft er will -- + er wird stillschweigend ignoriert. Ein Manager duerfte sonst durch + Anhaengen von ?sicht= in fremde Bereiche sehen. + + 2. NUR LESEN. Ersetzt wird ausschliesslich req.sicht, niemals + req.person. Alles, was schreibt oder Rechte prueft, arbeitet + weiterhin mit dem echten Angemeldeten. Sonst haette ein + Schreibvorgang plotzlich im Namen eines anderen stattgefunden -- + und im Protokoll staende der falsche Name. + + 3. NUR AKTIVE, ECHTE PERSONEN. Eine geloeschte oder abgeschaltete + Nummer faellt auf die eigene Sicht zurueck, statt eine leere Seite + zu zeigen, die aussieht wie ein Fehler. + ===================================================================== */ + +/** Durch wessen Augen wird gerade gesehen? Gibt IMMER eine Person + * zurueck -- im Normalfall den Angemeldeten selbst. */ +export function sichtPerson(req) { + /* req.person setzt jedes Modul in seiner eigenen angemeldet()-Huerde. + Diese Funktion laeuft aber schon davor (global, siehe index.js) -- + deshalb liest sie die Sitzung notfalls selbst. */ + const ich = req?.person || sitzungLesen(req); + if (!ich) return null; + if (ich.rolle !== "admin") return ich; + + const id = Number(req.query?.sicht || 0); + if (!Number.isInteger(id) || id <= 0 || id === ich.id) return ich; + + try { + const z = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ? AND aktiv = 1").get(id); + /* Nicht gefunden oder abgeschaltet: zurueck auf die eigene Sicht. + Eine leere Seite waere hier die schlechtere Antwort -- sie sieht + aus wie ein Fehler, obwohl nur die Auswahl veraltet ist. */ + return z || ich; + } catch { + return ich; /* im Zweifel die eigene Sicht, nie eine fremde */ + } +} + +/** Middleware: setzt req.sicht. Steht danach jedem Modul zur Verfuegung. + * Module, die sie nicht benutzen, arbeiten unveraendert weiter -- req.sicht + * ist dort schlicht dasselbe wie req.person. */ +export function sichtSetzen(req, res, next) { + const s = sichtPerson(req); + if (s) req.sicht = s; + next(); +} + /* Darf diese Person den Bereich dieses Creators sehen und bearbeiten? */ export function darfCreator(person, creatorId) { if (!person || !creatorId) return false; diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index d59763e8..096b0383 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -444,10 +444,21 @@ body.start > main { position: relative; z-index: 1; } color: var(--text-leise); font-size: .92rem; line-height: 1.6; border: 1px solid color-mix(in srgb, var(--ton, var(--akzent)) 20%, var(--rand)); border-radius: var(--radius); + /* Ein Stueck dichter als die uebrige Flaeche (0,88 statt 0,78) -- + Wunsch vom 01.09.2026: "ganz leicht noch dunkler, so dass man den + Text besser gelesen bekommt, aber auch nicht zu viel, so dass man + den Hasen noch sieht." + + Diese Kaesten tragen mehr zusammenhaengenden Text als jede andere + Flaeche im Workspace (der Erklaerkasten im Scouting sind acht + Absaetze). Zehn Punkte mehr Deckung sind gemessen der Unterschied + zwischen muehsam und ruhig lesbar -- und lassen zwoelf Prozent Bild + durch, das Motiv bleibt also sichtbar. Mehr waere ein Kasten mit + einem Bild dahinter, das man nur noch erahnt. */ background: radial-gradient(120% 90% at 50% -10%, color-mix(in srgb, var(--ton, var(--akzent)) 9%, transparent), transparent 68%), - var(--flaeche); + rgba(9, 13, 22, .88); } /* Der Ring: ein Kreis in der Seitenfarbe, hohl, weich leuchtend. Ein Zeichen wäre hier zu laut -- es geht ja gerade darum, dass NICHTS da @@ -2571,3 +2582,129 @@ body.start #protokoll { .inhalt textarea, .inhalt select { font-size: 16px; } } + +/* ---------- Die Sicht eines anderen (nur DogFather) ----------------------- + + Der Umschalter steht im Seitenkopf, links neben dem eigenen Namen -- + also genau dort, wo ohnehin steht, WER man gerade ist. Beides gehoert + zusammen: "ich, und wessen Arbeitsplatz ich gerade ansehe." + + Solange eine fremde Sicht aktiv ist, faellt das deutlich auf. Das ist + der wichtigste Teil dieser ganzen Funktion: Der gefaehrlichste Fall + ist nicht, dass man die Sicht nicht wechseln kann, sondern dass man + vergisst, dass man sie gewechselt hat -- und am naechsten Tag drei + Aufgaben statt dreissig sieht und das fuer den Bestand haelt. */ + +.sicht { + --s-ton: #d8a13a; /* die Rollenfarbe von DogFather */ + display: flex; align-items: center; gap: 8px; + padding: 4px 6px 4px 11px; + border: 1px solid var(--rand); border-radius: 999px; + background: rgba(0, 0, 0, .25); + /* SCHRUMPFEN MUSS ERLAUBT SEIN. In der Kopfleiste steht + `.kopfleiste__rechts > * { flex: 0 0 auto; }` -- jedes Teil dort + behaelt also seine Wunschbreite. Der Umschalter schob die Seite + dadurch um 46 px (Rechner) bis 117 px (Handy) aus dem Bild, und der + Abmelden-Knopf war nicht mehr erreichbar. Gemeldet von + pruef-workspace-seiten und pruef-handy, bevor es jemand gesehen hat. + Diese Regel steht spaeter in der Datei und gewinnt deshalb. */ + flex: 0 1 auto; + min-width: 0; +} +.sicht__schild { + flex: none; + /* 0,75 rem = 12 px. Nicht kleiner: Unter 11,5 px meldet die + Geraetepruefung zu Recht "zu kleine Schrift" -- und was auf dem + Handy niemand liest, ist auf dem Rechner auch nur Zierde. */ + font-size: .75rem; font-weight: 600; + letter-spacing: .1em; text-transform: uppercase; + color: var(--text-still); +} +/* Kein `width: 100%` wie bei den Feldern im Inhalt -- hier gehoert das + Feld in eine Zeile und darf nicht die halbe Kopfleiste einnehmen. */ +/* NICHT das bleibt im Dokument, wird + aber auf einen Pixel geschrumpft und unsichtbar gemacht; sichtbar ist + ein Knopf darueber (.wahl2__knopf). + + Der erste Anlauf gab dem nichts Eigenes -- es sieht dann schlicht aus, ist + aber vollstaendig bedienbar. Das ist die richtige Reihenfolge. */ +.sicht .wahl2 { flex: 0 1 auto; min-width: 0; max-width: 180px; } +.sicht .wahl2__knopf { + padding: 5px 10px; + font-size: .82rem; + border-radius: 999px; + background: rgba(255, 255, 255, .04); +} +.sicht .wahl2__knopf:hover:not([disabled]) { + background: rgba(255, 255, 255, .07); +} +/* Der Name darf gekuerzt werden, statt den Knopf aufzublaehen. */ +.sicht .wahl2__text { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +/* FREMDE SICHT AKTIV -- deutlich, aber nicht schreiend. */ +.sicht[data-fremd="ja"] { + border-color: color-mix(in srgb, var(--s-ton) 55%, transparent); + background: + linear-gradient(rgba(216, 161, 58, .12), rgba(216, 161, 58, .12)), + rgba(0, 0, 0, .3); +} +.sicht[data-fremd="ja"] .sicht__schild { color: var(--s-ton); } +.sicht__weg { + flex: none; min-height: 28px; padding: 4px 11px; + border: 1px solid color-mix(in srgb, var(--s-ton) 45%, transparent); + border-radius: 999px; + background: transparent; color: var(--s-ton); + font: inherit; font-size: .76rem; cursor: pointer; + transition: background var(--tempo); +} +.sicht__weg:hover { background: color-mix(in srgb, var(--s-ton) 16%, transparent); } +.sicht__weg:focus-visible { outline: 2px solid var(--s-ton); outline-offset: 2px; } + +/* Ein feiner Rahmen um die ganze Seite, solange eine fremde Sicht laeuft. + Er kostet keinen Platz, laesst sich nicht wegscrollen und ist damit die + einzige Anzeige, die man wirklich nicht uebersehen kann. */ +body[data-fremde-sicht="ja"]::after { + content: ""; + position: fixed; inset: 0; + border: 2px solid rgba(216, 161, 58, .38); + pointer-events: none; + z-index: 60; +} + +/* Ab hier wird die Kopfleiste eng. Das Schild "SICHT" entfaellt als + Erstes -- das Feld selbst sagt bereits, wessen Sicht laeuft, und der + Rahmen um die Seite sagt DASS eine fremde laeuft. */ +@media (max-width: 1180px) { + .sicht__schild { display: none; } + .sicht { padding-left: 6px; } + .sicht .wahl2 { max-width: 150px; } +} +@media (max-width: 900px) { + .sicht .wahl2 { max-width: 128px; } + /* Der Knopf "zurueck zu mir" entfaellt -- "Alles (meine Sicht)" steht + ganz oben in der Auswahl und tut dasselbe. */ + .sicht__weg { display: none; } +} +@media (max-width: 620px) { + /* MINDESTBREITE, nicht nur Hoechstbreite: Ein Knopf, der auf zwanzig + Pixel zusammenfaellt, ist mit dem Daumen nicht zu treffen. + Den Platz gibt der eigene Name daneben ab -- er steht ohnehin als + Plakette da, und wessen Sicht laeuft, ist die wichtigere Auskunft. */ + .sicht .wahl2 { min-width: 104px; max-width: 118px; } + .sicht { padding: 3px 4px; } + .wer__name { display: none; } +} +@media (forced-colors: active) { + body[data-fremde-sicht="ja"]::after { border-color: Highlight; } +} diff --git a/workspace/assets/css/wissen.css b/workspace/assets/css/wissen.css index 1e77a7b9..68571e75 100644 --- a/workspace/assets/css/wissen.css +++ b/workspace/assets/css/wissen.css @@ -166,9 +166,33 @@ transition: border-color .18s ease; } .pdf:hover { border-color: rgba(255, 255, 255, .18); } +/* WICHTIG UND NEU MUESSEN BESSER LESBAR SEIN ALS DER REST, nicht + schlechter -- und genau das war hier verkehrt herum. + + `background` ist eine Eigenschaft, keine Schicht: Die Zeile + `background: rgba(232,192,125,.045)` hat die Flaeche var(--flaeche) + nicht getoent, sondern ERSETZT. Uebrig blieben viereinhalb Prozent + Deckung -- das Hintergrundbild kam praktisch ungefiltert durch, und + ausgerechnet die Anleitung, die jeder lesen soll, war die am + schwersten lesbare auf der Seite. + + Jetzt liegt die Toenung als eigene Schicht DARUEBER (ein + linear-gradient aus zweimal derselben Farbe ist der uebliche Weg, aus + einer Farbe eine Schicht zu machen). Die Flaeche bleibt darunter -- + und weil "wichtig" mehr Ruhe verdient als der Rest, ist sie hier + sogar etwas dichter als anderswo. */ .pdf[data-wichtig="ja"] { - border-color: rgba(232, 192, 125, .32); - background: rgba(232, 192, 125, .045); + border-color: rgba(232, 192, 125, .42); + background: + linear-gradient(rgba(232, 192, 125, .07), rgba(232, 192, 125, .07)), + rgba(9, 13, 22, .88); +} +/* Dasselbe fuer das Neue: Es steht nur 48 Stunden da und soll in dieser + Zeit auch wirklich gelesen werden. */ +.pdf:has(.marke-neu) { + background: + linear-gradient(rgba(95, 201, 154, .05), rgba(95, 201, 154, .05)), + rgba(9, 13, 22, .88); } .pdf__kopf { display: flex; gap: 14px; align-items: flex-start; } diff --git a/workspace/assets/js/kopf.js b/workspace/assets/js/kopf.js index 72475b3a..b8328eec 100644 --- a/workspace/assets/js/kopf.js +++ b/workspace/assets/js/kopf.js @@ -672,3 +672,201 @@ return liste.length; }; })(); + +/* =================================================================== + DIE SICHT EINES ANDEREN — nur fuer DogFather, auf jeder Seite. + + Wunsch vom 01.09.2026: "ich will alles von den Scouts und Managern + einsehen koennen, auswaehlen, was und von wem ich sehen will. Und das + soll ich als DogFather-Rolle ueberall haben, auf jeder Seite. + NUR WIR BEIDE SOLLEN DIESE OPTION HABEN." + + ZWEI ENTSCHEIDUNGEN, die alles andere nach sich ziehen. + + 1. DIE PERSON WIRD GETAUSCHT, NICHT DIE REGEL. + Waehlt DogFather einen Scout, laeuft jede vorhandene + Sichtbarkeitsregel auf dem Server unveraendert mit diesem Scout -- + er sieht dann exakt dessen Arbeitsplatz. Kein neuer Filter je + Seite, keine zweite Rechteregel, die irgendwann von der ersten + abweicht. + + 2. EIN EINZIGER ORT IM BROWSER. + Statt in vierzehn Dateien "&sicht=" anzuhaengen (und es in der + fuenfzehnten zu vergessen), wird fetch EINMAL umgeleitet: Jede + lesende Anfrage an /workspace/api bekommt den Wert automatisch + mit. Neue Seiten sind damit von selbst dabei -- man kann es gar + nicht vergessen. + + Angehaengt wird NUR bei GET. Ein Schreibvorgang findet immer im + eigenen Namen statt; alles andere waere ein Eintrag im Protokoll + unter falschem Namen. + + Die Auswahl liegt im Browser (localStorage), nicht am Konto: Sie ist + eine Ansicht dieses Geraets, keine Eigenschaft der Person. Wer sich + woanders anmeldet, faengt bei "alles" an -- und das ist richtig so. + + Der Server prueft die Rolle NOCH EINMAL (workspace.js, sichtPerson). + Was hier steht, ist Bedienung, kein Schutz: Wer kein DogFather ist, + kann den Wert von Hand anhaengen, so oft er mag -- er wird ignoriert. + =================================================================== */ +(() => { + 'use strict'; + + const SCHLUESSEL = 'dfw-sicht'; + + const lesen = () => { + try { return Number(localStorage.getItem(SCHLUESSEL)) || 0; } catch { return 0; } + }; + const schreiben = (id) => { + try { + if (id) localStorage.setItem(SCHLUESSEL, String(id)); + else localStorage.removeItem(SCHLUESSEL); + } catch { /* privater Modus -- dann gilt es nur fuer diese Seite */ } + }; + + window.sichtId = lesen; + + /* ---------- fetch einmal umleiten ------------------------------------- */ + + let aktiv = 0; /* 0 = eigene Sicht */ + const echtesFetch = window.fetch.bind(window); + + window.fetch = function (eingabe, optionen) { + try { + const art = (optionen?.method || (typeof eingabe === 'object' ? eingabe.method : '') || 'GET') + .toUpperCase(); + const pfad = typeof eingabe === 'string' ? eingabe : (eingabe?.url || ''); + if (aktiv && art === 'GET' && pfad.startsWith('/workspace/api/') + && !pfad.includes('sicht=')) { + const neu = pfad + (pfad.includes('?') ? '&' : '?') + 'sicht=' + aktiv; + return echtesFetch(typeof eingabe === 'string' ? neu : new Request(neu, eingabe), optionen); + } + } catch { /* im Zweifel unveraendert weiterreichen */ } + return echtesFetch(eingabe, optionen); + }; + + /* ---------- Der Umschalter im Seitenkopf -------------------------------- */ + + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + + const ROLLE_GRUPPE = [ + ['manager', 'Manager'], + ['scout', 'Scouts'], + ['creator', 'Creator'], + ['admin', 'DogFather'], + ]; + + /* EIN WETTLAUF, den es wirklich gab: Auf profil.html rufen zwei Dateien + werZeigen() auf (profil.js und steckbrief.js). Beide kamen an der + Pruefung "gibt es den Umschalter schon?" vorbei, BEVOR eine von + ihnen ihn angehaengt hatte -- dazwischen liegt ein await auf die + Personenliste. Ergebnis: zwei Umschalter uebereinander, der eine + verdeckte den anderen. Gemeldet von pruef-handy als "verdeckt". + Die Sperre muss deshalb VOR dem ersten await gesetzt werden. */ + let baut = false; + + window.sichtAufbauen = async function (ich) { + if (!ich || ich.rolle !== 'admin') return; + const kopf = document.querySelector('.kopfleiste__rechts'); + if (!kopf || baut || document.getElementById('sicht-wahl')) return; + baut = true; + + aktiv = lesen(); + + let personen = []; + try { + /* Ueber das echte fetch -- diese eine Abfrage darf nicht durch die + gewaehlte Sicht laufen, sonst waere die Auswahlliste selbst + gefiltert und man kaeme nie wieder heraus. */ + const a = await echtesFetch('/workspace/api/personen', { credentials: 'same-origin' }); + if (!a.ok) { baut = false; return; } + personen = (await a.json()).personen || []; + } catch { baut = false; return; } + + const feld = el('div', 'sicht'); + feld.id = 'sicht-wahl'; + + const schild = el('label', 'sicht__schild', 'Sicht'); + schild.setAttribute('for', 'sicht-feld'); + feld.append(schild); + + const s = el('select', 'sicht__feld'); + s.id = 'sicht-feld'; + s.append(new Option('Alles (meine Sicht)', '')); + for (const [rolle, name] of ROLLE_GRUPPE) { + const drin = personen.filter((p) => p.rolle === rolle && p.id !== ich.id && p.aktiv !== false); + if (!drin.length) continue; + const g = document.createElement('optgroup'); + g.label = name; + for (const p of drin) g.append(new Option(p.name, String(p.id))); + s.append(g); + } + if (aktiv) s.value = String(aktiv); + /* Stand die gemerkte Person nicht mehr in der Liste (geloescht oder + abgeschaltet), faellt die Auswahl zurueck -- sonst zeigte das Feld + "Alles" und die Seiten trotzdem eine fremde Sicht. */ + if (aktiv && s.value !== String(aktiv)) { aktiv = 0; schreiben(0); } + + s.addEventListener('change', () => { + aktiv = Number(s.value) || 0; + schreiben(aktiv); + /* Neu laden statt jede Seite einzeln zu benachrichtigen: Jede + Seite baut sich beim Laden ohnehin vollstaendig auf, und ein + halb umgestellter Bildschirm waere schlimmer als eine kurze + Pause. */ + location.reload(); + }); + feld.append(s); + + /* Ein deutlicher Hinweis, SOLANGE eine fremde Sicht aktiv ist. + Ohne ihn ist der gefaehrlichste Fall dieser Funktion: Man vergisst + die Auswahl, sieht am naechsten Tag drei Aufgaben statt dreissig + und haelt das fuer den Bestand. */ + if (aktiv) { + feld.dataset.fremd = 'ja'; + const zurueck = el('button', 'sicht__weg', 'zurück zu mir'); + zurueck.type = 'button'; + zurueck.addEventListener('click', () => { schreiben(0); location.reload(); }); + feld.append(zurueck); + document.body.dataset.fremdeSicht = 'ja'; + } + + kopf.prepend(feld); + }; + + /* An werZeigen anhaengen: Jede Seite ruft es ohnehin auf, sobald sie + weiss, wer angemeldet ist. Damit ist der Umschalter wirklich auf + JEDER Seite da, ohne dass eine davon eine Zeile bekommt. */ + let gerufen = false; + const vorher = window.werZeigen; + window.werZeigen = function (ich) { + gerufen = true; + vorher?.(ich); + window.sichtAufbauen(ich); + }; + + /* DAS NETZ DARUNTER. + + "Jede Seite ruft es ohnehin auf" stimmte nicht ganz: steckbrief.html + tat es nie, und deshalb stand dort oben rechts monatelang "…" statt + des eigenen Namens. Gefunden hat es nicht das Auge, sondern eine + Pruefung, die den Umschalter auf ALLEN Seiten verlangt hat. + + Ein Fehler, der einmal passiert ist, passiert wieder -- die + naechste neue Seite vergisst dieselbe Zeile. Meldet sich also nach + kurzer Zeit niemand, holt der Kopf sich selbst, wer angemeldet ist. + Im Normalfall kostet das nichts: Dann war jemand schneller, und + diese Abfrage findet gar nicht erst statt. */ + setTimeout(async () => { + if (gerufen || !document.getElementById('wer')) return; + try { + const a = await echtesFetch('/workspace/api/ich', { credentials: 'same-origin' }); + if (a.ok) window.werZeigen(await a.json()); + } catch { /* nicht angemeldet -- die Seite leitet ohnehin um */ } + }, 1200); +})(); diff --git a/workspace/assets/js/steckbrief.js b/workspace/assets/js/steckbrief.js index c212b39e..54754d1a 100644 --- a/workspace/assets/js/steckbrief.js +++ b/workspace/assets/js/steckbrief.js @@ -349,6 +349,13 @@ (async () => { try { const ich = await (await hole('/workspace/api/ich')).json().catch(() => null); + /* Die Kopfleiste fuellen -- VOR dem moeglichen Ausstieg unten. + Diese Seite war die einzige im ganzen Workspace, die das nie + getan hat: Oben rechts stand dauerhaft "…" statt des eigenen + Namens. Aufgefallen ist es erst, als der Sicht-Umschalter dort + fehlte, denn der haengt an derselben Stelle. Ein Fehler, den man + monatelang ansehen kann, ohne ihn zu sehen. */ + if (ich) window.werZeigen?.(ich); if (nurCreator && ich && ich.rolle !== 'creator') return; const a = await hole('/workspace/api/steckbrief/mein'); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index e82d3eae..691d668d 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -139,9 +139,9 @@ - - - - + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 585031f2..0abdb2e3 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -132,9 +132,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 128b8ef2..9192814c 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -115,13 +115,13 @@ - - - - - - - - + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index a1fb4437..101ab520 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -54,10 +54,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index e1c1c276..b7a87b21 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -164,11 +164,11 @@ - - - - - - + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index 5780dfd6..ccdab788 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -93,9 +93,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index 19ba3db5..dd45ef78 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -143,6 +143,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index 4a57ddc2..d32fa121 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -147,9 +147,9 @@ - - - - + + + + diff --git a/workspace/personen.html b/workspace/personen.html index f5800e48..a04ac17a 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -121,9 +121,9 @@ - - - - + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 1aa43fac..4beb4053 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -193,10 +193,10 @@ - - - - - + + + + + diff --git a/workspace/report.html b/workspace/report.html index cd367d54..bde160fe 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -91,10 +91,10 @@ - - - - - + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 466aaefe..38e56b1b 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,9 +124,9 @@ - - - - + + + + diff --git a/workspace/start.html b/workspace/start.html index 7d42f5f1..9a13872d 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -99,9 +99,9 @@

- - - - + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 1453ee39..43cbfecc 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -70,10 +70,10 @@ - - - - - + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index f054a979..b78bcd9f 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -111,9 +111,9 @@ - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 71ce2dce..83fbcae9 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -7,9 +7,9 @@ - - - + + + @@ -57,9 +57,9 @@ - + - - + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 786e41dd..e7ed63ef 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -175,9 +175,9 @@ - - - - + + + +