Aufgaben abbrechen, Teilnehmerwahl fuer alle, Benachrichtigungen
Zwei Wuensche vom 05.09.2026, dazu drei Fehler, die dabei ans Licht kamen.
ABBRECHEN (Wunsch: "in jedem status die aufgaben auch abbrechen koennen,
nur ich die manager und scouts")
Neuer Status mit Pflicht-Grund, aus jedem der vier Status heraus.
Festgehalten wird auch, WO die Aufgabe stand -- "im Review abgebrochen"
ist eine andere Aussage als "nie angefangen", und das Wiederaufnehmen
geht dorthin zurueck statt nach "offen".
Eigener Weg statt "abgebrochen" in der Statusliste: Dort entscheidet
darfAendern(), und das laesst auch den zustaendigen Creator aendern.
Der gewoehnliche PATCH kann diesen Zustand deshalb gar nicht erreichen
-- auch nicht fuer DogFather, sonst waere die Grund-Pflicht umgehbar.
Abgebrochenes steht in einem zugeklappten Bereich unter dem Brett, nicht
als fuenfte Spalte: Am Handy waeren dann alle fuenf unlesbar schmal.
Verschwinden darf es nicht, sonst waere der Abbruch ein Loeschen mit
Zwischenschritt.
Die Tabelle musste dafuer getauscht werden (SQLite kann CHECK nicht
aendern). Vorher auf einer Kopie durchgespielt: 40 von 40 Aufgaben,
Inhalte, Verweise und Indizes geprueft, Gegenprobe zeigt, dass der CHECK
noch lebt.
TEILNEHMERWAHL (Wunsch: "das soll viel besser aussehen und fuer jeden
verfuegbar sein")
Auf dem Bildschirm klebten die Namen aneinander: "DogfatherDogFather".
Ursache war, dass kalender.html das Stylesheet mit diesen Klassen nie
eingebunden hat -- sie standen in dateien.css. Vierzig gruene Pruefungen
zur Teilnehmerwahl hatten das nicht gemerkt, weil keine je gefragt hat,
ob es AUSSIEHT wie gedacht.
Jetzt eigene Klassen im eigenen Stylesheet, nach Rollen gruppiert: Die
Rolle steht einmal als Ueberschrift statt neunmal am Namen. Damit ist
das Kleben an der Wurzel weg, nicht zugepflastert.
"Fuer jeden" war mehr als ein hidden zu entfernen: darfEintragen() haette
einem Creator nur sich selbst erlaubt. Er haette seinen Scout gesehen,
angeklickt, und der Server haette ihn still weggelassen -- ein Knopf, der
nichts tut. einladbareIds() schaut jetzt in beide Richtungen, bewusst
getrennt von /api/personen: Wer die erweitert, gibt einem Creator
nebenbei die Moeglichkeit, seinem Scout Aufgaben zuzuweisen.
BENACHRICHTIGUNGEN (Wunsch: "sowas, und dass es perfekt funktioniert
fuer jeden")
Web Push nach RFC 8291/8292, ohne fremde Abhaengigkeit. Der Knopf sagt
in jeder Lage die Wahrheit, auch die unbequemen: abgelehnt (mit dem
Hinweis, wo man es zuruecknimmt), iPhone im Reiter (mit Anleitung),
Browser ohne Push. Ein Knopf, der bei abgelehnter Berechtigung nur
nichts tut, ist der sichere Weg zu "das funktioniert nicht".
DREI FEHLER, DIE DABEI AUFFIELEN
1. Ein defekter Zugangsdatensatz sperrte ALLE einer Rolle aus. Wirft
hashe() bei einer Person, flog die ganze Anmeldung in den catch: 503
"nicht verfuegbar" fuer jeden mit dieser Rolle. Aufgefallen durch
einen eigenen Testfehler. Jetzt wird die defekte Person uebersprungen
und laut protokolliert; die Gegenprobe zeigt, dass ein falscher Code
weiterhin abgelehnt wird.
2. Die Glocke sprengte die Kopfleiste -- zweimal. Bei 320 px lag die
Lupe des Suchknopfes auf dem Sicht-Umschalter (ein Knopf, der auf 12
Seiten ins Leere tippt), bei 768 px wurde der Abmelden-Knopf bis zu
15 px aus dem Bild geschoben, weil die Textgrenze auf 760 stand und
ein Tablet 768 hat. Nachgewiesen durch Messen mit und ohne Glocke,
nicht durch Vermuten.
3. .block__frage war viermal gestaltet und stand auf einer Seite, die
keine dieser Dateien laedt -- derselbe Fehler wie bei der
Teilnehmerwahl. Gefunden von der neuen Klassenpruefung beim ersten
Lauf.
NEUE PRUEFUNGEN
pruef-css-klassen jede gestaltete Klasse muss auf ihrer Seite ankommen
(unterscheidet Struktur-Anker von echtem Verlust)
pruef-dabei-optik die Wahl im Browser, an den echten Pixeln
pruef-abbrechen Umstellung auf einer Kopie, Rechte, Rueckweg
pruef-abbrechen-optik Knopf, Dialog, Bereich, Handy
pruef-glocke Zustaende, An/Abmelden, jede Rolle
pruef-push(-weg) Rechnung gegen die RFC-Vektoren, Zustellung
pruef-struktur prueft jetzt zusaetzlich, ob sich jedes Server-Modul als
ESM laden laesst. node --check auf einer .js-Datei prueft als CommonJS
und meldete "ok", waehrend der Import scheiterte.
Gesamtlauf: 55 von 55 Dateien, 1973 von 1973 Punkten.
Was NICHT geprueft werden konnte und deshalb dasteht: Der Schritt
"Browser holt eine Adresse beim Push-Dienst" braucht eine Verbindung zu
Googles FCM, die ein Pruef-Browser nicht hat. Die Pruefung misst das
zuerst und meldet es als dritten Ausgang, statt gruen zu sein.
Verschluesselung und Zustellung sind getrennt geprueft; diese eine
Strecke beweist sich erst auf dem Server.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+9
-3
@@ -42,6 +42,12 @@ bild-*.png
|
||||
# Aenderung erscheinen.
|
||||
handy-*.png
|
||||
|
||||
# Laufprotokoll der Browserpruefung -- Ergebnis, kein Quelltext
|
||||
pruef-browser-lauf.txt
|
||||
server/pruef-browser-lauf.txt
|
||||
# Laufprotokolle der Pruefungen -- Ergebnis, kein Quelltext.
|
||||
# Ein Muster statt einer Liste: Sonst haette jede neue Pruefung ihre
|
||||
# eigene Zeile gebraucht, und die vergisst man. Genau das ist am
|
||||
# 05.09.2026 passiert -- vier neue Protokolle standen ploetzlich als
|
||||
# Aenderung im Arbeitsstand.
|
||||
pruef-*-lauf.txt
|
||||
server/pruef-*-lauf.txt
|
||||
# Ergebnis des Sammellaufs (tools/alles-pruefen.mjs)
|
||||
gesamtlauf.txt
|
||||
|
||||
@@ -36,6 +36,7 @@ import { vorlagenRouter } from "./workspace-vorlagen.js";
|
||||
import { schulungRouter } from "./workspace-schulung.js";
|
||||
import { ampelRouter } from "./workspace-ampel.js";
|
||||
import { checklisteRouter } from "./workspace-checkliste.js";
|
||||
import { pushRouter, pushStarten } from "./workspace-push.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SITE_DIR = join(__dirname, "..");
|
||||
@@ -148,6 +149,10 @@ app.use(sicherungRouter);
|
||||
JSON in einem <img> ergibt nur ein kaputtes Bild ohne jeden Hinweis.
|
||||
Der Steckbrief bringt seine eigene Schranke mit. */
|
||||
app.use(steckbriefRouter);
|
||||
/* Ebenfalls VOR aufgabenRouter: Der Service Worker holt den
|
||||
oeffentlichen Schluessel, bevor irgendetwas anderes laeuft, und die
|
||||
Push-Wege bringen ihre eigene Schranke mit. */
|
||||
app.use(pushRouter);
|
||||
app.use(vorlagenRouter);
|
||||
app.use(schulungRouter);
|
||||
app.use(ampelRouter);
|
||||
@@ -182,6 +187,12 @@ sicherungStarten();
|
||||
Call-Liste oder die Suche benutzt -- die lesen dieselbe Tabelle. */
|
||||
serienStarten();
|
||||
|
||||
/* Benachrichtigungen. Eigener Takt im Prozess -- wie die Sicherung und
|
||||
der Serien-Nachfueller: kein Systemdienst, kein Cron, eine Sache
|
||||
weniger, die stillschweigend ausfallen kann. Begruendung im Kopf von
|
||||
workspace-push.js. */
|
||||
pushStarten();
|
||||
|
||||
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
|
||||
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
|
||||
ohne diese Sperre daher nicht nur die eigentliche Website aus, sondern auch den Server-
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/* ABBRECHEN — WIE ES SICH BEDIENEN LÄSST.
|
||||
|
||||
pruef-abbrechen.mjs beweist, dass der Server richtig entscheidet.
|
||||
Diese Prüfung fragt das, was heute früh niemand gefragt hatte:
|
||||
Kommt davon auch etwas auf dem Bildschirm an?
|
||||
|
||||
Der Anlass ist derselbe Tag. Vierzig grüne Prüfungen zur
|
||||
Teilnehmerwahl -- und auf Filipes Schirm klebten die Namen
|
||||
aneinander, weil kein einziger Test je hingesehen hatte. Ein Server,
|
||||
der alles richtig macht, nützt nichts, wenn der Knopf dazu fehlt.
|
||||
|
||||
GEMESSEN WIRD:
|
||||
* Sieht ein Creator den Abbrechen-Knopf ÜBERHAUPT NICHT?
|
||||
(Der Server lehnt ihn ohnehin ab -- aber einen Knopf anzubieten,
|
||||
der dann 403 sagt, ist eine Zumutung.)
|
||||
* Steht er in JEDEM Status an der Karte?
|
||||
* Verlangt der Dialog wirklich einen Grund?
|
||||
* Verschwindet die Aufgabe aus dem Brett und taucht unten wieder
|
||||
auf -- mit Grund, Person und dem Status, in dem sie war?
|
||||
* Und der Rückweg.
|
||||
|
||||
GEGENPROBEN: Jede Sperre wird auch von der erlaubten Seite versucht. */
|
||||
|
||||
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-abopt-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4297, "die Optikpruefung des Abbrechens");
|
||||
|
||||
process.env.PORT = "4297";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
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, 900));
|
||||
const BASIS = "http://127.0.0.1:4297";
|
||||
|
||||
const PROTOKOLL = join(process.cwd(), "pruef-abbrechen-optik-lauf.txt");
|
||||
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
|
||||
const melde = (z) => {
|
||||
console.log(z);
|
||||
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
|
||||
};
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Bestand ------------------------------------------------------------ */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
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();
|
||||
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 idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
|
||||
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idScout, jetzt);
|
||||
|
||||
/* Eine Aufgabe je Status -- der Wunsch lautet ausdrücklich "in JEDEM
|
||||
Status", also muss auch jeder einzeln nachgewiesen werden. */
|
||||
for (const s of ["offen", "arbeit", "review", "erledigt"]) {
|
||||
d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id,
|
||||
verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,?,'hoch',?,?,?,?)`)
|
||||
.run(`Aufgabe ${s}`, "Beschreibung.", s, idLuna, idLuna, jetzt, idDogi);
|
||||
}
|
||||
d.close();
|
||||
|
||||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await pw.chromium.launch();
|
||||
|
||||
/* Was der Browser meldet, wird mitgeschrieben. Beim ersten Lauf blieb
|
||||
diese Prüfung beim Absenden des Dialogs stehen, und die stumme
|
||||
Konsole sagte nicht, warum -- ein JavaScript-Fehler sieht von außen
|
||||
genauso aus wie "der Server antwortet nicht". */
|
||||
const konsole = [];
|
||||
async function alsRolle(rolle, code, breite = 1280) {
|
||||
const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 } });
|
||||
const seite = await ctx.newPage();
|
||||
seite.on("pageerror", (f) => konsole.push(`pageerror: ${f.message}`));
|
||||
seite.on("console", (m) => {
|
||||
if (m.type() === "error") konsole.push(`console: ${m.text().slice(0, 160)}`);
|
||||
});
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
|
||||
await seite.click(`.rolle[data-rolle="${rolle}"]`);
|
||||
await seite.fill("#code", code);
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 25000 });
|
||||
await seite.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" });
|
||||
await seite.waitForSelector(".karte", { timeout: 15000 });
|
||||
return { ctx, seite };
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
1. Der Knopf — bei wem, und in welchem Status
|
||||
======================================================================= */
|
||||
melde("\n=== Der Abbrechen-Knopf ===");
|
||||
{
|
||||
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001");
|
||||
|
||||
const karten = await seite.$$(".karte");
|
||||
/* DIE ANZAHL IN DIE BEDINGUNG: Ohne Karten liefe alles Folgende über
|
||||
eine leere Menge und meldete grün. */
|
||||
ok(karten.length === 4, `${karten.length} Karten auf dem Brett (erwartet 4, je Status eine)`);
|
||||
|
||||
/* In JEDEM Status -- einzeln nachgewiesen, nicht als Summe. */
|
||||
for (const status of ["offen", "arbeit", "review", "erledigt"]) {
|
||||
const da = await seite.$(`.karte[data-status="${status}"] .schritt--ab`);
|
||||
ok(!!da, `Status "${status}": Abbrechen-Knopf ist da`);
|
||||
}
|
||||
|
||||
/* Er darf die täglichen Knöpfe nicht verdrängen. */
|
||||
const nebeneinander = await seite.$$eval('.karte[data-status="offen"] .schritt',
|
||||
(b) => b.map((x) => x.textContent.trim()));
|
||||
ok(nebeneinander.includes("starten ▶") && nebeneinander.includes("abbrechen"),
|
||||
`auf der Karte stehen: ${nebeneinander.join(" | ")}`);
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
melde("\n=== Ein Creator sieht ihn gar nicht erst ===");
|
||||
{
|
||||
const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001");
|
||||
const karten = (await seite.$$(".karte")).length;
|
||||
const knoepfe = (await seite.$$(".schritt--ab")).length;
|
||||
/* Die Karten müssen DA sein -- sonst bewiese "0 Knöpfe" nur, dass
|
||||
die Seite leer ist. */
|
||||
ok(karten === 4 && knoepfe === 0,
|
||||
`${karten} Karten, aber ${knoepfe} Abbrechen-Knöpfe`);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
2. Der Dialog
|
||||
======================================================================= */
|
||||
melde("\n=== Der Dialog verlangt einen Grund ===");
|
||||
{
|
||||
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001");
|
||||
await seite.click('.karte[data-status="review"] .schritt--ab');
|
||||
await seite.waitForSelector("#abbruch-dialog[open]", { timeout: 5000 });
|
||||
|
||||
const titel = await seite.$eval("#abbruch-titel", (e) => e.textContent);
|
||||
ok(/Aufgabe review/.test(titel), `der Dialog nennt die Aufgabe: ${titel}`);
|
||||
|
||||
/* Sagt er, was passiert -- und was NICHT passiert? Die Sorge bei so
|
||||
einem Knopf ist immer, ob etwas verloren geht. */
|
||||
const was = await seite.$eval("#abbruch-was", (e) => e.textContent);
|
||||
ok(/nicht gelöscht/i.test(was) && /Review/.test(was),
|
||||
`er erklärt es: "${was.trim().slice(0, 95)}…"`);
|
||||
|
||||
/* Ohne Grund abschicken -- der Dialog muss offen bleiben. */
|
||||
await seite.click("#abbruch-ok");
|
||||
await seite.waitForTimeout(300);
|
||||
const nochOffen = await seite.$("#abbruch-dialog[open]");
|
||||
const meldung = await seite.$eval("#abbruch-fehler", (e) => e.textContent.trim());
|
||||
ok(!!nochOffen && meldung.length > 0,
|
||||
`ohne Grund bleibt er offen und sagt: "${meldung}"`);
|
||||
|
||||
/* GEGENPROBE: mit Grund geht er zu und die Aufgabe wandert. */
|
||||
await seite.fill("#abbruch-grund", "Kampagne verschoben – kommt im Oktober neu.");
|
||||
await seite.click("#abbruch-ok");
|
||||
/* NICHT mit waitForSelector, sondern messen und melden: Bleibt der
|
||||
Dialog offen, ist das ein Befund -- kein Grund, die ganze Prüfung
|
||||
mit einer Zeitüberschreitung abbrechen zu lassen. So steht am Ende
|
||||
da, WAS nicht ging, statt gar nichts. */
|
||||
/* `state: "attached"` ist hier Pflicht. waitForSelector wartet sonst
|
||||
darauf, dass das Element SICHTBAR wird -- und ein geschlossener
|
||||
<dialog> ist per Definition unsichtbar. Die Bedingung wäre also nie
|
||||
erfüllt worden, egal wie richtig der Code ist. Beim ersten Lauf
|
||||
stand deshalb "der Dialog bleibt offen", während er längst zu war
|
||||
und die Aufgabe schon in der richtigen Spalte stand. */
|
||||
const zu = await seite.waitForSelector("#abbruch-dialog:not([open])",
|
||||
{ state: "attached", timeout: 8000 }).then(() => true).catch(() => false);
|
||||
ok(zu, zu ? "mit Grund schließt sich der Dialog"
|
||||
: `der Dialog bleibt offen. Konsole: ${konsole.slice(-3).join(" | ") || "still"}`);
|
||||
await seite.waitForTimeout(700);
|
||||
|
||||
const nochImBrett = await seite.$('.karte[data-status="review"]');
|
||||
ok(!nochImBrett, "die Aufgabe steht nicht mehr in der Review-Spalte");
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. Der Bereich unter dem Brett
|
||||
======================================================================= */
|
||||
melde("\n=== Der Bereich 'Abgebrochen' ===");
|
||||
{
|
||||
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001");
|
||||
|
||||
const sichtbar = await seite.isVisible("#abgebrochen");
|
||||
ok(sichtbar, "der Bereich ist da");
|
||||
|
||||
const zahl = await seite.$eval("#abgebrochen-zahl", (e) => e.textContent);
|
||||
ok(zahl === "1", `die Anzahl steht im Titel: ${zahl}`);
|
||||
|
||||
/* ZUGEKLAPPT beim Ankommen -- er soll nicht im Weg stehen. */
|
||||
const offen = await seite.$eval("#abgebrochen", (e) => e.hasAttribute("open"));
|
||||
ok(!offen, "er ist zugeklappt, wie gewollt");
|
||||
|
||||
/* Aufklappen und nachsehen, ob wirklich alles drinsteht. */
|
||||
await seite.click(".abgebrochen__kopf");
|
||||
await seite.waitForTimeout(350);
|
||||
|
||||
const karte = await seite.$(".abbruch-karte");
|
||||
ok(!!karte, "aufgeklappt steht die Aufgabe da");
|
||||
|
||||
const grund = await seite.$eval(".abbruch-karte__grund", (e) => e.textContent.trim());
|
||||
ok(/Kampagne verschoben/.test(grund), `der Grund steht dran: "${grund}"`);
|
||||
|
||||
const vorher = await seite.$eval(".abbruch-karte__vorher", (e) => e.textContent.trim());
|
||||
ok(vorher === "Review", `und in welchem Status sie war: "${vorher}"`);
|
||||
|
||||
const wer = await seite.$eval(".abbruch-karte__wer", (e) => e.textContent.trim());
|
||||
ok(/Filipe/.test(wer), `und wer es war: "${wer}"`);
|
||||
|
||||
/* Der Durchstrich ist hier das eigentliche Merkmal -- gemessen am
|
||||
gerechneten Stil, nicht an der CSS-Datei. */
|
||||
const strich = await seite.$eval(".abbruch-karte__titel",
|
||||
(e) => getComputedStyle(e).textDecorationLine);
|
||||
ok(strich.includes("line-through"), `der Titel ist durchgestrichen (${strich})`);
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
4. Zählt Abgebrochenes noch als offen?
|
||||
======================================================================= */
|
||||
melde("\n=== Abgebrochenes mahnt nicht mehr ===");
|
||||
{
|
||||
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001");
|
||||
/* Drei Aufgaben laufen noch (offen, arbeit, erledigt), eine ist
|
||||
abgebrochen. "Offen" sind davon zwei -- die abgebrochene darf
|
||||
nicht mitgezählt werden, sonst mahnt die Seite etwas an, das
|
||||
ausdrücklich niemand mehr tun soll. */
|
||||
const satz = await seite.$eval("#lage", (e) => e.textContent.trim());
|
||||
ok(/2 Aufgaben stehen an/.test(satz), `die Lage sagt: "${satz}"`);
|
||||
|
||||
const alle = await seite.$eval('.schnitt[data-wert=""] .schnitt__zahl', (e) => e.textContent);
|
||||
ok(alle === "4", `der Filter "Alle" zählt weiterhin alle ${alle} (auch die abgebrochene)`);
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
5. Der Rückweg
|
||||
======================================================================= */
|
||||
melde("\n=== Wieder aufnehmen ===");
|
||||
{
|
||||
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001");
|
||||
await seite.click(".abgebrochen__kopf");
|
||||
await seite.waitForSelector(".abbruch-karte", { timeout: 5000 });
|
||||
await seite.click(".abbruch-karte .schritt");
|
||||
await seite.waitForTimeout(900);
|
||||
|
||||
/* ZURÜCK IN DEN REVIEW, nicht nach "offen". */
|
||||
const zurueck = await seite.$('.karte[data-status="review"]');
|
||||
ok(!!zurueck, "die Aufgabe steht wieder in der Review-Spalte (nicht bei Offen)");
|
||||
|
||||
const weg = await seite.isVisible("#abgebrochen");
|
||||
ok(!weg, "der Bereich verschwindet, wenn nichts mehr abgebrochen ist");
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
6. Am Handy
|
||||
======================================================================= */
|
||||
melde("\n=== Am Handy (390 px) ===");
|
||||
{
|
||||
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 390);
|
||||
await seite.click('.karte[data-status="offen"] .schritt--ab');
|
||||
await seite.waitForSelector("#abbruch-dialog[open]", { timeout: 5000 });
|
||||
|
||||
const passt = await seite.evaluate(() => {
|
||||
const dlg = document.querySelector("#abbruch-dialog");
|
||||
const r = dlg.getBoundingClientRect();
|
||||
return { ueber: Math.round(r.right - window.innerWidth), breite: Math.round(r.width) };
|
||||
});
|
||||
ok(passt.ueber <= 0, `der Dialog passt auf den Schirm (${passt.breite} px, ${passt.ueber} px Überstand)`);
|
||||
|
||||
/* Das Textfeld muss groß genug sein, um mit dem Daumen getroffen zu
|
||||
werden -- und um zu sehen, was man tippt. */
|
||||
const feld = await seite.$eval("#abbruch-grund",
|
||||
(e) => Math.round(e.getBoundingClientRect().height));
|
||||
ok(feld >= 60, `das Grundfeld ist ${feld} px hoch`);
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE — misst die Prüfung überhaupt etwas?
|
||||
======================================================================= */
|
||||
melde("\n=== Gegenprobe ===");
|
||||
{
|
||||
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001");
|
||||
/* Wenn der Selektor `.schritt--ab` gar nichts träfe, wären alle
|
||||
Haken oben wertlos. Also: Gibt es ihn, und verschwindet er, wenn
|
||||
man ihn absichtlich entfernt? */
|
||||
const vorher = (await seite.$$(".schritt--ab")).length;
|
||||
await seite.evaluate(() => {
|
||||
for (const b of document.querySelectorAll(".schritt--ab")) b.remove();
|
||||
});
|
||||
const nachher = (await seite.$$(".schritt--ab")).length;
|
||||
ok(vorher === 4 && nachher === 0,
|
||||
`der Selektor trifft wirklich (${vorher} gefunden, nach dem Entfernen ${nachher})`);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
|
||||
melde(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,405 @@
|
||||
/* AUFGABEN ABBRECHEN — Umstellung, Rechte und Rückweg.
|
||||
|
||||
Wunsch Filipe, 05.09.2026: *"ich will dass man in jedem status die
|
||||
aufgaben auch abbrechen kann. nur ich die manager und scouts sollen
|
||||
auch die aufgaben abbrechen können."*
|
||||
|
||||
ZWEI TEILE, und der erste ist der gefährlichere:
|
||||
|
||||
1. DIE UMSTELLUNG DER TABELLE. Der CHECK-Constraint einer Tabelle
|
||||
lässt sich in SQLite nicht ändern -- die Tabelle muss neu gebaut
|
||||
werden: anlegen, kopieren, alte löschen, umbenennen. Zwischen
|
||||
"kopieren" und "löschen" steht der ganze Bestand an Aufgaben.
|
||||
Hausregel dazu: *"Was sich nicht zurücknehmen lässt, wird vorher
|
||||
auf einer Kopie durchgespielt."* Genau das passiert hier -- mit
|
||||
einer Datenbank im ALTEN Zustand, gefüllt, und danach wird
|
||||
gezählt.
|
||||
|
||||
2. DIE RECHTE. Ein Creator darf nicht abbrechen, auch nicht seine
|
||||
eigene Aufgabe. Das ist der Punkt, an dem so etwas gern
|
||||
durchrutscht: Der normale PATCH lässt ihn ändern, und stünde
|
||||
"abgebrochen" einfach in der Statusliste, könnte er es darüber.
|
||||
|
||||
GEGENPROBEN durchgehend: Jede Sperre wird auch von der erlaubten
|
||||
Seite versucht -- eine Prüfung, die immer "verboten" sagt, beweist
|
||||
nichts. */
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-abbruch-"));
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* =======================================================================
|
||||
TEIL 1 — Die Umstellung, auf einer Kopie im alten Zustand
|
||||
======================================================================= */
|
||||
console.log("\n=== Umstellung der Aufgabentabelle (auf einer Kopie) ===");
|
||||
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const altPfad = join(ordner, "alt.db");
|
||||
{
|
||||
/* Eine Datenbank genau so, wie sie VOR der Umstellung aussah --
|
||||
mit dem alten CHECK ohne 'abgebrochen'. */
|
||||
const a = new DatabaseSync(altPfad);
|
||||
a.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE personen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
|
||||
rolle TEXT NOT NULL CHECK (rolle IN ('admin','manager','scout','creator')),
|
||||
code_hash TEXT NOT NULL, code_salt TEXT NOT NULL, code_n INTEGER NOT NULL,
|
||||
aktiv INTEGER NOT NULL DEFAULT 1, erstellt TEXT NOT NULL, letzter_login TEXT);
|
||||
CREATE TABLE aufgaben (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
titel TEXT NOT NULL, beschreibung TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'offen'
|
||||
CHECK (status IN ('offen','arbeit','review','erledigt')),
|
||||
prioritaet TEXT NOT NULL DEFAULT 'mittel'
|
||||
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
|
||||
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
frist TEXT, erstellt TEXT NOT NULL,
|
||||
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
geaendert TEXT, erledigt_am TEXT);
|
||||
`);
|
||||
const jetzt = new Date().toISOString();
|
||||
/* Diese Person ist nur das Ziel der Fremdschlüssel. Ihre
|
||||
scrypt-Parameter müssen trotzdem gültig sein: Mit code_n = 1 wirft
|
||||
scrypt "Invalid scrypt params" -- und genau das hat beim ersten
|
||||
Lauf dieser Prüfung JEDE Anmeldung als admin unmöglich gemacht.
|
||||
Der Fehler steckte in dieser Zeile, aber er hat einen echten
|
||||
Mangel im Server aufgedeckt (siehe unten, Abschnitt
|
||||
"Ein defekter Datensatz"). */
|
||||
a.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, erstellt) VALUES (?,?,?,?,?,?)")
|
||||
.run("Platzhalter", "creator", "x", "y", 16384, jetzt);
|
||||
/* Genug Zeilen, dass ein Verlust auffiele -- und in jedem Status,
|
||||
damit auch der Übergang jedes einzelnen geprüft ist. */
|
||||
for (let i = 0; i < 40; i++) {
|
||||
a.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id,
|
||||
verantwortlich_id, frist, erstellt, erstellt_von, geaendert, erledigt_am)
|
||||
VALUES (?,?,?,?,1,1,?,?,1,?,?)`)
|
||||
.run(`Aufgabe ${i}`, `Text ${i}`, ["offen", "arbeit", "review", "erledigt"][i % 4],
|
||||
["hoch", "mittel", "niedrig"][i % 3], "2026-09-30", jetzt, jetzt,
|
||||
i % 4 === 3 ? jetzt : null);
|
||||
}
|
||||
a.close();
|
||||
}
|
||||
|
||||
/* Der Server wird jetzt AUF DIESER Datei gestartet -- die Umstellung
|
||||
läuft beim Import. */
|
||||
process.env.WORKSPACE_DB = altPfad;
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4295, "die Abbruchpruefung");
|
||||
process.env.PORT = "4295";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
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, 1400));
|
||||
const BASIS = "http://127.0.0.1:4295";
|
||||
|
||||
{
|
||||
const p = new DatabaseSync(altPfad);
|
||||
const plan = p.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='aufgaben'").get()?.sql || "";
|
||||
ok(plan.includes("'abgebrochen'"), "der CHECK erlaubt jetzt 'abgebrochen'");
|
||||
|
||||
/* DIE ANZAHL IST DER EIGENTLICHE BEWEIS. Ein Tabellentausch, der
|
||||
Zeilen verliert, sieht ohne diese Zahl aus wie ein Erfolg. */
|
||||
const n = p.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
|
||||
ok(n === 40, `alle ${n} von 40 Aufgaben sind noch da`);
|
||||
|
||||
/* Und ihr INHALT, nicht nur ihre Zahl. Eine vergessene Spalte in der
|
||||
INSERT...SELECT-Liste füllt sie still mit NULL. */
|
||||
const eine = p.prepare("SELECT * FROM aufgaben WHERE titel = 'Aufgabe 7'").get();
|
||||
ok(eine?.beschreibung === "Text 7" && eine?.status === "erledigt"
|
||||
&& eine?.prioritaet === "mittel" && eine?.frist === "2026-09-30"
|
||||
&& eine?.erledigt_am, `Inhalte erhalten (Text, Status, Priorität, Frist, erledigt_am)`);
|
||||
|
||||
const spalten = p.prepare("PRAGMA table_info(aufgaben)").all().map((s) => s.name);
|
||||
const noetig = ["abbruch_grund", "abgebrochen_am", "abbruch_von", "status_vorher"];
|
||||
const fehlend = noetig.filter((s) => !spalten.includes(s));
|
||||
ok(fehlend.length === 0, fehlend.length
|
||||
? `es fehlen: ${fehlend.join(", ")}` : `alle vier Abbruchspalten sind da`);
|
||||
|
||||
/* Verweise: Der Tausch lief mit ausgeschalteten Fremdschlüsseln. */
|
||||
const kaputt = p.prepare("PRAGMA foreign_key_check").all();
|
||||
ok(kaputt.length === 0, `${kaputt.length} verwaiste Verweise nach dem Tausch`);
|
||||
|
||||
/* Und die Indizes -- sie hängen an der Tabelle und wären beim DROP
|
||||
mitgegangen. Ohne sie liest jede Brettabfrage die ganze Tabelle. */
|
||||
const idx = p.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='aufgaben'").all()
|
||||
.map((z) => z.name);
|
||||
ok(idx.includes("idx_aufgaben_status") && idx.includes("idx_aufgaben_creator"),
|
||||
`Indizes wieder da: ${idx.filter((x) => x.startsWith("idx_")).join(", ")}`);
|
||||
|
||||
/* GEGENPROBE: Der alte CHECK hätte 'abgebrochen' abgelehnt. Lässt
|
||||
die Tabelle jetzt ALLES zu, wäre der CHECK verlorengegangen -- das
|
||||
wäre schlimmer als der alte Zustand. */
|
||||
let unsinnGing = false;
|
||||
try {
|
||||
p.prepare("UPDATE aufgaben SET status = 'quatsch' WHERE id = 1").run();
|
||||
unsinnGing = true;
|
||||
} catch { unsinnGing = false; }
|
||||
ok(!unsinnGing, "ein erfundener Status wird weiterhin abgelehnt — der CHECK lebt");
|
||||
p.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
TEIL 2 — Rechte, am laufenden Server
|
||||
======================================================================= */
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
const d = new DatabaseSync(altPfad);
|
||||
const jetzt = new Date().toISOString();
|
||||
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("Dogi", "admin", "CODE-DOGI-0001");
|
||||
const idMana = anlegen("Cigdem", "manager", "CODE-MANA-0001");
|
||||
const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
|
||||
const idCrea = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idCrea, idScout, jetzt);
|
||||
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idScout, idMana, jetzt);
|
||||
|
||||
/** Legt eine Aufgabe an und gibt ihre Nummer zurück. */
|
||||
function aufgabe(status = "offen") {
|
||||
d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, creator_id, verantwortlich_id,
|
||||
erstellt, erstellt_von) VALUES (?,?,'hoch',?,?,?,?)`)
|
||||
.run(`Probe ${status} ${Math.abs(Number(process.hrtime.bigint() % 100000n))}`,
|
||||
status, idCrea, idCrea, jetzt, idDogi);
|
||||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
}
|
||||
|
||||
/** Meldet sich an und gibt die Kekse zurück. */
|
||||
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 keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
|
||||
return { ok: a.ok, keks };
|
||||
}
|
||||
const rufe = (keks, weg, koerper, methode = "POST") =>
|
||||
fetch(BASIS + weg, {
|
||||
method: methode,
|
||||
headers: { "Content-Type": "application/json", Cookie: keks },
|
||||
body: koerper === undefined ? undefined : JSON.stringify(koerper),
|
||||
});
|
||||
|
||||
const dogi = await anmelden("admin", "CODE-DOGI-0001");
|
||||
const mana = await anmelden("manager", "CODE-MANA-0001");
|
||||
const scout = await anmelden("scout", "CODE-SCOU-0001");
|
||||
const crea = await anmelden("creator", "CODE-CREA-0001");
|
||||
/* Ohne diese Prüfung liefe alles Folgende gegen nicht angemeldete
|
||||
Aufrufe -- und "403" käme dann aus dem falschen Grund. */
|
||||
ok([dogi, mana, scout, crea].every((x) => x.ok && x.keks),
|
||||
"alle vier Rollen sind angemeldet");
|
||||
|
||||
console.log("\n=== Wer darf abbrechen? ===");
|
||||
{
|
||||
for (const [name, wer, sollGehen] of [
|
||||
["DogFather", dogi, true], ["Manager", mana, true],
|
||||
["Scout", scout, true], ["Creator", crea, false],
|
||||
]) {
|
||||
const id = aufgabe("offen");
|
||||
const a = await rufe(wer.keks, `/workspace/api/aufgaben/${id}/abbrechen`,
|
||||
{ grund: "Kampagne verschoben." });
|
||||
const stand = d.prepare("SELECT status FROM aufgaben WHERE id = ?").get(id)?.status;
|
||||
ok(a.ok === sollGehen && (stand === "abgebrochen") === sollGehen,
|
||||
`${name}: ${a.status} → Status "${stand}" (${sollGehen ? "darf" : "darf NICHT"})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n=== Aus JEDEM Status heraus ===");
|
||||
{
|
||||
for (const status of ["offen", "arbeit", "review", "erledigt"]) {
|
||||
const id = aufgabe(status);
|
||||
const a = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`,
|
||||
{ grund: `Aus ${status} heraus abgebrochen.` });
|
||||
const z = d.prepare(
|
||||
"SELECT status, status_vorher, abbruch_grund, abbruch_von, erledigt_am FROM aufgaben WHERE id = ?").get(id);
|
||||
ok(a.ok && z.status === "abgebrochen" && z.status_vorher === status
|
||||
&& z.abbruch_grund && z.abbruch_von === idDogi,
|
||||
`aus "${status}": abgebrochen, status_vorher="${z.status_vorher}", Grund und Person festgehalten`);
|
||||
/* Ein abgebrochenes "erledigt" darf kein Erledigt-Datum behalten --
|
||||
sonst zählt es in Auswertungen weiter als geschafft. */
|
||||
if (status === "erledigt") {
|
||||
ok(z.erledigt_am === null, "beim Abbrechen eines erledigten wird erledigt_am geleert");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n=== Der Grund ist Pflicht ===");
|
||||
{
|
||||
const id = aufgabe("offen");
|
||||
for (const [was, koerper] of [
|
||||
["ganz ohne Grund", {}],
|
||||
["leerer Grund", { grund: "" }],
|
||||
["nur Leerzeichen", { grund: " " }],
|
||||
["zu kurz", { grund: "x" }],
|
||||
]) {
|
||||
const a = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`, koerper);
|
||||
const stand = d.prepare("SELECT status FROM aufgaben WHERE id = ?").get(id)?.status;
|
||||
ok(a.status === 400 && stand === "offen", `${was}: abgelehnt (${a.status}), Aufgabe unverändert`);
|
||||
}
|
||||
/* GEGENPROBE: Mit Grund geht es -- sonst prüfte das oben nichts. */
|
||||
const a = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`,
|
||||
{ grund: "Jetzt mit Grund." });
|
||||
ok(a.ok, "mit Grund geht es — die Prüfung unterscheidet also");
|
||||
|
||||
/* Zu lang wird ebenfalls abgelehnt, nicht abgeschnitten. */
|
||||
const id2 = aufgabe("offen");
|
||||
const b = await rufe(dogi.keks, `/workspace/api/aufgaben/${id2}/abbrechen`,
|
||||
{ grund: "z".repeat(501) });
|
||||
ok(b.status === 400, `501 Zeichen: abgelehnt (${b.status})`);
|
||||
}
|
||||
|
||||
console.log("\n=== Der Weg zurück ===");
|
||||
{
|
||||
const id = aufgabe("review");
|
||||
await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`, { grund: "Doch nicht nötig." });
|
||||
const a = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/wiederaufnehmen`);
|
||||
const z = d.prepare(
|
||||
"SELECT status, status_vorher, abbruch_grund, abgebrochen_am, abbruch_von FROM aufgaben WHERE id = ?").get(id);
|
||||
/* ZURÜCK IN DEN ALTEN STATUS, nicht nach "offen". Wer im Review
|
||||
abbricht und wieder aufnimmt, will dort weitermachen. */
|
||||
ok(a.ok && z.status === "review", `zurück in "${z.status}" (nicht nach "offen")`);
|
||||
ok(!z.status_vorher && !z.abbruch_grund && !z.abgebrochen_am && !z.abbruch_von,
|
||||
"die Abbruchfelder sind wieder leer — keine Reste");
|
||||
|
||||
/* Ein Creator darf auch das nicht. */
|
||||
const id2 = aufgabe("arbeit");
|
||||
await rufe(dogi.keks, `/workspace/api/aufgaben/${id2}/abbrechen`, { grund: "Test." });
|
||||
const b = await rufe(crea.keks, `/workspace/api/aufgaben/${id2}/wiederaufnehmen`);
|
||||
ok(b.status === 403, `Creator darf nicht wiederaufnehmen (${b.status})`);
|
||||
|
||||
/* Zweimal abbrechen ergibt keinen Sinn und wird erkannt. */
|
||||
const c = await rufe(dogi.keks, `/workspace/api/aufgaben/${id2}/abbrechen`, { grund: "Nochmal." });
|
||||
ok(c.status === 409, `zweimal abbrechen: abgelehnt (${c.status})`);
|
||||
|
||||
/* Und etwas Laufendes wiederaufnehmen ebenso wenig. */
|
||||
const id3 = aufgabe("offen");
|
||||
const e = await rufe(dogi.keks, `/workspace/api/aufgaben/${id3}/wiederaufnehmen`);
|
||||
ok(e.status === 409, `etwas Laufendes wiederaufnehmen: abgelehnt (${e.status})`);
|
||||
}
|
||||
|
||||
console.log("\n=== Der Umweg über den normalen PATCH ist zu ===");
|
||||
{
|
||||
/* DER PUNKT, AN DEM ES DURCHRUTSCHEN WÜRDE: Stünde "abgebrochen"
|
||||
einfach in der STATUS-Liste, könnte ein Creator seine eigene
|
||||
Aufgabe über den gewöhnlichen PATCH abbrechen -- ohne Grund, ohne
|
||||
Protokoll, und an der Rollenregel vorbei. */
|
||||
const id = aufgabe("offen");
|
||||
const a = await rufe(crea.keks, `/workspace/api/aufgaben/${id}`,
|
||||
{ status: "abgebrochen" }, "PATCH");
|
||||
const stand = d.prepare("SELECT status FROM aufgaben WHERE id = ?").get(id)?.status;
|
||||
ok(a.status === 400 && stand === "offen",
|
||||
`Creator per PATCH: abgelehnt (${a.status}), Status bleibt "${stand}"`);
|
||||
|
||||
/* Auch DogFather kommt hier nicht durch -- der Grund wäre sonst
|
||||
umgehbar, und ohne Grund ist ein Abbruch ein Löschen. */
|
||||
const b = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}`,
|
||||
{ status: "abgebrochen" }, "PATCH");
|
||||
ok(b.status === 400, `auch DogFather per PATCH: abgelehnt (${b.status}) — der Grund ist nicht umgehbar`);
|
||||
|
||||
/* GEGENPROBE: Ein echter Statuswechsel geht weiterhin. */
|
||||
const c = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}`,
|
||||
{ status: "arbeit" }, "PATCH");
|
||||
ok(c.ok, "ein normaler Statuswechsel geht weiterhin — der PATCH ist nicht kaputt");
|
||||
}
|
||||
|
||||
console.log("\n=== Sichtbarkeit bleibt gewahrt ===");
|
||||
{
|
||||
/* Ein Scout darf keine Aufgabe abbrechen, die er gar nicht sehen
|
||||
darf -- sonst wäre die Nummer ein Weg, fremde Aufgaben anzufassen.
|
||||
Dafür eine Aufgabe eines Creators, der niemandem zugeteilt ist. */
|
||||
const idFremd = anlegen("Nora", "creator", "CODE-CREA-0002");
|
||||
d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, creator_id, verantwortlich_id,
|
||||
erstellt, erstellt_von) VALUES ('Fremde Aufgabe','offen','hoch',?,?,?,?)`)
|
||||
.run(idFremd, idFremd, jetzt, idDogi);
|
||||
const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
|
||||
const a = await rufe(scout.keks, `/workspace/api/aufgaben/${id}/abbrechen`,
|
||||
{ grund: "Sollte nicht gehen." });
|
||||
const stand = d.prepare("SELECT status FROM aufgaben WHERE id = ?").get(id)?.status;
|
||||
/* 404 und nicht 403: Was man nicht sehen darf, existiert nicht --
|
||||
sonst liesse sich durch Ausprobieren herausfinden, welche Nummern
|
||||
es gibt. */
|
||||
ok(a.status === 404 && stand === "offen",
|
||||
`Scout an einer fremden Aufgabe: ${a.status}, Status bleibt "${stand}"`);
|
||||
|
||||
/* GEGENPROBE: An einer EIGENEN geht es. */
|
||||
const idEigen = aufgabe("offen");
|
||||
const b = await rufe(scout.keks, `/workspace/api/aufgaben/${idEigen}/abbrechen`,
|
||||
{ grund: "Bei seinem Creator." });
|
||||
ok(b.ok, "an der Aufgabe seines eigenen Creators geht es — die Sperre trifft das Richtige");
|
||||
}
|
||||
|
||||
console.log("\n=== Ein defekter Datensatz sperrt nicht alle aus ===");
|
||||
{
|
||||
/* GEFUNDEN DURCH EINEN EIGENEN FEHLER, 05.09.2026.
|
||||
|
||||
Im ersten Entwurf dieser Prüfung legte ich eine Person mit
|
||||
code_n = 1 an. scrypt lehnt das ab ("Invalid scrypt params"), die
|
||||
Anmeldeschleife hatte keinen Schutz, und die Ausnahme flog bis in
|
||||
den catch am Ende der Route: 503 für JEDEN mit dieser Rolle.
|
||||
|
||||
Das ist keine Kleinigkeit. Ein einziger krummer Datensatz -- aus
|
||||
einem Import, einer Migration, einer Reparatur von Hand -- hätte
|
||||
ein ganzes Team ausgesperrt, mit der Meldung "nicht verfügbar".
|
||||
Da sucht man lange.
|
||||
|
||||
Deshalb wird es hier absichtlich herbeigeführt und geprüft. */
|
||||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
||||
.run("Kaputt", "manager", "aa", "bb", 1, jetzt);
|
||||
|
||||
const trotzdem = await anmelden("manager", "CODE-MANA-0001");
|
||||
ok(trotzdem.ok && trotzdem.keks,
|
||||
trotzdem.ok ? "trotz eines defekten Datensatzes kommt die gesunde Person herein"
|
||||
: `AUSGESPERRT — ein krummer Datensatz blockiert die ganze Rolle`);
|
||||
|
||||
/* GEGENPROBE: Ein falscher Code wird trotzdem noch abgelehnt. Ein
|
||||
try/catch, das zu viel verschluckt, wäre schlimmer als das
|
||||
Problem -- es könnte jeden hereinlassen. */
|
||||
const falsch = await anmelden("manager", "CODE-FALSCH-9999");
|
||||
ok(!falsch.ok, `ein falscher Code wird weiterhin abgelehnt (${falsch.ok ? "NEIN!" : "ja"})`);
|
||||
|
||||
d.prepare("DELETE FROM personen WHERE name = 'Kaputt'").run();
|
||||
}
|
||||
|
||||
console.log("\n=== Was die Oberfläche zu sehen bekommt ===");
|
||||
{
|
||||
const id = aufgabe("arbeit");
|
||||
await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`, { grund: "Sichtbarer Grund." });
|
||||
const a = await fetch(BASIS + "/workspace/api/aufgaben", { headers: { Cookie: dogi.keks } });
|
||||
const antwort = await a.json();
|
||||
/* Die Liste kann unter verschiedenen Namen kommen -- steht sie gar
|
||||
nicht da, ist das ein Fehler und kein Grund zum Abstürzen. */
|
||||
const liste = antwort.aufgaben || antwort.eintraege || antwort.liste;
|
||||
ok(Array.isArray(liste), Array.isArray(liste)
|
||||
? `die Liste kam an (${liste.length} Aufgaben)`
|
||||
: `unerwartete Antwort: ${JSON.stringify(antwort).slice(0, 90)}`);
|
||||
const eine = (liste || []).find((x) => x.id === id);
|
||||
ok(eine?.abbruch_grund === "Sichtbarer Grund." && eine?.status_vorher === "arbeit"
|
||||
&& eine?.abbruch_von_name === "Dogi" && eine?.abgebrochen_am,
|
||||
`Grund, vorheriger Status, Name und Zeitpunkt kommen in der Liste mit`);
|
||||
|
||||
/* Und sie steht nicht mehr in einer der vier Brettspalten. */
|
||||
ok(eine?.status === "abgebrochen",
|
||||
`Status "${eine?.status}" — fällt damit aus allen vier Spalten heraus`);
|
||||
}
|
||||
|
||||
d.close();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,327 @@
|
||||
/* STEHT DAS CSS AUCH AUF DER SEITE, DIE ES BENUTZT?
|
||||
|
||||
ENTSTANDEN AUS EINEM ECHTEN FEHLER (05.09.2026).
|
||||
|
||||
Die Teilnehmerwahl im Kalender benutzte `.wahl__knopf` und
|
||||
`.wahl__rolle`. Beide sind sauber definiert -- in dateien.css. Und
|
||||
kalender.html bindet dateien.css nicht ein.
|
||||
|
||||
Ohne `display:inline-flex` und `gap` stehen zwei <span> eben direkt
|
||||
nebeneinander. Auf Filipes Bildschirm stand deshalb:
|
||||
|
||||
DogfatherDogFather VanVanDogFather CigdemManager
|
||||
|
||||
Vierzig Prüfungen zur Teilnehmerwahl waren grün. Sie prüften, dass
|
||||
die richtigen Personen ankommen, dass Fremde weggefiltert werden,
|
||||
dass die Sichtbarkeit stimmt -- alles richtig, und alles am Problem
|
||||
vorbei. KEINE hat gefragt, ob das Ergebnis auch aussieht wie
|
||||
gedacht. Das ist die dritte Sorte Fehler aus der Projektnotiz: nicht
|
||||
der übersprungene Test, nicht der grüne, der das Falsche prüft,
|
||||
sondern die Frage, die niemand gestellt hat.
|
||||
|
||||
WAS HIER GEMESSEN WIRD
|
||||
|
||||
Für jede Workspace-Seite:
|
||||
1. Welche Stylesheets bindet sie ein? (rekursiv über @import)
|
||||
2. Welche Klassen vergibt sie -- im HTML *und* in ihrem eigenen
|
||||
JavaScript (className, classList.add, setAttribute('class')).
|
||||
3. Ist jede davon in einem der eingebundenen Stylesheets definiert?
|
||||
|
||||
WAS BEWUSST NICHT GEMESSEN WIRD
|
||||
|
||||
Klassen, die es nur zum Anfassen gibt (`js-`), reine Datenanker und
|
||||
Klassen aus gemeinsamen Bausteinen, die jede Seite lädt. Und die
|
||||
Gegenrichtung ("CSS ohne Benutzer") gehört nicht hierher: Ungenutztes
|
||||
CSS ist Ballast, kein Fehler.
|
||||
|
||||
DRITTER AUSGANG: Findet die Prüfung keine Seiten oder keine Klassen,
|
||||
ist das kein "bestanden" -- dann hat sie nichts gemessen und sagt es.
|
||||
|
||||
GEGENPROBE am Ende: Eine erfundene Klasse muss auffallen, und eine
|
||||
echte darf es nicht. */
|
||||
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HIER = dirname(fileURLToPath(import.meta.url));
|
||||
const WURZEL = resolve(HIER, "..");
|
||||
const WS = join(WURZEL, "workspace");
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---------- Welche Stylesheets bindet eine Seite ein? -------------------- */
|
||||
|
||||
/** Liest ein Stylesheet und folgt seinen @import-Zeilen. */
|
||||
function cssMitImporten(pfad, gesehen = new Set()) {
|
||||
const echt = resolve(pfad);
|
||||
if (gesehen.has(echt) || !existsSync(echt)) return "";
|
||||
gesehen.add(echt);
|
||||
const text = readFileSync(echt, "utf8");
|
||||
let alles = text;
|
||||
for (const m of text.matchAll(/@import\s+(?:url\()?["']([^"')]+)["']\)?/g)) {
|
||||
alles += "\n" + cssMitImporten(join(dirname(echt), m[1].split("?")[0]), gesehen);
|
||||
}
|
||||
return alles;
|
||||
}
|
||||
|
||||
/** Alle Klassennamen, die ein CSS-Text definiert. */
|
||||
function definierteKlassen(css) {
|
||||
const raus = new Set();
|
||||
/* Erst Kommentare weg: In diesem Haus stehen lange Erklärungen im
|
||||
CSS, und dort kommen Klassennamen als Fließtext vor ("wie
|
||||
.wahl__knopf in dateien.css"). Die zählen nicht als Definition --
|
||||
sonst würde ausgerechnet der Kommentar, der den Fehler beschreibt,
|
||||
ihn verdecken. */
|
||||
const ohne = css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
for (const m of ohne.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)) raus.add(m[1]);
|
||||
return raus;
|
||||
}
|
||||
|
||||
/* ---------- Welche Klassen vergibt eine Seite? --------------------------- */
|
||||
|
||||
/** Klassen aus dem HTML. */
|
||||
function klassenAusHtml(html) {
|
||||
const raus = new Set();
|
||||
const ohneKommentar = html.replace(/<!--[\s\S]*?-->/g, " ");
|
||||
for (const m of ohneKommentar.matchAll(/\bclass\s*=\s*["']([^"']+)["']/g)) {
|
||||
for (const k of m[1].split(/\s+/)) if (k) raus.add(k);
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
|
||||
/** Klassen aus JavaScript. Nur die Formen, die dieses Haus benutzt --
|
||||
* ein vollständiger Parser wäre hier Aufwand ohne Ertrag. */
|
||||
function klassenAusJs(js) {
|
||||
const raus = new Set();
|
||||
const ohne = js.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
const muster = [
|
||||
/\.className\s*=\s*['"]([^'"]+)['"]/g,
|
||||
/setAttribute\(\s*['"]class['"]\s*,\s*['"]([^'"]+)['"]/g,
|
||||
/class\s*=\s*\\?["']([^"'\\]+)/g, // in Template-Literalen
|
||||
];
|
||||
for (const m of muster) {
|
||||
for (const t of ohne.matchAll(m)) {
|
||||
for (const stueck of t[1].split(/['",\s]+/)) {
|
||||
const k = stueck.trim();
|
||||
/* Nur echte Klassennamen -- keine Variablen, keine Ausdrücke. */
|
||||
if (k && /^-?[A-Za-z_][\w-]*$/.test(k)) raus.add(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* classList.add() BRAUCHT eine eigene Behandlung.
|
||||
Der erste Entwurf nahm den ganzen Klammerinhalt und zerlegte ihn
|
||||
an Anführungszeichen und Kommas. Bei
|
||||
|
||||
classList.add(i === 0 ? 'marke__haupt' : 'marke__leise')
|
||||
|
||||
fiel dabei `i` als angebliche Klasse heraus -- und die Prüfung
|
||||
meldete auf sechzehn Seiten eine fehlende Klasse `.i`, die es nie
|
||||
gab. Eine Warnung, die falsch ist, ist schlimmer als keine: Sie
|
||||
kostet Zeit und wird beim nächsten Mal überlesen.
|
||||
Deshalb werden hier NUR die Zeichenketten gelesen, nie der
|
||||
Ausdruck drumherum. */
|
||||
for (const t of ohne.matchAll(/classList\.(?:add|remove|toggle)\(([^)]*)\)/g)) {
|
||||
for (const s of t[1].matchAll(/['"]([^'"]+)['"]/g)) {
|
||||
for (const k of s[1].trim().split(/\s+/)) {
|
||||
if (k && /^-?[A-Za-z_][\w-]*$/.test(k)) raus.add(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
|
||||
/** Welche eigenen Skripte bindet die Seite ein? */
|
||||
function skripteVon(html) {
|
||||
const raus = [];
|
||||
for (const m of html.matchAll(/<script[^>]+src\s*=\s*["']([^"']+)["']/g)) {
|
||||
const p = m[1].split("?")[0];
|
||||
if (/^https?:/.test(p)) continue; // fremde Skripte nicht
|
||||
raus.push(join(WS, p.replace(/^\//, "").replace(/^workspace\//, "")));
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
|
||||
function stylesheetsVon(html) {
|
||||
const raus = [];
|
||||
for (const m of html.matchAll(/<link[^>]+rel\s*=\s*["']stylesheet["'][^>]*>/g)) {
|
||||
const h = /href\s*=\s*["']([^"']+)["']/.exec(m[0]);
|
||||
if (!h || /^https?:/.test(h[1])) continue;
|
||||
raus.push(join(WS, h[1].split("?")[0].replace(/^\//, "").replace(/^workspace\//, "")));
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
|
||||
/* Klassen, die absichtlich kein CSS haben. Jede Ausnahme braucht einen
|
||||
Grund -- eine Liste ohne Begründung wächst, bis sie alles enthält. */
|
||||
const OHNE_CSS = new Set([
|
||||
"leise", // Grundstil, steht in gate.css als Elementregel
|
||||
]);
|
||||
const OHNE_CSS_PRAEFIX = ["js-", "u-"]; // reine Anfasser, nie gestaltet
|
||||
|
||||
/* =======================================================================
|
||||
Die Messung
|
||||
======================================================================= */
|
||||
console.log("\n=== Ist jede benutzte Klasse auf ihrer Seite auch definiert? ===");
|
||||
|
||||
const seiten = readdirSync(WS).filter((n) => n.endsWith(".html"));
|
||||
/* DIE ANZAHL GEHOERT IN DIE BEDINGUNG, nicht nur in den Meldetext.
|
||||
Findet die Prüfung keine Seiten, hat sie nichts gemessen -- und
|
||||
"0 von 0 Klassen fehlen" wäre grün und wertlos. */
|
||||
ok(seiten.length >= 10, `${seiten.length} Workspace-Seiten gefunden`);
|
||||
|
||||
/* ALLES CSS DES HAUSES -- für die entscheidende Unterscheidung.
|
||||
|
||||
Eine Klasse ohne CSS ist zweierlei, und nur eines davon ist ein
|
||||
Fehler:
|
||||
|
||||
(a) NIRGENDS gestaltet. Ein reiner Struktur-Anker wie
|
||||
`.k-wdh__ende`, der nur ein <div> benennt. Kein Fehler --
|
||||
sonst müsste man jedem Container Deko andichten.
|
||||
|
||||
(b) Gestaltet, aber in einer Datei, die DIESE SEITE NICHT LÄDT.
|
||||
Das ist der Fehler von heute: Jemand hat es gestaltet, es
|
||||
sieht im Editor richtig aus, und auf der Seite kommt nichts
|
||||
an. Niemandem fällt es auf, weil das CSS ja existiert.
|
||||
|
||||
Nur (b) schlägt an. */
|
||||
const ALLE_CSS = definierteKlassen(
|
||||
readdirSync(join(WS, "assets/css")).filter((n) => n.endsWith(".css"))
|
||||
.map((n) => readFileSync(join(WS, "assets/css", n), "utf8")).join("\n"));
|
||||
|
||||
let geprueft = 0;
|
||||
const luecken = []; // (b) -- gestaltet, aber nicht hier
|
||||
const ungestaltet = []; // (a) -- nur zur Ansicht
|
||||
|
||||
for (const seite of seiten) {
|
||||
const html = readFileSync(join(WS, seite), "utf8");
|
||||
|
||||
const css = stylesheetsVon(html).map((p) => cssMitImporten(p)).join("\n");
|
||||
const definiert = definierteKlassen(css);
|
||||
|
||||
const benutzt = klassenAusHtml(html);
|
||||
for (const skript of skripteVon(html)) {
|
||||
if (!existsSync(skript)) continue;
|
||||
for (const k of klassenAusJs(readFileSync(skript, "utf8"))) benutzt.add(k);
|
||||
}
|
||||
|
||||
for (const k of benutzt) {
|
||||
if (OHNE_CSS.has(k)) continue;
|
||||
if (OHNE_CSS_PRAEFIX.some((p) => k.startsWith(p))) continue;
|
||||
geprueft++;
|
||||
if (definiert.has(k)) continue;
|
||||
if (ALLE_CSS.has(k)) luecken.push(`${seite}: .${k}`);
|
||||
else ungestaltet.push(`${seite}: .${k}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* Auch hier die Anzahl in die Bedingung: Wären alle Muster kaputt und
|
||||
fänden nichts, stünde sonst "0 Lücken" da -- grün, ohne je eine
|
||||
Klasse angesehen zu haben. */
|
||||
ok(geprueft > 200, `${geprueft} Klassenverwendungen geprüft`);
|
||||
|
||||
/* NACH KLASSE GRUPPIERT, nicht nach Seite. Eine fehlende Klasse auf
|
||||
sechzehn Seiten ist EIN Fehler, nicht sechzehn -- und wer sechzehn
|
||||
fast gleiche Zeilen liest, überliest die siebzehnte, die etwas
|
||||
anderes sagt. */
|
||||
const nachKlasse = new Map();
|
||||
for (const eintrag of luecken) {
|
||||
const [seite, klasse] = eintrag.split(": .");
|
||||
if (!nachKlasse.has(klasse)) nachKlasse.set(klasse, []);
|
||||
nachKlasse.get(klasse).push(seite);
|
||||
}
|
||||
const bericht = [...nachKlasse.entries()]
|
||||
.sort((a, b) => b[1].length - a[1].length)
|
||||
.map(([k, s]) => `.${k} — fehlt auf ${s.length} Seite${s.length === 1 ? "" : "n"}`
|
||||
+ ` (${s.slice(0, 3).join(", ")}${s.length > 3 ? " …" : ""})`);
|
||||
|
||||
ok(luecken.length === 0 && geprueft > 200,
|
||||
luecken.length
|
||||
? `${nachKlasse.size} Klasse${nachKlasse.size === 1 ? "" : "n"} sind gestaltet, aber nicht auf ihrer Seite:`
|
||||
+ `\n ${bericht.join("\n ")}`
|
||||
: `jede gestaltete Klasse kommt auch auf ihrer Seite an`);
|
||||
|
||||
/* Kein Fehler, nur zur Kenntnis: Klassen, die es nirgends gibt. Meist
|
||||
Struktur-Anker; gelegentlich aber ein Tippfehler, der hier auffällt,
|
||||
bevor jemand sich wundert, warum nichts passiert. */
|
||||
const nurAnker = new Set(ungestaltet.map((e) => e.split(": .")[1]));
|
||||
console.log(` -- ${nurAnker.size} Klassen sind nirgends gestaltet `
|
||||
+ `(Struktur-Anker, kein Fehler): ${[...nurAnker].slice(0, 8).join(", ")}`
|
||||
+ (nurAnker.size > 8 ? " …" : ""));
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE — die Messung muss beides können
|
||||
======================================================================= */
|
||||
console.log("\n=== Gegenprobe ===");
|
||||
{
|
||||
/* 1. Der echte Fehler von heute, nachgestellt: eine Seite, die eine
|
||||
Klasse benutzt, deren CSS in einem nicht eingebundenen
|
||||
Stylesheet steht. */
|
||||
const definiert = definierteKlassen(".k-dabei__knopf { color: red; }");
|
||||
ok(definiert.has("k-dabei__knopf"), "eine definierte Klasse wird erkannt");
|
||||
ok(!definiert.has("gibt-es-nicht"), "eine erfundene Klasse wird NICHT erkannt");
|
||||
|
||||
/* 2. Klassen aus JavaScript werden wirklich mitgelesen -- sonst wäre
|
||||
genau der heutige Fall unsichtbar geblieben, denn die Wahl wird
|
||||
komplett aus JS gebaut und steht in keinem HTML. */
|
||||
const ausJs = klassenAusJs("b.className = 'k-dabei__knopf';\nx.classList.add('offen');");
|
||||
ok(ausJs.has("k-dabei__knopf") && ausJs.has("offen"),
|
||||
"Klassen aus className und classList.add werden gefunden");
|
||||
|
||||
/* 3. Ein Klassenname, der nur im KOMMENTAR steht, gilt nicht als
|
||||
definiert. Sonst hätte ausgerechnet die Erklärung dieses Fehlers
|
||||
ihn zugedeckt. */
|
||||
const nurKommentar = definierteKlassen("/* wie .wahl__knopf in dateien.css */\n.echt { color: red; }");
|
||||
ok(!nurKommentar.has("wahl__knopf") && nurKommentar.has("echt"),
|
||||
"ein Klassenname aus einem Kommentar zählt nicht als Definition");
|
||||
|
||||
/* 4. Und der Beweis am echten Bestand: Die Wahl im Kalender muss
|
||||
JETZT gedeckt sein -- vor der Reparatur wäre genau das rot. */
|
||||
const kal = readFileSync(join(WS, "kalender.html"), "utf8");
|
||||
const kalCss = definierteKlassen(stylesheetsVon(kal).map((p) => cssMitImporten(p)).join("\n"));
|
||||
const noetig = ["k-dabei", "k-dabei__knopf", "k-dabei__zeichen", "k-dabei__name",
|
||||
"k-dabei__titel", "k-dabei__liste", "k-dabei__gruppe", "k-dabei__haken", "k-dabei__punkt"];
|
||||
const fehlend = noetig.filter((k) => !kalCss.has(k));
|
||||
ok(fehlend.length === 0 && noetig.length === 9,
|
||||
fehlend.length ? `im Kalender fehlt CSS für: ${fehlend.join(", ")}`
|
||||
: `alle ${noetig.length} Klassen der Teilnehmerwahl sind in kalender.css definiert`);
|
||||
|
||||
/* 5. Und die alten Klassen sind wirklich weg -- nicht nur die neuen
|
||||
da. Ein halber Umbau hinterlässt beides. */
|
||||
const kalJs = readFileSync(join(WS, "assets/js/kalender.js"), "utf8");
|
||||
ok(!/wahl__knopf|wahl__rolle|wahl__name/.test(kalJs.replace(/\/\*[\s\S]*?\*\//g, " ")),
|
||||
"die alten .wahl__*-Klassen stehen nicht mehr im Kalender-Skript");
|
||||
|
||||
/* 6. DIE WICHTIGSTE GEGENPROBE: Unterscheidet die Prüfung die beiden
|
||||
Fälle wirklich? Der ganze Wert hängt daran -- ohne sie könnte
|
||||
die Bedingung "ALLE_CSS.has(k)" verdreht sein, und die Prüfung
|
||||
wäre grün, während sie den echten Fehler durchwinkt.
|
||||
|
||||
Nachgestellt wird der Fall von heute an einem Miniaturbeispiel:
|
||||
Zwei Klassen, beide ohne CSS auf ihrer Seite -- eine davon
|
||||
woanders gestaltet, die andere nirgends. */
|
||||
const seitenCss = definierteKlassen(".nur-hier { color: red; }");
|
||||
const hausCss = definierteKlassen(".nur-hier { color: red; } .woanders { color: blue; }");
|
||||
|
||||
const fallB = !seitenCss.has("woanders") && hausCss.has("woanders");
|
||||
ok(fallB, fallB
|
||||
? "gestaltet, aber nicht auf dieser Seite → wird als Fehler erkannt"
|
||||
: "MISSLUNGEN — der eigentliche Fehlerfall würde durchgehen");
|
||||
|
||||
const fallA = !seitenCss.has("blosser-anker") && !hausCss.has("blosser-anker");
|
||||
ok(fallA, fallA
|
||||
? "nirgends gestaltet → gilt als Struktur-Anker, kein Fehler"
|
||||
: "MISSLUNGEN — Struktur-Anker würden fälschlich anschlagen");
|
||||
|
||||
/* 7. Und der Fehlalarm, den es schon gab, darf nicht zurückkommen:
|
||||
`classList.add(i === 0 ? 'a' : 'b')` hatte `i` als Klasse
|
||||
gemeldet -- auf sechzehn Seiten. */
|
||||
const ausAusdruck = klassenAusJs("x.classList.add(i === 0 ? 'marke__haupt' : 'marke__leise');");
|
||||
ok(!ausAusdruck.has("i") && ausAusdruck.has("marke__haupt") && ausAusdruck.has("marke__leise"),
|
||||
"aus classList.add(bedingung ? 'a' : 'b') werden beide Klassen gelesen, aber nicht die Variable");
|
||||
}
|
||||
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,274 @@
|
||||
/* WIE SIEHT DIE TEILNEHMERWAHL WIRKLICH AUS?
|
||||
|
||||
WARUM ES DIESE PRÜFUNG GIBT. Am 05.09.2026 waren vierzig Prüfungen
|
||||
zur Teilnehmerwahl grün -- und auf Filipes Bildschirm stand:
|
||||
|
||||
DogfatherDogFather VanVanDogFather CigdemManager
|
||||
|
||||
Alle vierzig prüften Daten: Kommen die richtigen Personen an? Werden
|
||||
Fremde weggefiltert? Stimmt die Sichtbarkeit? Alles richtig. Keine
|
||||
einzige hat gefragt, ob das Ergebnis auch AUSSIEHT wie gedacht.
|
||||
|
||||
pruef-css-klassen.mjs fängt die Ursache (CSS auf einer Seite, die es
|
||||
nicht lädt). Diese hier misst die Wirkung -- im echten Browser, an
|
||||
den echten Pixeln:
|
||||
|
||||
* Stehen Name und Rolle getrennt, oder kleben sie?
|
||||
* Ist der Knopf hoch genug für einen Daumen?
|
||||
* Sieht man einem ausgewählten Knopf an, dass er an ist -- und
|
||||
zwar nicht nur an der Farbe?
|
||||
* Und die eigentliche Frage: Erscheint die Wahl bei JEDER Rolle?
|
||||
|
||||
Gemessen wird mit getComputedStyle und getBoundingClientRect, nicht
|
||||
an der CSS-Datei. Ein Wert, der im Stylesheet steht, kann von einer
|
||||
anderen Regel überschrieben sein -- was zählt, ist, was am Ende auf
|
||||
dem Schirm ankommt.
|
||||
|
||||
GEGENPROBE am Ende: Die Messung muss "klebt" auch erkennen können. */
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-dabei-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4293, "die Optikpruefung der Teilnehmerwahl");
|
||||
|
||||
process.env.PORT = "4293";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
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, 900));
|
||||
const BASIS = "http://127.0.0.1:4293";
|
||||
|
||||
/* LAUFEND MITSCHREIBEN. Node puffert stdout, sobald es umgeleitet wird
|
||||
-- bei einem Lauf mit sechs Browser-Anmeldungen steht bis zum Schluss
|
||||
NICHTS in der Datei. Beim ersten Versuch hing die Prüfung (ein
|
||||
geratener Selektor), und die leere Datei sagte nicht, wie weit sie
|
||||
gekommen war. Dieselbe Lehre steht schon in pruef-browser.mjs. */
|
||||
const { appendFileSync } = await import("node:fs");
|
||||
const PROTOKOLL = join(process.cwd(), "pruef-dabei-lauf.txt");
|
||||
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
|
||||
const melde = (z) => {
|
||||
console.log(z);
|
||||
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Protokoll ist Zugabe */ }
|
||||
};
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Bestand: eine Kette, wie sie im Betrieb aussieht ------------------- */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
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();
|
||||
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;
|
||||
}
|
||||
/* Namen wie im echten Bestand -- ein zweiteiliger Name ist der Fall,
|
||||
an dem das Kleben aufgefallen ist. */
|
||||
const idDogi = anlegen("Filipe Fernandes Ribeiro", "admin", "CODE-DOGI-0001");
|
||||
const idCig = anlegen("Cigdem", "manager", "CODE-MANA-0001");
|
||||
const idPat = anlegen("Patrick von Hohenstein", "scout", "CODE-SCOU-0001");
|
||||
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
|
||||
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idCig, jetzt);
|
||||
d.close();
|
||||
|
||||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await pw.chromium.launch();
|
||||
|
||||
/** Meldet sich an und öffnet den Kalender mit geöffnetem Formular.
|
||||
*
|
||||
* ERST DIE ROLLE, DANN DER CODE. Das war der Grund, warum der erste
|
||||
* Versuch dieser Prüfung hängen blieb: Ich hatte den Rollenklick
|
||||
* geraten statt nachgesehen, das Formular nahm den Code nicht an, und
|
||||
* waitForURL wartete auf eine Adresse, die nie kam. */
|
||||
async function alsRolle(rolle, code, breite = 1280) {
|
||||
const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } });
|
||||
const seite = await ctx.newPage();
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
|
||||
await seite.click(`.rolle[data-rolle="${rolle}"]`);
|
||||
await seite.fill("#code", code);
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 25000 });
|
||||
await seite.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" });
|
||||
/* Das Formular öffnen -- die Wahl steht darin. */
|
||||
await seite.click("#neu-oeffnen");
|
||||
await seite.waitForSelector("#neu:not([hidden])", { timeout: 10000 });
|
||||
await seite.waitForTimeout(400);
|
||||
return { ctx, seite };
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
1. Als DogFather -- sieht er alle vier Gruppen?
|
||||
======================================================================= */
|
||||
console.log("\n=== Die Wahl aus Sicht von DogFather ===");
|
||||
{
|
||||
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001");
|
||||
|
||||
const sichtbar = await seite.isVisible("#feld-dabei");
|
||||
ok(sichtbar, sichtbar ? "das Feld 'Wer ist noch dabei?' ist da" : "das Feld fehlt");
|
||||
|
||||
const knoepfe = await seite.$$("#dabei-wahl .k-dabei__knopf");
|
||||
/* DIE ANZAHL IN DIE BEDINGUNG: Wären es null, würde jede folgende
|
||||
Messung über eine leere Menge laufen und grün melden. */
|
||||
ok(knoepfe.length === 3,
|
||||
`${knoepfe.length} Personen zur Auswahl (erwartet 3 -- alle außer ihm selbst)`);
|
||||
|
||||
const gruppen = await seite.$$eval(".k-dabei__gruppe", (g) => g.map((x) => x.dataset.rolle));
|
||||
ok(gruppen.length === 3 && !gruppen.includes("admin"),
|
||||
`Gruppen: ${gruppen.join(", ") || "keine"} — er selbst ist die einzige admin-Person und steht nicht drin`);
|
||||
|
||||
/* DER KERN: Kleben Name und Zeichen? Gemessen wird der echte
|
||||
Abstand zwischen den zwei Kästchen, nicht der CSS-Wert. */
|
||||
const abstand = await seite.evaluate(() => {
|
||||
const k = document.querySelector("#dabei-wahl .k-dabei__knopf");
|
||||
if (!k) return null;
|
||||
const z = k.querySelector(".k-dabei__zeichen")?.getBoundingClientRect();
|
||||
const n = k.querySelector(".k-dabei__name")?.getBoundingClientRect();
|
||||
if (!z || !n) return null;
|
||||
return Math.round(n.left - z.right);
|
||||
});
|
||||
ok(abstand !== null && abstand >= 6,
|
||||
abstand === null ? "Zeichen oder Name nicht gefunden"
|
||||
: `${abstand} px zwischen Zeichen und Name (der alte Fehler war 0)`);
|
||||
|
||||
/* Und die Rolle steht NICHT mehr am Namen -- sonst hieße es wieder
|
||||
"CigdemManager". */
|
||||
const ersterText = await seite.$eval("#dabei-wahl .k-dabei__knopf", (k) => k.textContent.trim());
|
||||
ok(!/DogFather|Manager|Scout|Creator/.test(ersterText),
|
||||
`der Knopf trägt nur den Namen: "${ersterText}"`);
|
||||
|
||||
/* Die Rolle steht stattdessen über der Gruppe. */
|
||||
const titel = await seite.$$eval(".k-dabei__titel", (t) => t.map((x) => x.textContent.trim()));
|
||||
ok(titel.length === 3 && titel.some((t) => /Manager/.test(t)),
|
||||
`Gruppenüberschriften: ${titel.join(" · ")}`);
|
||||
|
||||
/* Daumengröße. 40 px ist die Grenze, die dieses Haus überall nutzt. */
|
||||
const hoehe = await seite.$eval("#dabei-wahl .k-dabei__knopf",
|
||||
(k) => Math.round(k.getBoundingClientRect().height));
|
||||
ok(hoehe >= 40, `Knopfhöhe ${hoehe} px (mindestens 40 für den Daumen)`);
|
||||
|
||||
/* Auswählen: Ändert sich etwas Sichtbares -- und zwar mehr als die
|
||||
Farbe? Ein Häkchen erkennt auch, wer Farben schlecht sieht. */
|
||||
const vorher = await seite.$eval("#dabei-wahl .k-dabei__knopf .k-dabei__haken",
|
||||
(h) => getComputedStyle(h).opacity);
|
||||
await seite.click("#dabei-wahl .k-dabei__knopf");
|
||||
/* ERST DIE ÜBERBLENDUNG ABWARTEN. Beim ersten Lauf stand hier
|
||||
"0 → 0" und sah aus wie ein defektes Häkchen -- gemessen wurde
|
||||
aber mitten in der 0,16-Sekunden-Blende, als es tatsächlich noch
|
||||
unsichtbar war. Nicht der Code war falsch, sondern der Zeitpunkt
|
||||
der Messung. 250 ms sind reichlich über den 160. */
|
||||
await seite.waitForTimeout(250);
|
||||
const nachher = await seite.$eval("#dabei-wahl .k-dabei__knopf .k-dabei__haken",
|
||||
(h) => getComputedStyle(h).opacity);
|
||||
ok(Number(vorher) < 0.1 && Number(nachher) > 0.9,
|
||||
`Häkchen: ${vorher} → ${nachher} (erscheint beim Anwählen)`);
|
||||
|
||||
const gedrueckt = await seite.$eval("#dabei-wahl .k-dabei__knopf",
|
||||
(k) => k.getAttribute("aria-pressed"));
|
||||
ok(gedrueckt === "true", `aria-pressed = ${gedrueckt} (ein Vorleseprogramm sagt den Zustand an)`);
|
||||
|
||||
const hinweis = await seite.$eval("#dabei-hinweis", (p) => p.textContent.trim());
|
||||
ok(/1 Person sieht/.test(hinweis), `Hinweis: "${hinweis}"`);
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
2. DIE EIGENTLICHE FRAGE: Bekommt jede Rolle die Wahl?
|
||||
======================================================================= */
|
||||
console.log("\n=== 'für jeden verfügbar' — jede Rolle einzeln ===");
|
||||
{
|
||||
const faelle = [
|
||||
["Manager", "manager", "CODE-MANA-0001", 3], // DogFather, Patrick, Luna
|
||||
["Scout", "scout", "CODE-SCOU-0001", 3], // DogFather, Luna, Cigdem (sein Manager)
|
||||
["Creator", "creator", "CODE-CREA-0001", 3], // DogFather, Patrick, Cigdem
|
||||
];
|
||||
for (const [name, rolle, code, erwartet] of faelle) {
|
||||
const { ctx, seite } = await alsRolle(rolle, code);
|
||||
const sichtbar = await seite.isVisible("#feld-dabei");
|
||||
const anzahl = (await seite.$$("#dabei-wahl .k-dabei__knopf")).length;
|
||||
ok(sichtbar && anzahl === erwartet,
|
||||
`${name}: Feld ${sichtbar ? "sichtbar" : "FEHLT"}, ${anzahl} Personen zur Auswahl (erwartet ${erwartet})`);
|
||||
|
||||
/* Und niemand sieht jemanden, den er nicht sehen darf. Bei einem
|
||||
Creator ist das der Punkt: Er darf seinen Scout markieren, aber
|
||||
keinen fremden Creator. */
|
||||
const namen = await seite.$$eval("#dabei-wahl .k-dabei__name", (n) => n.map((x) => x.textContent));
|
||||
ok(!namen.includes("Luna") || name !== "Creator",
|
||||
`${name}: sieht ${namen.join(", ") || "niemanden"}`);
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. Am Handy
|
||||
======================================================================= */
|
||||
console.log("\n=== Am Handy (390 px) ===");
|
||||
{
|
||||
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 390);
|
||||
const ueber = await seite.evaluate(() => {
|
||||
const w = document.querySelector("#dabei-wahl");
|
||||
if (!w) return null;
|
||||
return Math.round(w.scrollWidth - w.clientWidth);
|
||||
});
|
||||
ok(ueber !== null && ueber <= 1, `nichts steht seitlich über (${ueber} px)`);
|
||||
|
||||
const hoehe = await seite.$eval("#dabei-wahl .k-dabei__knopf",
|
||||
(k) => Math.round(k.getBoundingClientRect().height));
|
||||
ok(hoehe >= 40, `Knopfhöhe am Handy ${hoehe} px`);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE — kann die Messung "klebt" überhaupt erkennen?
|
||||
======================================================================= */
|
||||
console.log("\n=== Gegenprobe ===");
|
||||
{
|
||||
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001");
|
||||
|
||||
/* Das CSS wird absichtlich kaputt gemacht -- genau so, wie es vorher
|
||||
WAR: kein Flex, kein gap. Schlägt die Messung jetzt nicht an, misst
|
||||
sie nichts und alle Haken oben sind wertlos. */
|
||||
await seite.addStyleTag({ content:
|
||||
".k-dabei__knopf { display: inline !important; gap: 0 !important; }" });
|
||||
await seite.waitForTimeout(120);
|
||||
|
||||
const abstand = await seite.evaluate(() => {
|
||||
const k = document.querySelector("#dabei-wahl .k-dabei__knopf");
|
||||
const z = k.querySelector(".k-dabei__zeichen")?.getBoundingClientRect();
|
||||
const n = k.querySelector(".k-dabei__name")?.getBoundingClientRect();
|
||||
return Math.round(n.left - z.right);
|
||||
});
|
||||
ok(abstand < 6,
|
||||
`mit kaputtem CSS misst die Prüfung ${abstand} px — sie erkennt das Kleben also`);
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,493 @@
|
||||
/* DER BENACHRICHTIGUNGS-KNOPF, IM ECHTEN BROWSER.
|
||||
|
||||
Wunsch Filipe, 05.09.2026, mit dem Bild eines "Benachrichtigungen
|
||||
aus"-Knopfes: *"ich will auch sowas und dass es perfekt funktioniert
|
||||
auf der seite für jeden!!!!"*
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WAS HIER NICHT GEMESSEN WERDEN KANN — und warum das dasteht
|
||||
|
||||
`pushManager.subscribe()` braucht einen echten Push-Dienst. Chromium
|
||||
spricht dafür mit Googles FCM, Firefox mit Mozillas autopush. Ein
|
||||
Browser, der in einer Prüfung startet, hat diese Verbindung nicht --
|
||||
der Aufruf scheitert, und daran ist nichts zu ändern.
|
||||
|
||||
Das ist der DRITTE AUSGANG aus der Projektnotiz: nicht in Ordnung ist
|
||||
etwas anderes als nicht nachgesehen. Diese Prüfung tut deshalb
|
||||
zweierlei:
|
||||
|
||||
1. Sie MISST ZUERST, ob echtes subscribe hier geht. Geht es, wird
|
||||
der echte Weg geprüft. Geht es nicht, wird das ausdrücklich
|
||||
gemeldet -- nicht verschwiegen und nicht als "bestanden".
|
||||
|
||||
2. Für alles andere tritt ein Test-Doppel an die Stelle des
|
||||
Browser-Kanals: Der PushManager wird ersetzt, alles davor und
|
||||
dahinter bleibt echt. Damit sind Knopf, Zustände, Server-Aufrufe
|
||||
und Speicherung wirklich geprüft -- nur die Strecke zwischen
|
||||
Browser und Google nicht.
|
||||
|
||||
Was die Rechnung selbst angeht, ist die Lage besser als es klingt:
|
||||
pruef-push.mjs prüft sie gegen die Testvektoren aus RFC 8291 und
|
||||
8292, pruef-push-weg.mjs schickt eine Nachricht durch einen
|
||||
nachgebauten Dienst und macht sie wieder auf. Was hier fehlt, ist
|
||||
allein der Schritt "Browser holt sich eine Adresse bei Google".
|
||||
|
||||
---------------------------------------------------------------------
|
||||
GEMESSEN WIRD
|
||||
|
||||
* Steht der Knopf auf JEDER Seite (außer der Anmeldeseite)?
|
||||
* Sagt er in jeder Lage die Wahrheit -- auch wenn sie unbequem ist?
|
||||
(abgelehnt, iPhone im Reiter, alter Browser)
|
||||
* Legt ein Klick wirklich eine Anmeldung an, und nimmt ein zweiter
|
||||
sie wieder weg?
|
||||
* Bekommt jede ROLLE ihn -- "für jeden" war der Wunsch.
|
||||
|
||||
GEGENPROBEN durchgehend. */
|
||||
|
||||
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-glocke-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4299, "die Glockenpruefung");
|
||||
|
||||
process.env.PORT = "4299";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
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, 900));
|
||||
const BASIS = "http://127.0.0.1:4299";
|
||||
|
||||
const PROTOKOLL = join(process.cwd(), "pruef-glocke-lauf.txt");
|
||||
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
|
||||
const melde = (z) => {
|
||||
console.log(z);
|
||||
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
|
||||
};
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
const hinweis = (t) => melde(" -- " + t);
|
||||
|
||||
/* ---- Bestand ------------------------------------------------------------ */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
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();
|
||||
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");
|
||||
anlegen("Cigdem", "manager", "CODE-MANA-0001");
|
||||
const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
|
||||
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idScout, jetzt);
|
||||
d.close();
|
||||
|
||||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
|
||||
/** Ein Test-Doppel für den Browser-Push-Kanal.
|
||||
*
|
||||
* Ersetzt NUR pushManager -- Berechtigung, Service Worker, Knopf,
|
||||
* fetch und Server bleiben echt. Der Endpunkt sieht aus wie einer von
|
||||
* Google und ist https, weil der Server das verlangt (und verlangen
|
||||
* soll: eine http-Zustelladresse wäre im Klartext erreichbar). */
|
||||
const DOPPEL = `
|
||||
(() => {
|
||||
const abo = {
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/PRUEF-' + Math.random().toString(36).slice(2),
|
||||
getKey: (art) => new Uint8Array(art === 'auth' ? 16 : 65).fill(7),
|
||||
toJSON: () => ({ keys: {
|
||||
p256dh: 'BQ'.padEnd(87, 'A'), auth: 'AQ'.padEnd(22, 'A'),
|
||||
} }),
|
||||
unsubscribe: async () => true,
|
||||
};
|
||||
let angemeldet = null;
|
||||
const manager = {
|
||||
subscribe: async () => { angemeldet = abo; return abo; },
|
||||
getSubscription: async () => angemeldet,
|
||||
};
|
||||
/* Auch die Registrierung wird abgefangen: Der Service Worker
|
||||
selbst wird hier nicht gebraucht, und ein fehlgeschlagenes
|
||||
register() würde den ganzen Ablauf abbrechen. */
|
||||
const reg = { pushManager: manager, showNotification: async () => {} };
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
register: async () => reg,
|
||||
getRegistration: async () => reg,
|
||||
ready: Promise.resolve(reg),
|
||||
addEventListener: () => {},
|
||||
},
|
||||
});
|
||||
window.__doppel = true;
|
||||
})();
|
||||
`;
|
||||
|
||||
/* DEN BERECHTIGUNGSSTAND SETZEN — und warum das nötig ist.
|
||||
|
||||
Ein Chromium, der in einer Prüfung startet, meldet
|
||||
`Notification.permission === "denied"`. Ein echter Browser meldet
|
||||
beim ersten Besuch "default" und fragt erst, wenn man klickt.
|
||||
|
||||
Ohne diese Zeile misst die Prüfung also überall den Zustand
|
||||
"schon einmal abgelehnt" -- und der Knopf sagt dann völlig zu Recht
|
||||
"Vom Browser blockiert". Beim ersten Lauf standen deshalb acht
|
||||
Fehler da, von denen keiner ein Fehler war: Der Knopf tat genau das
|
||||
Richtige, nur zur falschen Frage.
|
||||
|
||||
`grantPermissions` allein genügt nicht -- es setzt die Berechtigung
|
||||
im Browser, aber der Ausgangszustand "noch nicht gefragt" lässt sich
|
||||
damit nicht herstellen. Deshalb wird beides gesetzt: der Stand und,
|
||||
wo nötig, die Antwort auf die Nachfrage. */
|
||||
function berechtigung(stand) {
|
||||
return `
|
||||
(() => {
|
||||
Object.defineProperty(Notification, 'permission',
|
||||
{ value: '${stand}', configurable: true });
|
||||
Notification.requestPermission = async () => '${stand === "default" ? "granted" : stand}';
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
async function anmelden(ctx, rolle, code) {
|
||||
const seite = await ctx.newPage();
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
|
||||
await seite.click(`.rolle[data-rolle="${rolle}"]`);
|
||||
await seite.fill("#code", code);
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 25000 });
|
||||
return seite;
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
1. Geht ECHTES subscribe in dieser Umgebung?
|
||||
======================================================================= */
|
||||
melde("\n=== Lässt sich der echte Browser-Kanal hier messen? ===");
|
||||
let echtGeht = false;
|
||||
{
|
||||
const browser = await pw.chromium.launch();
|
||||
const ctx = await browser.newContext({ permissions: ["notifications"] });
|
||||
const seite = await anmelden(ctx, "admin", "CODE-DOGI-0001");
|
||||
|
||||
const ergebnis = await seite.evaluate(async () => {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/workspace/sw.js', { scope: '/workspace/' });
|
||||
await navigator.serviceWorker.ready;
|
||||
const a = await fetch('/workspace/api/push/schluessel', { credentials: 'same-origin' });
|
||||
const { schluessel } = await a.json();
|
||||
const roh = atob(schluessel.replace(/-/g, '+').replace(/_/g, '/')
|
||||
+ '='.repeat((4 - schluessel.length % 4) % 4));
|
||||
const abo = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: Uint8Array.from(roh, (c) => c.charCodeAt(0)),
|
||||
});
|
||||
return { ok: true, endpunkt: abo.endpoint.slice(0, 40) };
|
||||
} catch (f) { return { ok: false, grund: String(f?.message || f).slice(0, 80) }; }
|
||||
});
|
||||
|
||||
echtGeht = ergebnis.ok;
|
||||
if (echtGeht) {
|
||||
ok(true, `echtes subscribe GEHT hier — geprüft wird der echte Weg (${ergebnis.endpunkt}…)`);
|
||||
} else {
|
||||
/* KEIN FEHLER, aber auch kein Haken: der dritte Ausgang. */
|
||||
hinweis(`echtes subscribe geht hier NICHT: ${ergebnis.grund}`);
|
||||
hinweis(` Grund: ein Prüf-Browser hat keine Verbindung zu Googles Push-Dienst.`);
|
||||
hinweis(` Die Verschlüsselung selbst ist in pruef-push.mjs gegen die`);
|
||||
hinweis(` RFC-Testvektoren geprüft, die Zustellung in pruef-push-weg.mjs.`);
|
||||
hinweis(` Hier wird der PushManager ersetzt; alles davor und dahinter ist echt.`);
|
||||
}
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
2. Steht der Knopf da — auf jeder Seite, bei jeder Rolle?
|
||||
======================================================================= */
|
||||
melde("\n=== Der Knopf ist überall ===");
|
||||
{
|
||||
const browser = await pw.chromium.launch();
|
||||
const ctx = await browser.newContext();
|
||||
await ctx.addInitScript(berechtigung("default"));
|
||||
const seite = await anmelden(ctx, "admin", "CODE-DOGI-0001");
|
||||
|
||||
const seitenNamen = ["start", "aufgaben", "kalender", "bereich", "content",
|
||||
"dateien", "wissen", "report"];
|
||||
let gefunden = 0;
|
||||
const ohne = [];
|
||||
for (const n of seitenNamen) {
|
||||
await seite.goto(`${BASIS}/workspace/${n}.html`, { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(250);
|
||||
if (await seite.$("#glocke")) gefunden++; else ohne.push(n);
|
||||
}
|
||||
ok(gefunden === seitenNamen.length,
|
||||
ohne.length ? `fehlt auf: ${ohne.join(", ")}`
|
||||
: `auf allen ${gefunden} geprüften Seiten`);
|
||||
|
||||
/* Und NICHT auf der Anmeldeseite -- dort ist niemand angemeldet,
|
||||
den man benachrichtigen könnte. */
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(250);
|
||||
ok(!(await seite.$("#glocke")), "auf der Anmeldeseite steht er nicht");
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
melde("\n=== Jede Rolle bekommt ihn ===");
|
||||
{
|
||||
const browser = await pw.chromium.launch();
|
||||
for (const [name, rolle, code] of [
|
||||
["DogFather", "admin", "CODE-DOGI-0001"], ["Manager", "manager", "CODE-MANA-0001"],
|
||||
["Scout", "scout", "CODE-SCOU-0001"], ["Creator", "creator", "CODE-CREA-0001"],
|
||||
]) {
|
||||
const ctx = await browser.newContext();
|
||||
await ctx.addInitScript(berechtigung("default"));
|
||||
const seite = await anmelden(ctx, rolle, code);
|
||||
await seite.waitForTimeout(400);
|
||||
const da = await seite.$("#glocke");
|
||||
const text = da ? await seite.$eval("#glocke .glocke__text", (e) => e.textContent) : "";
|
||||
ok(!!da, `${name}: ${da ? `Knopf da ("${text}")` : "FEHLT"}`);
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. Sagt er in jeder Lage die Wahrheit?
|
||||
======================================================================= */
|
||||
melde("\n=== Die Lagen ===");
|
||||
{
|
||||
const browser = await pw.chromium.launch();
|
||||
|
||||
/* AUS -- noch nie gefragt worden. Das ist der Zustand, in dem jeder
|
||||
zum ersten Mal auf die Seite kommt. */
|
||||
{
|
||||
const ctx = await browser.newContext();
|
||||
await ctx.addInitScript(berechtigung("default"));
|
||||
const seite = await anmelden(ctx, "admin", "CODE-DOGI-0001");
|
||||
await seite.waitForTimeout(400);
|
||||
const lage = await seite.$eval("#glocke", (e) => e.dataset.lage);
|
||||
const text = await seite.$eval("#glocke .glocke__text", (e) => e.textContent);
|
||||
ok(lage === "aus" && /aus/i.test(text), `ohne Berechtigung: "${text}" (${lage})`);
|
||||
|
||||
/* Der Strich muss sichtbar sein -- er sagt es ohne Lesen. */
|
||||
const strich = await seite.$eval(".glocke__strich", (e) => getComputedStyle(e).opacity);
|
||||
ok(Number(strich) > 0.5, `die Glocke ist durchgestrichen (${strich})`);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
/* VERBOTEN -- schon einmal abgelehnt. Der wichtigste Fall: Per
|
||||
JavaScript lässt sich das NICHT zurücknehmen, also muss der Knopf
|
||||
sagen, wo man es tut. Ein Knopf, der hier nur nichts tut, ist der
|
||||
sichere Weg zu "das funktioniert nicht". */
|
||||
{
|
||||
const ctx = await browser.newContext();
|
||||
await ctx.grantPermissions([]); // alles verweigert
|
||||
const seite = await anmelden(ctx, "admin", "CODE-DOGI-0001");
|
||||
await seite.evaluate(() => {
|
||||
Object.defineProperty(Notification, "permission", { value: "denied", configurable: true });
|
||||
});
|
||||
await seite.reload({ waitUntil: "networkidle" });
|
||||
await seite.evaluate(() => {
|
||||
Object.defineProperty(Notification, "permission", { value: "denied", configurable: true });
|
||||
});
|
||||
await seite.waitForTimeout(400);
|
||||
const lage = await seite.evaluate(() => {
|
||||
document.getElementById("glocke")?.click();
|
||||
return document.getElementById("glocke")?.dataset.lage;
|
||||
});
|
||||
const titel = await seite.$eval("#glocke", (e) => e.title);
|
||||
ok(lage === "verboten", `abgelehnt: Lage "${lage}"`);
|
||||
ok(/Browsereinstellungen/i.test(titel),
|
||||
`und er sagt, WO man es ändert: "${titel.slice(0, 80)}…"`);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
/* GEHT NICHT -- ein Browser ohne Push. */
|
||||
{
|
||||
const ctx = await browser.newContext();
|
||||
const seite = await anmelden(ctx, "admin", "CODE-DOGI-0001");
|
||||
await seite.addInitScript(() => {
|
||||
delete window.PushManager;
|
||||
});
|
||||
await seite.reload({ waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(400);
|
||||
const lage = await seite.$eval("#glocke", (e) => e.dataset.lage);
|
||||
const aus = await seite.$eval("#glocke", (e) => e.disabled);
|
||||
ok(lage === "geht-nicht" && aus,
|
||||
`Browser ohne Push: Lage "${lage}", Knopf abgeschaltet (${aus})`);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
/* IPHONE im Reiter -- dort gibt es die Berechtigung überhaupt erst
|
||||
nach "Zum Home-Bildschirm". Das muss dastehen, sonst tippt jemand
|
||||
zehnmal und hält es für kaputt. */
|
||||
{
|
||||
const ctx = await browser.newContext({
|
||||
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
|
||||
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
});
|
||||
const seite = await anmelden(ctx, "admin", "CODE-DOGI-0001");
|
||||
await seite.waitForTimeout(400);
|
||||
const lage = await seite.$eval("#glocke", (e) => e.dataset.lage);
|
||||
const titel = await seite.$eval("#glocke", (e) => e.title);
|
||||
ok(lage === "iphone", `iPhone im Reiter: Lage "${lage}"`);
|
||||
ok(/Home-Bildschirm/i.test(titel), `mit Anleitung: "${titel.slice(0, 70)}…"`);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
4. An- und abmelden — mit dem Test-Doppel
|
||||
======================================================================= */
|
||||
melde("\n=== Anmelden und wieder abmelden ===");
|
||||
{
|
||||
const browser = await pw.chromium.launch();
|
||||
const ctx = await browser.newContext({ permissions: ["notifications"] });
|
||||
await ctx.addInitScript(berechtigung("granted"));
|
||||
await ctx.addInitScript(DOPPEL);
|
||||
const seite = await ctx.newPage();
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
|
||||
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: 25000 });
|
||||
await seite.waitForTimeout(500);
|
||||
|
||||
/* Steht das Doppel überhaupt? Ohne diese Prüfung liefe alles
|
||||
Folgende womöglich gegen den echten (hier kaputten) Kanal und
|
||||
meldete Fehler, die nichts bedeuten. */
|
||||
ok(await seite.evaluate(() => window.__doppel === true),
|
||||
"das Test-Doppel steht (der Rest der Kette ist echt)");
|
||||
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const zahl = () => p.prepare("SELECT COUNT(*) AS n FROM push_anmeldungen").get().n;
|
||||
ok(zahl() === 0, `vorher ${zahl()} Anmeldungen in der Datenbank`);
|
||||
|
||||
await seite.click("#glocke");
|
||||
await seite.waitForTimeout(1200);
|
||||
|
||||
ok(zahl() === 1, `nach dem Klick: ${zahl()} Anmeldung`);
|
||||
|
||||
const eintrag = p.prepare("SELECT * FROM push_anmeldungen").get();
|
||||
ok(eintrag?.person_id === idDogi && /^https:\/\//.test(eintrag?.endpunkt || ""),
|
||||
`sie gehört der richtigen Person und hat eine https-Adresse`);
|
||||
/* Der Gerätename hilft beim Wiedererkennen in der Liste. */
|
||||
ok(/Chrome|Edge|Browser/.test(eintrag?.geraet || ""),
|
||||
`Gerät erkannt: "${eintrag?.geraet}"`);
|
||||
|
||||
const lage = await seite.$eval("#glocke", (e) => e.dataset.lage);
|
||||
const text = await seite.$eval("#glocke .glocke__text", (e) => e.textContent);
|
||||
ok(lage === "an" && /an/i.test(text), `der Knopf steht jetzt auf "${text}" (${lage})`);
|
||||
|
||||
const strich = await seite.$eval(".glocke__strich", (e) => getComputedStyle(e).opacity);
|
||||
ok(Number(strich) < 0.2, `der Strich ist weg (${strich})`);
|
||||
|
||||
/* NOCH EINMAL KLICKEN darf nicht verdoppeln -- INSERT OR REPLACE auf
|
||||
dem Endpunkt. Sonst käme jede Nachricht mehrfach an. */
|
||||
await seite.click("#glocke"); // aus
|
||||
await seite.waitForTimeout(900);
|
||||
ok(zahl() === 0, `nach dem Abmelden: ${zahl()} Anmeldungen`);
|
||||
|
||||
await seite.click("#glocke"); // wieder an
|
||||
await seite.waitForTimeout(1200);
|
||||
await seite.click("#glocke"); // aus
|
||||
await seite.waitForTimeout(900);
|
||||
await seite.click("#glocke"); // an
|
||||
await seite.waitForTimeout(1200);
|
||||
ok(zahl() === 1, `nach mehrfachem Hin und Her: genau ${zahl()} Anmeldung, keine Dubletten`);
|
||||
|
||||
/* Und der Stand-Weg zeigt sie -- ohne den vollen Endpunkt zu
|
||||
verraten, der die Zustelladresse dieses Geräts ist. */
|
||||
const stand = await seite.evaluate(async () => {
|
||||
const a = await fetch("/workspace/api/push/stand", { credentials: "same-origin" });
|
||||
return a.json();
|
||||
});
|
||||
ok(stand.geraete?.length === 1 && !stand.geraete[0].endpunkt,
|
||||
`der Stand nennt ${stand.geraete?.length} Gerät, aber nicht dessen volle Adresse`);
|
||||
ok(Array.isArray(stand.arten) && stand.arten.length >= 3,
|
||||
`${stand.arten?.length} Arten von Benachrichtigungen einstellbar`);
|
||||
|
||||
p.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE — misst das oben überhaupt etwas?
|
||||
======================================================================= */
|
||||
melde("\n=== Gegenprobe ===");
|
||||
{
|
||||
const browser = await pw.chromium.launch();
|
||||
const ctx = await browser.newContext({ permissions: ["notifications"] });
|
||||
await ctx.addInitScript(berechtigung("granted"));
|
||||
const seite = await ctx.newPage();
|
||||
/* Ein Doppel, dessen subscribe SCHEITERT. Der Knopf darf dann NICHT
|
||||
auf "an" springen -- täte er es, wäre die Anzeige eine Lüge, und
|
||||
alle Haken oben wären wertlos. */
|
||||
await seite.addInitScript(`
|
||||
(() => {
|
||||
const reg = { pushManager: {
|
||||
subscribe: async () => { throw new Error('Registration failed'); },
|
||||
getSubscription: async () => null,
|
||||
} };
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: { register: async () => reg, getRegistration: async () => reg,
|
||||
ready: Promise.resolve(reg), addEventListener: () => {} },
|
||||
});
|
||||
})();
|
||||
`);
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
|
||||
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: 25000 });
|
||||
await seite.waitForTimeout(500);
|
||||
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const vorher = p.prepare("SELECT COUNT(*) AS n FROM push_anmeldungen").get().n;
|
||||
await seite.click("#glocke");
|
||||
await seite.waitForTimeout(1200);
|
||||
const nachher = p.prepare("SELECT COUNT(*) AS n FROM push_anmeldungen").get().n;
|
||||
const lage = await seite.$eval("#glocke", (e) => e.dataset.lage);
|
||||
|
||||
ok(lage !== "an" && nachher === vorher,
|
||||
`wenn subscribe scheitert: Knopf bleibt auf "${lage}", `
|
||||
+ `Anmeldungen ${vorher} → ${nachher} (keine erfundene)`);
|
||||
|
||||
/* Und die Seite darf daran nicht zerbrechen. */
|
||||
const lebt = await seite.evaluate(() => !!document.getElementById("glocke"));
|
||||
ok(lebt, "die Seite läuft weiter, der Knopf ist noch da");
|
||||
|
||||
p.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
|
||||
melde(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
if (!echtGeht) {
|
||||
melde(`HINWEIS: Der Schritt "Browser holt eine Adresse beim Push-Dienst" war`);
|
||||
melde(`hier nicht messbar (kein Zugang zu FCM). Alles andere wurde geprüft.`);
|
||||
}
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,209 @@
|
||||
/* DER GANZE WEG EINER BENACHRICHTIGUNG.
|
||||
|
||||
pruef-push.mjs beweist, dass die RECHNUNG stimmt -- gegen die
|
||||
Testvektoren von RFC 8291 und 8292. Diese Pruefung geht einen Schritt
|
||||
weiter und fragt: Kommt am anderen Ende auch wirklich der Text an?
|
||||
|
||||
Dafuer wird ein Browser nachgespielt. Er erzeugt sein Schluesselpaar
|
||||
wie ein echter, der Server verschickt eine Nachricht an einen
|
||||
nachgebauten Push-Dienst, und dieser "Browser" macht sie mit seinem
|
||||
privaten Schluessel wieder auf.
|
||||
|
||||
WARUM DAS NOETIG IST, obwohl die Testvektoren schon stimmen: Der
|
||||
Vektor prueft EINEN festen Fall mit festem Salz und festem
|
||||
Schluesselpaar. Im Betrieb werden beide je Nachricht neu erzeugt --
|
||||
und ein Fehler genau dort (etwa ein Schluessel in der falschen
|
||||
Reihenfolge) faellt gegen einen festen Vektor nicht auf.
|
||||
|
||||
Dazu die zwei Dinge, die im Alltag zaehlen:
|
||||
* Der Push-Dienst darf den Inhalt NICHT lesen koennen.
|
||||
* Eine tote Anmeldung muss als tot erkannt werden, sonst scheitert
|
||||
jeder weitere Lauf an derselben Stelle.
|
||||
|
||||
GEGENPROBE am Ende: Mit dem falschen Schluessel darf sich die
|
||||
Nachricht NICHT aufmachen lassen. */
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { createECDH, hkdfSync, createDecipheriv } from "node:crypto";
|
||||
import {
|
||||
verschluesseln, schluesselErzeugen, schicken, b64u,
|
||||
} from "./workspace-push-krypto.js";
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Ein Browser, wie er sich anmelden wuerde -------------------------- */
|
||||
const browser = createECDH("prime256v1");
|
||||
browser.generateKeys();
|
||||
const auth = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
|
||||
const anmeldung = { p256dh: b64u(browser.getPublicKey()), auth: b64u(auth) };
|
||||
|
||||
/** Macht die Nachricht auf -- genau wie es der echte Browser tut
|
||||
* (RFC 8291, in die andere Richtung gelesen). */
|
||||
function entschluesseln(paket, wer = browser, geheim16 = auth) {
|
||||
const salz = paket.subarray(0, 16);
|
||||
const len = paket.readUInt8(20);
|
||||
const senderOeff = paket.subarray(21, 21 + len);
|
||||
const rest = paket.subarray(21 + len);
|
||||
|
||||
const gemeinsam = wer.computeSecret(senderOeff);
|
||||
const prkInfo = Buffer.concat([
|
||||
Buffer.from("WebPush: info\0"), wer.getPublicKey(), senderOeff,
|
||||
]);
|
||||
const ikm = Buffer.from(hkdfSync("sha256", gemeinsam, geheim16, prkInfo, 32));
|
||||
const cek = Buffer.from(hkdfSync("sha256", ikm, salz,
|
||||
Buffer.from("Content-Encoding: aes128gcm\0"), 16));
|
||||
const nonce = Buffer.from(hkdfSync("sha256", ikm, salz,
|
||||
Buffer.from("Content-Encoding: nonce\0"), 12));
|
||||
|
||||
const tag = rest.subarray(rest.length - 16);
|
||||
const daten = rest.subarray(0, rest.length - 16);
|
||||
const dec = createDecipheriv("aes-128-gcm", cek, nonce);
|
||||
dec.setAuthTag(tag);
|
||||
const klar = Buffer.concat([dec.update(daten), dec.final()]);
|
||||
return klar.subarray(0, klar.length - 1).toString("utf8"); // Polsterbyte weg
|
||||
}
|
||||
|
||||
const TEXT = JSON.stringify({
|
||||
titel: "Es funktioniert",
|
||||
text: "Probe aus dem Workspace",
|
||||
ziel: "/workspace/start.html",
|
||||
});
|
||||
|
||||
/* =======================================================================
|
||||
1. Verschluesseln und wieder aufmachen
|
||||
======================================================================= */
|
||||
console.log("\n=== Verschluesseln -> aufmachen ===");
|
||||
{
|
||||
const paket = verschluesseln(TEXT, anmeldung.p256dh, anmeldung.auth);
|
||||
ok(paket.length > 86, `verschluesselt (${paket.length} Bytes)`);
|
||||
const zurueck = entschluesseln(paket);
|
||||
ok(zurueck === TEXT, zurueck === TEXT
|
||||
? "der Browser bekommt GENAU den Text zurueck"
|
||||
: `ABWEICHUNG: ${zurueck.slice(0, 60)}`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
2. Zehnmal hintereinander -- jedes Mal neue Schluessel
|
||||
======================================================================= */
|
||||
console.log("\n=== Zehn Nachrichten, jede mit eigenem Schluessel ===");
|
||||
{
|
||||
const gesehen = new Set();
|
||||
let alleLesbar = true;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const p = verschluesseln(TEXT, anmeldung.p256dh, anmeldung.auth);
|
||||
gesehen.add(b64u(p));
|
||||
if (entschluesseln(p) !== TEXT) alleLesbar = false;
|
||||
}
|
||||
ok(gesehen.size === 10, `${gesehen.size} von 10 sind verschieden (Salz und Paar sind neu)`);
|
||||
ok(alleLesbar, "und trotzdem alle zehn lesbar");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. Ueber HTTP, wie beim echten Dienst
|
||||
======================================================================= */
|
||||
console.log("\n=== Zustellung ueber HTTP ===");
|
||||
let empfangen = null, kopfzeilen = null;
|
||||
{
|
||||
const dienst = createServer((q, a) => {
|
||||
const stuecke = [];
|
||||
q.on("data", (c) => stuecke.push(c));
|
||||
q.on("end", () => {
|
||||
empfangen = Buffer.concat(stuecke);
|
||||
kopfzeilen = q.headers;
|
||||
a.writeHead(201); a.end();
|
||||
});
|
||||
});
|
||||
await new Promise((r) => dienst.listen(4341, "127.0.0.1", r));
|
||||
|
||||
const paar = schluesselErzeugen();
|
||||
const e = await schicken({ endpunkt: "http://127.0.0.1:4341/push/abc", ...anmeldung },
|
||||
TEXT, paar, "mailto:[email protected]");
|
||||
|
||||
ok(e.ok, `zugestellt (Status ${e.status})`);
|
||||
ok(!!empfangen && empfangen.length > 86, `der Dienst hat ${empfangen?.length || 0} Bytes bekommen`);
|
||||
ok(kopfzeilen?.["content-encoding"] === "aes128gcm",
|
||||
`Kodierung: ${kopfzeilen?.["content-encoding"]}`);
|
||||
ok(String(kopfzeilen?.authorization || "").startsWith("vapid t="),
|
||||
"die VAPID-Signatur ist dabei");
|
||||
ok(kopfzeilen?.ttl === "3600", `TTL: ${kopfzeilen?.ttl}`);
|
||||
ok(entschluesseln(empfangen) === TEXT, "und der Inhalt kommt unveraendert an");
|
||||
dienst.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
4. Der Push-Dienst sieht nichts vom Inhalt
|
||||
======================================================================= */
|
||||
console.log("\n=== Was der Push-Dienst zu sehen bekommt ===");
|
||||
{
|
||||
const alsText = empfangen.toString("utf8");
|
||||
const alsLatin = empfangen.toString("latin1");
|
||||
ok(!alsText.includes("Es funktioniert") && !alsLatin.includes("Es funktioniert"),
|
||||
"der Titel steht nicht im Klartext im Paket");
|
||||
ok(!alsText.includes("Workspace") && !alsLatin.includes("Workspace"),
|
||||
"auch sonst kein lesbares Wort");
|
||||
/* Das ist der eigentliche Grund, warum dieser Weg vertretbar ist:
|
||||
Google, Mozilla und Apple leiten weiter, ohne mitlesen zu koennen. */
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
5. Tote Anmeldungen
|
||||
======================================================================= */
|
||||
console.log("\n=== Ein Geraet, das es nicht mehr gibt ===");
|
||||
{
|
||||
const paar = schluesselErzeugen();
|
||||
for (const [code, erwartetWeg] of [[410, true], [404, true], [500, false], [429, false]]) {
|
||||
const d = createServer((q, a) => { a.writeHead(code); a.end(); });
|
||||
await new Promise((r) => d.listen(4342, "127.0.0.1", r));
|
||||
const e = await schicken({ endpunkt: "http://127.0.0.1:4342/x", ...anmeldung },
|
||||
TEXT, paar, "mailto:[email protected]");
|
||||
ok(e.weg === erwartetWeg,
|
||||
`${code} -> weg=${e.weg} (erwartet ${erwartetWeg})`
|
||||
+ (erwartetWeg ? " — die Anmeldung wird geloescht" : " — sie bleibt, es kann voruebergehend sein"));
|
||||
d.close();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
6. Ein Dienst, der gar nicht antwortet
|
||||
======================================================================= */
|
||||
console.log("\n=== Wenn der Push-Dienst schweigt ===");
|
||||
{
|
||||
const paar = schluesselErzeugen();
|
||||
/* Adresse, die niemand bedient. Der Aufruf darf NICHT werfen und
|
||||
nicht ewig haengen -- sonst bliebe der ganze Lauf stehen und die
|
||||
uebrigen Empfaenger bekaemen nichts. */
|
||||
const e = await schicken({ endpunkt: "http://127.0.0.1:4399/tot", ...anmeldung },
|
||||
TEXT, paar, "mailto:[email protected]");
|
||||
ok(e.ok === false && e.weg === false,
|
||||
`kein Absturz, die Anmeldung bleibt (${e.grund?.slice(0, 40) || e.status})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE — mit dem falschen Schluessel darf es NICHT gehen
|
||||
======================================================================= */
|
||||
console.log("\n=== Gegenprobe ===");
|
||||
{
|
||||
const fremder = createECDH("prime256v1");
|
||||
fremder.generateKeys();
|
||||
const paket = verschluesseln(TEXT, anmeldung.p256dh, anmeldung.auth);
|
||||
|
||||
let ging = false;
|
||||
try { ging = entschluesseln(paket, fremder, auth) === TEXT; } catch { ging = false; }
|
||||
ok(!ging, "ein fremder Schluessel kann die Nachricht NICHT aufmachen");
|
||||
|
||||
let mitFalschemGeheimnis = false;
|
||||
try {
|
||||
const anderes = Buffer.from(auth); anderes[0] ^= 0xff;
|
||||
mitFalschemGeheimnis = entschluesseln(paket, browser, anderes) === TEXT;
|
||||
} catch { mitFalschemGeheimnis = false; }
|
||||
ok(!mitFalschemGeheimnis, "auch mit falschem Geheimnis nicht");
|
||||
|
||||
/* Und der Beweis, dass diese Messung ueberhaupt "geht auf" erkennt. */
|
||||
ok(entschluesseln(paket) === TEXT,
|
||||
"mit dem richtigen Schluessel dagegen schon — die Messung unterscheidet also");
|
||||
}
|
||||
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,193 @@
|
||||
/* WEB PUSH — gerechnet gegen die Testvektoren der RFCs.
|
||||
|
||||
WARUM DAS DIE WICHTIGSTE PRUEFUNG DIESES BAUSTEINS IST.
|
||||
|
||||
Die Verschluesselung fuer Web Push ist an einer Stelle besonders
|
||||
unangenehm: Stimmt irgendetwas nicht -- ein Byte im Info-Text, die
|
||||
Reihenfolge zweier Schluessel, das Polster am Ende --, dann verwirft
|
||||
der Browser die Nachricht STILL. Kein Fehler, keine Meldung, keine
|
||||
Konsolenzeile. Der Push-Dienst antwortet mit 201 "angenommen", und
|
||||
trotzdem kommt nie etwas an.
|
||||
|
||||
Man kann so etwas nicht durch Ausprobieren richtig bekommen. Deshalb
|
||||
wird hier nicht "sieht gut aus" geprueft, sondern gegen die
|
||||
vollstaendigen Beispieldaten aus
|
||||
|
||||
RFC 8291, Anhang A (Message Encryption for Web Push)
|
||||
RFC 8292 (VAPID)
|
||||
|
||||
Trifft die eigene Rechnung diese Zahlen, ist sie richtig -- nicht
|
||||
wahrscheinlich richtig, sondern richtig. Und wenn sich morgen jemand
|
||||
vertippt, faellt es in derselben Sekunde auf.
|
||||
|
||||
GEGENPROBE am Ende: ein absichtlich veraenderter Schluessel MUSS ein
|
||||
anderes Ergebnis liefern. Eine Pruefung, die immer bestaetigt,
|
||||
bestaetigt nichts. */
|
||||
|
||||
import {
|
||||
verschluesseln, vapidKopf, schluesselErzeugen, b64u, ausB64u,
|
||||
} from "./workspace-push-krypto.js";
|
||||
import { createECDH, createVerify, createPublicKey } from "node:crypto";
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* =======================================================================
|
||||
1. RFC 8291, Anhang A — der vollstaendige Testvektor
|
||||
======================================================================= */
|
||||
console.log("\n=== RFC 8291 Anhang A: Verschluesselung ===");
|
||||
{
|
||||
/* Die Werte stehen woertlich im RFC. */
|
||||
const klartext = "When I grow up, I want to be a watermelon";
|
||||
const empfaengerOeff = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx"
|
||||
+ "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4";
|
||||
const auth = "BTBZMqHH6r4Tts7J_aSIgg";
|
||||
const salz = ausB64u("DGv6ra1nlYgDCS1FRnbzlw");
|
||||
const eigenerPrivat = ausB64u("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw");
|
||||
|
||||
/* Das erwartete Ergebnis, woertlich aus dem RFC (dort ueber mehrere
|
||||
Zeilen umbrochen, hier zusammengesetzt).
|
||||
|
||||
⚠️ AUS DER QUELLE ABSCHREIBEN, NICHT AUS DEM GEDAECHTNIS. Der erste
|
||||
Entwurf dieser Pruefung trug hier einen Wert, der ab Zeichen 61
|
||||
abwich -- die Pruefung meldete einen Fehler, und der lag in ihr
|
||||
selbst, nicht in der Rechnung. Der Kopf (Salz, Groesse,
|
||||
Schluessel) stimmte, weil er unmittelbar aus den Eingaben folgt;
|
||||
nur der verschluesselte Teil war erfunden. Eine Pruefung mit einer
|
||||
falschen Erwartung ist schlimmer als keine: Sie schickt die
|
||||
Fehlersuche an die falsche Stelle. */
|
||||
const erwartet = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27"
|
||||
+ "mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6"
|
||||
+ "cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulc"
|
||||
+ "y4a-fN";
|
||||
|
||||
const ergebnis = verschluesseln(klartext, empfaengerOeff, auth, salz, eigenerPrivat);
|
||||
const alsText = b64u(ergebnis);
|
||||
|
||||
ok(alsText === erwartet,
|
||||
alsText === erwartet
|
||||
? `die Rechnung trifft den Testvektor auf das Byte genau (${ergebnis.length} Bytes)`
|
||||
: `ABWEICHUNG\n erwartet: ${erwartet.slice(0, 60)}…\n`
|
||||
+ ` bekommen: ${alsText.slice(0, 60)}…`);
|
||||
|
||||
/* Die Form des Ergebnisses, unabhaengig vom Inhalt: 16 Byte Salz,
|
||||
4 Byte Groesse, 1 Byte Laenge, 65 Byte Schluessel, dann der Rest. */
|
||||
ok(ergebnis.length === 16 + 4 + 1 + 65 + klartext.length + 1 + 16,
|
||||
`die Laenge stimmt (${ergebnis.length} Bytes: Kopf 86 + Text ${klartext.length} + Polster 1 + Pruefsumme 16)`);
|
||||
ok(ergebnis.readUInt8(20) === 65, "die Schluessellaenge im Kopf steht auf 65");
|
||||
ok(ergebnis.readUInt32BE(16) === 4096, "die Datensatzgroesse steht auf 4096");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
2. Jede Nachricht bekommt ein eigenes Salz und ein eigenes Paar
|
||||
======================================================================= */
|
||||
console.log("\n=== Kein Schluessel wird zweimal benutzt ===");
|
||||
{
|
||||
const empf = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx"
|
||||
+ "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4";
|
||||
const auth = "BTBZMqHH6r4Tts7J_aSIgg";
|
||||
const a = verschluesseln("dieselbe Nachricht", empf, auth);
|
||||
const b = verschluesseln("dieselbe Nachricht", empf, auth);
|
||||
|
||||
/* Zwei Verschluesselungen desselben Textes muessen VERSCHIEDEN
|
||||
aussehen. Waeren sie gleich, wuerde entweder das Salz oder das
|
||||
Schluesselpaar wiederverwendet -- der klassische Fehler an dieser
|
||||
Stelle, und einer, der die Verschluesselung angreifbar macht. */
|
||||
ok(!a.equals(b), "zweimal derselbe Text ergibt zwei verschiedene Nachrichten");
|
||||
ok(!a.subarray(0, 16).equals(b.subarray(0, 16)), "das Salz ist jedes Mal neu");
|
||||
ok(!a.subarray(21, 86).equals(b.subarray(21, 86)), "das Schluesselpaar ist jedes Mal neu");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. VAPID — ist das JWT echt und gueltig?
|
||||
======================================================================= */
|
||||
console.log("\n=== RFC 8292: VAPID-Signatur ===");
|
||||
{
|
||||
const paar = schluesselErzeugen();
|
||||
ok(ausB64u(paar.oeffentlich).length === 65,
|
||||
`oeffentlicher Schluessel: ${ausB64u(paar.oeffentlich).length} Bytes (65 erwartet)`);
|
||||
ok(ausB64u(paar.privat).length === 32,
|
||||
`privater Schluessel: ${ausB64u(paar.privat).length} Bytes (32 erwartet)`);
|
||||
|
||||
const kopf = vapidKopf("https://fcm.googleapis.com/fcm/send/abc123", paar,
|
||||
"mailto:[email protected]");
|
||||
|
||||
ok(kopf.Authorization.startsWith("vapid t="), "die Kopfzeile hat die vorgeschriebene Form");
|
||||
ok(kopf["Content-Encoding"] === "aes128gcm", "die Kodierung ist aes128gcm");
|
||||
|
||||
const jwt = kopf.Authorization.match(/t=([^,]+)/)[1];
|
||||
const [t1, t2, sig] = jwt.split(".");
|
||||
const kopfDaten = JSON.parse(ausB64u(t1).toString());
|
||||
const inhalt = JSON.parse(ausB64u(t2).toString());
|
||||
|
||||
ok(kopfDaten.alg === "ES256", `Verfahren: ${kopfDaten.alg}`);
|
||||
ok(inhalt.aud === "https://fcm.googleapis.com",
|
||||
`Empfaenger: ${inhalt.aud} (nur Schema und Host, kein Pfad)`);
|
||||
ok(inhalt.sub === "mailto:[email protected]", `Absender: ${inhalt.sub}`);
|
||||
const stunden = (inhalt.exp - Math.floor(Date.now() / 1000)) / 3600;
|
||||
ok(stunden > 0 && stunden <= 24, `laeuft in ${stunden.toFixed(1)} Stunden ab (hoechstens 24)`);
|
||||
|
||||
/* DER EIGENTLICHE BEWEIS: Laesst sich die Signatur mit dem
|
||||
oeffentlichen Schluessel pruefen? Genau das tut der Push-Dienst.
|
||||
Alles davor waere hinfaellig, wenn das hier nicht stimmt. */
|
||||
const roh = ausB64u(paar.oeffentlich);
|
||||
const der = Buffer.concat([
|
||||
Buffer.from("3059301306072a8648ce3d020106082a8648ce3d030107034200", "hex"), roh,
|
||||
]);
|
||||
const pub = createPublicKey({ key: der, format: "der", type: "spki" });
|
||||
const echt = createVerify("SHA256").update(`${t1}.${t2}`)
|
||||
.verify({ key: pub, dsaEncoding: "ieee-p1363" }, ausB64u(sig));
|
||||
ok(echt, "die Signatur laesst sich mit dem oeffentlichen Schluessel pruefen");
|
||||
ok(ausB64u(sig).length === 64, `Signatur: ${ausB64u(sig).length} Bytes (64, also r||s roh)`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
4. Unbrauchbare Angaben werden abgewiesen
|
||||
======================================================================= */
|
||||
console.log("\n=== Kaputte Anmeldedaten ===");
|
||||
{
|
||||
const guteAuth = "BTBZMqHH6r4Tts7J_aSIgg";
|
||||
for (const [wie, p256dh, auth] of [
|
||||
["zu kurzer Schluessel", b64u(Buffer.alloc(30)), guteAuth],
|
||||
["Schluessel ohne 0x04 vorn", b64u(Buffer.alloc(65)), guteAuth],
|
||||
["zu kurzes Geheimnis", "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx"
|
||||
+ "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", b64u(Buffer.alloc(8))],
|
||||
["leer", "", ""],
|
||||
]) {
|
||||
let geworfen = false;
|
||||
try { verschluesseln("test", p256dh, auth); } catch { geworfen = true; }
|
||||
ok(geworfen, `${wie} wird abgewiesen`);
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE — merkt die Pruefung ueberhaupt einen Fehler?
|
||||
======================================================================= */
|
||||
console.log("\n=== Gegenprobe (muss abweichen) ===");
|
||||
{
|
||||
const klartext = "When I grow up, I want to be a watermelon";
|
||||
const empf = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx"
|
||||
+ "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4";
|
||||
const auth = "BTBZMqHH6r4Tts7J_aSIgg";
|
||||
const salz = ausB64u("DGv6ra1nlYgDCS1FRnbzlw");
|
||||
const privat = ausB64u("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw");
|
||||
const richtig = b64u(verschluesseln(klartext, empf, auth, salz, privat));
|
||||
|
||||
/* Ein einziges verändertes Byte im Geheimnis muss ein voellig
|
||||
anderes Ergebnis liefern. Waere es dasselbe, ginge das Geheimnis
|
||||
gar nicht in die Rechnung ein -- und die Uebereinstimmung oben
|
||||
waere Zufall. */
|
||||
const andereAuth = b64u(Buffer.concat([ausB64u(auth).subarray(0, 15), Buffer.from([0xff])]));
|
||||
const anders = b64u(verschluesseln(klartext, empf, andereAuth, salz, privat));
|
||||
ok(anders !== richtig, "ein veraendertes Geheimnis ergibt ein anderes Ergebnis");
|
||||
|
||||
const andererText = b64u(verschluesseln(klartext + "!", empf, auth, salz, privat));
|
||||
ok(andererText !== richtig, "ein veraenderter Text ergibt ein anderes Ergebnis");
|
||||
|
||||
const anderesSalz = b64u(verschluesseln(klartext, empf, auth,
|
||||
Buffer.concat([salz.subarray(0, 15), Buffer.from([0x00])]), privat));
|
||||
ok(anderesSalz !== richtig, "ein veraendertes Salz ergibt ein anderes Ergebnis");
|
||||
}
|
||||
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -401,6 +401,95 @@ console.log(`
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 11. Laesst sich jedes Server-Modul ueberhaupt laden? --------
|
||||
|
||||
`node --check datei.js` prueft eine .js-Datei als CommonJS. Dieses
|
||||
Haus faehrt aber ESM ("type": "module" in package.json). Der
|
||||
Unterschied ist keine Formsache: Ein Syntaxfehler kann dabei
|
||||
durchrutschen und die Datei trotzdem beim Start abstuerzen lassen.
|
||||
|
||||
Genau das ist am 05.09.2026 passiert. In einem SQL-Block -- einem
|
||||
Template-Literal mit Backticks -- stand in einem KOMMENTAR ein Wort
|
||||
in Backticks. Das beendete das Literal mitten im Text. `node --check`
|
||||
meldete "Syntax ok", der Import scheiterte mit "missing ) after
|
||||
argument list", und der Server waere beim naechsten Start gar nicht
|
||||
hochgekommen.
|
||||
|
||||
Deshalb wird hier jedes Modul WIRKLICH geladen. Das ist die einzige
|
||||
Pruefung, die dieselbe Frage stellt wie der Serverstart. */
|
||||
console.log(`\n=== Laedt jedes Server-Modul? ===`);
|
||||
{
|
||||
/* GEPRUEFT WIRD MIT NODE SELBST, nicht mit einem Suchmuster.
|
||||
|
||||
Der erste Entwurf suchte per regulaerem Ausdruck nach Backticks in
|
||||
SQL-Bloecken -- und schlug bei drei Bloecken an, die voellig in
|
||||
Ordnung sind: Enthaelt ein Block ein ${...}, grenzt das Muster ihn
|
||||
falsch ab. Eine Warnung, die falsch ist, ist schlimmer als keine;
|
||||
sie kostet beim naechsten Mal zehn Minuten und wird danach
|
||||
ignoriert.
|
||||
|
||||
Node kann die Frage selbst beantworten. Die Datei wird als .mjs
|
||||
kopiert und mit `node --check` geprueft -- damit gilt dieselbe
|
||||
Regel wie beim Start (ESM statt CommonJS). Genau darin lag der
|
||||
Fehler vom 05.09.2026: `node --check datei.js` meldete "ok",
|
||||
der Import scheiterte trotzdem. */
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const { writeFileSync, unlinkSync, mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
const module = readdirSync(SERVER).filter((n) => n.endsWith(".js"));
|
||||
ok(module.length >= 20, `${module.length} Server-Module gefunden`);
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "modulcheck-"));
|
||||
const kaputt = [];
|
||||
for (const datei of module) {
|
||||
const kopie = join(ordner, datei.replace(/\.js$/, ".mjs"));
|
||||
writeFileSync(kopie, readFileSync(join(SERVER, datei), "utf8"));
|
||||
try {
|
||||
execFileSync(process.execPath, ["--check", kopie], { stdio: "pipe" });
|
||||
} catch (f) {
|
||||
const meldung = String(f.stderr || f.message).split(/\r?\n/)
|
||||
.find((z) => z.includes("Error")) || "Syntaxfehler";
|
||||
kaputt.push(`${datei}: ${meldung.trim().slice(0, 70)}`);
|
||||
}
|
||||
try { unlinkSync(kopie); } catch { /* egal */ }
|
||||
}
|
||||
ok(kaputt.length === 0,
|
||||
kaputt.length
|
||||
? `als ESM NICHT ladbar: ${kaputt.slice(0, 5).join(" · ")}`
|
||||
: `alle ${module.length} Module lassen sich als ESM einlesen`);
|
||||
|
||||
/* GEGENPROBE mit genau dem Fehler von heute.
|
||||
|
||||
Nachgebaut wird der echte Fall: ein Backtick in einem KOMMENTAR,
|
||||
der innerhalb eines Template-Literals steht. Sieht harmlos aus,
|
||||
beendet aber die Zeichenkette. */
|
||||
const boese = join(ordner, "gegenprobe.mjs");
|
||||
writeFileSync(boese, [
|
||||
"const d = { exec: () => {} };",
|
||||
"d.exec(`",
|
||||
" CREATE TABLE x (a TEXT);",
|
||||
" /* Der Wert `endpunkt` ist der Schluessel. */",
|
||||
"`);",
|
||||
].join("\n"));
|
||||
let erkannt = false;
|
||||
try { execFileSync(process.execPath, ["--check", boese], { stdio: "pipe" }); }
|
||||
catch { erkannt = true; }
|
||||
ok(erkannt, erkannt
|
||||
? "Gegenprobe: ein Backtick im SQL-Kommentar wird als Fehler erkannt"
|
||||
: "Gegenprobe MISSLUNGEN — die Pruefung wuerde den Fehler durchlassen");
|
||||
|
||||
/* Und der Beweis, dass sie nicht einfach alles ablehnt. */
|
||||
const brav = join(ordner, "gegenprobe-ok.mjs");
|
||||
writeFileSync(brav, "export const x = `alles gut ${1 + 1}`;\n");
|
||||
let bravOk = true;
|
||||
try { execFileSync(process.execPath, ["--check", brav], { stdio: "pipe" }); }
|
||||
catch { bravOk = false; }
|
||||
ok(bravOk, "Gegenprobe: eine saubere Datei wird durchgelassen");
|
||||
|
||||
for (const f of [boese, brav]) { try { unlinkSync(f); } catch { /* egal */ } }
|
||||
}
|
||||
|
||||
/* ---------- Gegenprobe --------------------------------------------------- */
|
||||
|
||||
console.log(`\n=== Gegenprobe (muss anschlagen) ===`);
|
||||
|
||||
@@ -107,12 +107,19 @@ const SPALTEN = `
|
||||
a.creator_id, a.verantwortlich_id, a.erstellt, a.geaendert, a.erledigt_am,
|
||||
a.creator_extern, a.verantwortlich_extern,
|
||||
${externSql("pc.name", "a.creator_extern")} AS creator_name,
|
||||
${externSql("pv.name", "a.verantwortlich_extern")} AS verantwortlich_name`;
|
||||
${externSql("pv.name", "a.verantwortlich_extern")} AS verantwortlich_name,
|
||||
/* Abbruch (05.09.2026). Der Grund gehört mit in die Liste, nicht
|
||||
hinter einen zweiten Aufruf: Wer den Bereich aufklappt, will genau
|
||||
das lesen -- ein Nachladen je Zeile wäre bei zwanzig abgebrochenen
|
||||
Aufgaben zwanzig Anfragen. */
|
||||
a.abbruch_grund, a.abgebrochen_am, a.status_vorher,
|
||||
pa.name AS abbruch_von_name`;
|
||||
|
||||
const VERBUND = `
|
||||
FROM aufgaben a
|
||||
LEFT JOIN personen pc ON pc.id = a.creator_id
|
||||
LEFT JOIN personen pv ON pv.id = a.verantwortlich_id`;
|
||||
LEFT JOIN personen pv ON pv.id = a.verantwortlich_id
|
||||
LEFT JOIN personen pa ON pa.id = a.abbruch_von`;
|
||||
|
||||
/* ---------- Lesen ------------------------------------------------------- */
|
||||
|
||||
@@ -493,6 +500,123 @@ aufgabenRouter.patch("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res)
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Abbrechen ----------------------------------------------------
|
||||
|
||||
Wunsch Filipe, 05.09.2026: *"ich will dass man in jedem status die
|
||||
aufgaben auch abbrechen kann. nur ich die manager und scouts sollen
|
||||
auch die aufgaben abbrechen können."*
|
||||
|
||||
WARUM EIN EIGENER WEG und nicht einfach `status: "abgebrochen"` über
|
||||
den PATCH oben: Dort entscheidet darfAendern() -- und das lässt auch
|
||||
den zuständigen Creator ändern. Stünde "abgebrochen" bloss in der
|
||||
STATUS-Liste, könnte ein Creator seine eigene Aufgabe abbrechen,
|
||||
ohne dass es irgendwo auffällt. Die Rollenregel gehört an EINE
|
||||
Stelle, an der man sie sieht.
|
||||
|
||||
Deshalb steht "abgebrochen" bewusst NICHT in STATUS: Der normale
|
||||
Statuswechsel kann diesen Zustand gar nicht erreichen.
|
||||
|
||||
Der Grund ist Pflicht. Ohne ihn weiss in vier Wochen niemand mehr,
|
||||
warum etwas wegfiel -- und dann ist es von Löschen nicht mehr zu
|
||||
unterscheiden. */
|
||||
|
||||
/** Wer darf abbrechen? DogFather, Manager, Scouts -- Creator nicht. */
|
||||
const DARF_ABBRECHEN = new Set(["admin", "manager", "scout"]);
|
||||
const ABBRUCH_GRUND_MAX = 500;
|
||||
|
||||
aufgabenRouter.post("/workspace/api/aufgaben/:id/abbrechen", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
if (!DARF_ABBRECHEN.has(req.person.rolle)) {
|
||||
return res.status(403).json({ fehler: "Nur DogFather, Manager und Scouts können Aufgaben abbrechen." });
|
||||
}
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const grund = String(req.body?.grund ?? "").trim();
|
||||
if (grund.length < 3) {
|
||||
return res.status(400).json({ fehler: "Bitte kurz sagen, warum die Aufgabe abgebrochen wird." });
|
||||
}
|
||||
if (grund.length > ABBRUCH_GRUND_MAX) {
|
||||
return res.status(400).json({ fehler: `Der Grund ist länger als ${ABBRUCH_GRUND_MAX} Zeichen.` });
|
||||
}
|
||||
|
||||
/* Wie beim PATCH: erst durch die Sichtbarkeitsregel. Was jemand
|
||||
nicht sehen darf, existiert für ihn nicht -- 404, nicht 403. */
|
||||
const { wo, werte } = sichtbar(req.person);
|
||||
const aufgabe = db().prepare(
|
||||
`SELECT a.* ${VERBUND} WHERE ${wo} AND a.id = ?`).get(...werte, id);
|
||||
if (!aufgabe) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
if (aufgabe.status === "abgebrochen") {
|
||||
return res.status(409).json({ fehler: "Diese Aufgabe ist schon abgebrochen." });
|
||||
}
|
||||
|
||||
/* status_vorher hält fest, WIE WEIT es war. "Im Review
|
||||
abgebrochen" ist eine ganz andere Aussage als "nie angefangen"
|
||||
-- und beim Wiederaufnehmen geht es genau dorthin zurück. */
|
||||
db().prepare(`
|
||||
UPDATE aufgaben
|
||||
SET status = 'abgebrochen', status_vorher = ?, abbruch_grund = ?,
|
||||
abgebrochen_am = ?, abbruch_von = ?, geaendert = ?, erledigt_am = NULL
|
||||
WHERE id = ?`)
|
||||
.run(aufgabe.status, grund, jetzt(), req.person.id, jetzt(), id);
|
||||
|
||||
protokolliere("aufgabe_abgebrochen", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${id} (${aufgabe.status}) ${grund}`.slice(0, 120),
|
||||
});
|
||||
res.json({ ok: true, status_vorher: aufgabe.status });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Aufgabe abbrechen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Wieder aufnehmen. Geht zurück in den Status, in dem abgebrochen
|
||||
* wurde -- nicht nach "offen". Wer eine Aufgabe im Review abbricht
|
||||
* und wieder aufnimmt, will sie im Review zurück, nicht von vorn.
|
||||
*
|
||||
* Dieselbe Rollenregel: Wer abbrechen darf, darf auch zurücknehmen.
|
||||
* Eine Handlung ohne Umkehr wäre hier falsch -- ein Abbruch aus
|
||||
* Versehen soll niemanden zwingen, die Aufgabe neu zu schreiben. */
|
||||
aufgabenRouter.post("/workspace/api/aufgaben/:id/wiederaufnehmen", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
if (!DARF_ABBRECHEN.has(req.person.rolle)) {
|
||||
return res.status(403).json({ fehler: "Nur DogFather, Manager und Scouts können das." });
|
||||
}
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const { wo, werte } = sichtbar(req.person);
|
||||
const aufgabe = db().prepare(
|
||||
`SELECT a.* ${VERBUND} WHERE ${wo} AND a.id = ?`).get(...werte, id);
|
||||
if (!aufgabe) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (aufgabe.status !== "abgebrochen") {
|
||||
return res.status(409).json({ fehler: "Diese Aufgabe läuft doch." });
|
||||
}
|
||||
|
||||
/* Fällt status_vorher aus (alte Zeile, von Hand geändert), geht es
|
||||
nach "offen" -- ein bekannter Zustand ist besser als ein leeres
|
||||
Feld, das den CHECK verletzt und die Aufgabe unerreichbar macht. */
|
||||
const zurueck = STATUS.includes(aufgabe.status_vorher) ? aufgabe.status_vorher : "offen";
|
||||
|
||||
db().prepare(`
|
||||
UPDATE aufgaben
|
||||
SET status = ?, status_vorher = NULL, abbruch_grund = NULL,
|
||||
abgebrochen_am = NULL, abbruch_von = NULL, geaendert = ?
|
||||
WHERE id = ?`).run(zurueck, jetzt(), id);
|
||||
|
||||
protokolliere("aufgabe_wiederaufgenommen", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${id} -> ${zurueck}`.slice(0, 120),
|
||||
});
|
||||
res.json({ ok: true, status: zurueck });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Aufgabe wiederaufnehmen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Löschen ------------------------------------------------------
|
||||
Nur Management. Das Konzept will ausdrücklich, dass Erledigtes sichtbar
|
||||
bleibt ("Erledigte Aufgaben verschwinden nicht") -- Löschen ist deshalb
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import express from "express";
|
||||
import {
|
||||
db, protokolliere, echteIp, sitzungLesen, istLeitung, istDogFather, betreutWo, termineSichtbar, heuteLokal,
|
||||
externPruefen, externSql, betreuteIds, ROLLEN_SORTIERUNG,
|
||||
externPruefen, externSql, betreuteIds, einladbareIds, ROLLEN_SORTIERUNG,
|
||||
} from "./workspace.js";
|
||||
import {
|
||||
nachfuellenAlle, serienPruefen, serieAnlegen, zuordnungErzwingen,
|
||||
@@ -96,10 +96,24 @@ const VERBUND = `
|
||||
und nicht in der Oberflaeche.
|
||||
===================================================================== */
|
||||
|
||||
/** Die Nummern, die diese Person an einem Termin eintragen darf. */
|
||||
/** Die Nummern, die diese Person an einem Termin eintragen darf.
|
||||
*
|
||||
* ERWEITERT AM 05.09.2026, und zwar aus einem Fehler heraus: Hier
|
||||
* stand nur `person.id` plus die BETREUTEN. Für einen Creator ergab
|
||||
* das genau eine Nummer -- seine eigene. Er hätte in der neuen Wahl
|
||||
* seinen Scout gesehen, ihn angeklickt, gespeichert, und der Server
|
||||
* hätte ihn still weggelassen. Ein Knopf, der nichts tut und nichts
|
||||
* sagt, ist schlimmer als ein fehlender.
|
||||
*
|
||||
* einladbareIds() nimmt jetzt beide Richtungen: wen ich führe UND wer
|
||||
* mich führt, dazu DogFather. Die Begründung, warum das keine
|
||||
* Aufweichung der Sichtbarkeit ist, steht dort. */
|
||||
function darfEintragen(person) {
|
||||
if (istLeitung(person)) return null; // null = alle
|
||||
const erlaubt = new Set([person.id]);
|
||||
const ids = einladbareIds(person);
|
||||
if (ids === null) return null;
|
||||
const erlaubt = new Set(ids);
|
||||
erlaubt.add(person.id); // sich selbst immer
|
||||
for (const id of betreuteIds(person)) erlaubt.add(id);
|
||||
return erlaubt;
|
||||
}
|
||||
@@ -183,6 +197,41 @@ export function teilnehmerZu(terminIds) {
|
||||
|
||||
/* ---------- Lesen ------------------------------------------------------- */
|
||||
|
||||
/** WEN KANN ICH ZU EINEM TERMIN DAZUSTELLEN?
|
||||
*
|
||||
* Eigener Weg statt /api/personen (05.09.2026). Der dortige liefert
|
||||
* die Liste für ZUWEISUNGEN und zeigt deshalb nur nach unten -- ein
|
||||
* Creator fände sich dort allein wieder und hätte ein leeres Feld.
|
||||
*
|
||||
* Die beiden Listen dürfen nicht dieselbe sein: Wer /api/personen
|
||||
* erweitert, gibt einem Creator nebenbei die Möglichkeit, seinem
|
||||
* Scout Aufgaben zuzuweisen. Deshalb zwei Wege mit zwei Regeln --
|
||||
* und beide enden bei derselben Prüfung, die teilnehmerSetzen()
|
||||
* ohnehin noch einmal anwendet. Diese Liste ist eine Bequemlichkeit,
|
||||
* keine Sicherung. */
|
||||
kalenderRouter.get("/workspace/api/einladbar", (req, res) => {
|
||||
try {
|
||||
const person = sitzungLesen(req);
|
||||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||||
|
||||
const ids = einladbareIds(person);
|
||||
const wo = ids === null ? "" : ` AND id IN (${ids.map(() => "?").join(",")})`;
|
||||
const werte = ids === null ? [] : ids;
|
||||
/* Ist die Liste leer, wäre "id IN ()" ungültiges SQL -- der Fall
|
||||
tritt ein, sobald jemand niemandem zugeordnet ist. */
|
||||
if (ids !== null && !ids.length) return res.json({ personen: [] });
|
||||
|
||||
res.json({
|
||||
personen: db().prepare(
|
||||
`SELECT id, name, rolle FROM personen WHERE aktiv = 1${wo}
|
||||
ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte),
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Einladbar:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
kalenderRouter.get("/workspace/api/termine", (req, res) => {
|
||||
try {
|
||||
/* Wiederkehrende Termine nachlegen, BEVOR gelesen wird -- sonst
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/* =====================================================================
|
||||
workspace-push-krypto.js — Web Push, verschlüsselt und signiert.
|
||||
|
||||
Der reine Rechenteil: Er nimmt eine Anmeldung (Endpunkt + zwei
|
||||
Schlüssel des Browsers) und einen Text, und macht daraus das, was
|
||||
der Push-Dienst von Google/Mozilla/Apple entgegennimmt.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WARUM SELBST UND NICHT `web-push`
|
||||
|
||||
Der Perfektionsplan nannte die Bibliothek `web-push`. Beim Nachsehen
|
||||
sprachen zwei Dinge dagegen:
|
||||
|
||||
LIZENZ web-push steht unter MPL-2.0. Die Hausregel lautet
|
||||
"bevorzugt MIT oder Apache 2.0, die dem Nutzer selbst
|
||||
gehört". MPL ist schwaches Copyleft -- benutzbar, aber
|
||||
nicht das, was hier vorgegeben ist.
|
||||
|
||||
UMFANG Sie bringt fünf weitere Pakete mit (asn1.js, http_ece,
|
||||
https-proxy-agent, jws, minimist), die ihrerseits welche
|
||||
haben. Der Workspace läuft bisher mit DREI Abhängigkeiten
|
||||
insgesamt; Anmeldung, Sitzungen und Datenbank kommen
|
||||
vollständig aus Node selbst.
|
||||
|
||||
Node 24 bringt jede benötigte Rechenart mit -- nachgemessen:
|
||||
createECDH, hkdfSync, createCipheriv (aes-128-gcm), createSign mit
|
||||
ES256 und ieee-p1363-Kodierung. Damit ist der Eigenbau kein
|
||||
Selbst-Erfinden von Kryptographie, sondern das Zusammensetzen
|
||||
vorhandener, geprüfter Bausteine nach zwei RFCs.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WAS HIER PASSIERT, IN ZWEI SCHRITTEN
|
||||
|
||||
1. VERSCHLÜSSELN (RFC 8291, "aes128gcm")
|
||||
Der Server erzeugt für JEDE Nachricht ein neues Schlüsselpaar,
|
||||
rechnet daraus mit dem öffentlichen Schlüssel des Browsers ein
|
||||
gemeinsames Geheimnis (ECDH), leitet daraus über HKDF den
|
||||
eigentlichen Schlüssel ab und verschlüsselt damit.
|
||||
|
||||
Der Push-Dienst selbst sieht dabei NUR Zeichensalat. Weder Google
|
||||
noch Apple erfahren, was in der Benachrichtigung steht -- das ist
|
||||
der Grund, warum dieser Weg überhaupt vertretbar ist.
|
||||
|
||||
2. SIGNIEREN (RFC 8292, "VAPID")
|
||||
Ein kurzlebiges JWT, signiert mit dem privaten Serverschlüssel.
|
||||
Es beweist dem Push-Dienst, dass die Nachricht wirklich von
|
||||
dogfather-universe.com kommt.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
GEPRÜFT WIRD GEGEN DIE TESTVEKTOREN DER RFCs, nicht gegen das eigene
|
||||
Gefühl. RFC 8291, Anhang A enthält vollständige Beispieldaten samt
|
||||
erwartetem Ergebnis. Wenn diese Rechnung sie trifft, ist sie richtig
|
||||
-- und wenn sich morgen jemand vertippt, fällt es sofort auf.
|
||||
Siehe pruef-push.mjs.
|
||||
===================================================================== */
|
||||
|
||||
import {
|
||||
createECDH, hkdfSync, createCipheriv, createSign, randomBytes,
|
||||
createPublicKey, createPrivateKey,
|
||||
} from "node:crypto";
|
||||
|
||||
/* ---------- Kleinkram --------------------------------------------------- */
|
||||
|
||||
/** Base64 ohne Polster und mit URL-tauglichen Zeichen -- die einzige
|
||||
* Schreibweise, die im Web-Push-Umfeld vorkommt. */
|
||||
export const b64u = (buf) => Buffer.from(buf).toString("base64")
|
||||
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
|
||||
export const ausB64u = (s) => Buffer.from(
|
||||
String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
||||
|
||||
/* ---------- 1. Verschlüsseln (RFC 8291) --------------------------------- */
|
||||
|
||||
/**
|
||||
* Verschlüsselt einen Text für genau eine Anmeldung.
|
||||
*
|
||||
* @param {string} text was ankommen soll (JSON)
|
||||
* @param {string} p256dhB64u öffentlicher Schlüssel des Browsers
|
||||
* @param {string} authB64u das Zufallsgeheimnis des Browsers
|
||||
* @param {Buffer} [salt] nur für die Prüfung gegen den Testvektor
|
||||
* @param {object} [eigenes] dito -- sonst wird je Nachricht neu erzeugt
|
||||
*/
|
||||
export function verschluesseln(text, p256dhB64u, authB64u, salt = null, eigenes = null) {
|
||||
const empfaenger = ausB64u(p256dhB64u); // 65 Byte, unkomprimiert
|
||||
const auth = ausB64u(authB64u); // 16 Byte
|
||||
|
||||
if (empfaenger.length !== 65 || empfaenger[0] !== 4) {
|
||||
throw new Error("Der Schluessel des Browsers hat nicht die erwartete Form.");
|
||||
}
|
||||
if (auth.length !== 16) {
|
||||
throw new Error("Das Geheimnis des Browsers hat nicht die erwartete Laenge.");
|
||||
}
|
||||
|
||||
/* Ein eigenes Paar JE NACHRICHT. Ein wiederverwendetes waere der
|
||||
klassische Fehler an dieser Stelle: Aus zwei Nachrichten mit
|
||||
demselben Schluessel und demselben Salz laesst sich der Klartext
|
||||
angreifen. */
|
||||
const ecdh = createECDH("prime256v1");
|
||||
if (eigenes) ecdh.setPrivateKey(eigenes);
|
||||
else ecdh.generateKeys();
|
||||
const eigenerOeff = ecdh.getPublicKey(); // 65 Byte
|
||||
const gemeinsam = ecdh.computeSecret(empfaenger);
|
||||
|
||||
const salz = salt || randomBytes(16);
|
||||
|
||||
/* Schritt 1: aus dem gemeinsamen Geheimnis und `auth` wird das
|
||||
"pseudo random key". Der Info-Text ist im RFC woertlich
|
||||
vorgeschrieben -- ein Tippfehler hier ergibt eine Nachricht, die
|
||||
der Browser stumm verwirft. */
|
||||
const prkInfo = Buffer.concat([
|
||||
Buffer.from("WebPush: info\0"), empfaenger, eigenerOeff,
|
||||
]);
|
||||
const ikm = Buffer.from(hkdfSync("sha256", gemeinsam, auth, prkInfo, 32));
|
||||
|
||||
/* Schritt 2: daraus Schluessel (16 Byte) und Startwert (12 Byte). */
|
||||
const cek = Buffer.from(hkdfSync("sha256", ikm, salz,
|
||||
Buffer.from("Content-Encoding: aes128gcm\0"), 16));
|
||||
const nonce = Buffer.from(hkdfSync("sha256", ikm, salz,
|
||||
Buffer.from("Content-Encoding: nonce\0"), 12));
|
||||
|
||||
/* Das Polster: Der Klartext bekommt eine 0x02 angehaengt (letzter
|
||||
Datensatz). Ohne dieses Byte verwirft der Browser die Nachricht --
|
||||
ohne jede Meldung, und man sucht den Fehler im eigenen Code. */
|
||||
const klartext = Buffer.concat([Buffer.from(text, "utf8"), Buffer.from([2])]);
|
||||
|
||||
const c = createCipheriv("aes-128-gcm", cek, nonce);
|
||||
const geheim = Buffer.concat([c.update(klartext), c.final(), c.getAuthTag()]);
|
||||
|
||||
/* Der Kopf des Datensatzes: Salz, Datensatzgroesse, Laenge und
|
||||
Inhalt des eigenen oeffentlichen Schluessels. */
|
||||
const kopf = Buffer.alloc(21);
|
||||
salz.copy(kopf, 0);
|
||||
kopf.writeUInt32BE(4096, 16);
|
||||
kopf.writeUInt8(eigenerOeff.length, 20);
|
||||
|
||||
return Buffer.concat([kopf, eigenerOeff, geheim]);
|
||||
}
|
||||
|
||||
/* ---------- 2. Signieren (RFC 8292, VAPID) ------------------------------ */
|
||||
|
||||
/** Erzeugt ein neues VAPID-Schluesselpaar. Einmal je Server. */
|
||||
export function schluesselErzeugen() {
|
||||
const ecdh = createECDH("prime256v1");
|
||||
ecdh.generateKeys();
|
||||
return {
|
||||
oeffentlich: b64u(ecdh.getPublicKey()),
|
||||
privat: b64u(ecdh.getPrivateKey()),
|
||||
};
|
||||
}
|
||||
|
||||
/** Der private Schluessel als PKCS8, damit createSign ihn annimmt.
|
||||
* Node kann aus rohen 32 Byte nichts machen -- der Umweg ueber DER
|
||||
* ist der uebliche und braucht keine ASN.1-Bibliothek, weil das
|
||||
* Geruest fuer P-256 immer gleich ist. */
|
||||
function privatAlsSchluessel(privatRoh, oeffentlichRoh) {
|
||||
const kopf = Buffer.from("308187020100301306072a8648ce3d020106082a8648ce3d030107046d306b0201010420", "hex");
|
||||
const mitte = Buffer.from("a144034200", "hex");
|
||||
const der = Buffer.concat([kopf, privatRoh, mitte, oeffentlichRoh]);
|
||||
return createPrivateKey({ key: der, format: "der", type: "pkcs8" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Kopfzeilen, die der Push-Dienst sehen will.
|
||||
*
|
||||
* @param {string} endpunkt die Adresse aus der Anmeldung
|
||||
* @param {object} paar { oeffentlich, privat } als base64url
|
||||
* @param {string} absender "mailto:..." -- wen der Dienst anschreibt,
|
||||
* wenn etwas nicht stimmt
|
||||
* @param {number} stunden wie lange das JWT gilt
|
||||
*/
|
||||
export function vapidKopf(endpunkt, paar, absender, stunden = 12) {
|
||||
const ziel = new URL(endpunkt);
|
||||
const jetzt = Math.floor(Date.now() / 1000);
|
||||
|
||||
const teil1 = b64u(JSON.stringify({ typ: "JWT", alg: "ES256" }));
|
||||
const teil2 = b64u(JSON.stringify({
|
||||
aud: `${ziel.protocol}//${ziel.host}`,
|
||||
/* Nicht laenger als 24 Stunden -- das schreibt der RFC vor, und
|
||||
Firefox lehnt laengere ab. */
|
||||
exp: jetzt + Math.min(stunden, 23) * 3600,
|
||||
sub: absender,
|
||||
}));
|
||||
|
||||
const schluessel = privatAlsSchluessel(ausB64u(paar.privat), ausB64u(paar.oeffentlich));
|
||||
/* `ieee-p1363` ist die rohe Form (r||s, 64 Byte). Die Voreinstellung
|
||||
waere DER -- die nimmt kein Push-Dienst an. */
|
||||
const sig = createSign("SHA256")
|
||||
.update(`${teil1}.${teil2}`)
|
||||
.sign({ key: schluessel, dsaEncoding: "ieee-p1363" });
|
||||
|
||||
return {
|
||||
Authorization: `vapid t=${teil1}.${teil2}.${b64u(sig)}, k=${paar.oeffentlich}`,
|
||||
"Content-Encoding": "aes128gcm",
|
||||
"Content-Type": "application/octet-stream",
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- 3. Verschicken ---------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Schickt eine Nachricht an eine Anmeldung.
|
||||
*
|
||||
* Gibt IMMER ein Ergebnis zurueck statt zu werfen -- der Aufrufer
|
||||
* verschickt an viele Menschen, und eine abgelaufene Anmeldung darf den
|
||||
* ganzen Lauf nicht abbrechen.
|
||||
*
|
||||
* `weg: true` heisst: Diese Anmeldung ist tot (Browser deinstalliert,
|
||||
* Berechtigung entzogen) und gehoert geloescht. Der Push-Dienst sagt
|
||||
* das mit 404 oder 410.
|
||||
*/
|
||||
export async function schicken(anmeldung, text, paar, absender, { ttl = 3600 } = {}) {
|
||||
try {
|
||||
const koerper = verschluesseln(text, anmeldung.p256dh, anmeldung.auth);
|
||||
const kopf = vapidKopf(anmeldung.endpunkt, paar, absender);
|
||||
|
||||
const a = await fetch(anmeldung.endpunkt, {
|
||||
method: "POST",
|
||||
headers: { ...kopf, TTL: String(ttl), Urgency: "normal" },
|
||||
body: koerper,
|
||||
/* Ein haengender Push-Dienst darf den Lauf nicht aufhalten. */
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (a.status === 404 || a.status === 410) return { ok: false, weg: true, status: a.status };
|
||||
if (!a.ok) {
|
||||
const grund = await a.text().catch(() => "");
|
||||
return { ok: false, weg: false, status: a.status, grund: grund.slice(0, 200) };
|
||||
}
|
||||
return { ok: true, status: a.status };
|
||||
} catch (fehler) {
|
||||
return { ok: false, weg: false, status: 0, grund: String(fehler?.message || fehler).slice(0, 200) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
/* =====================================================================
|
||||
workspace-push.js — Benachrichtigungen, die wirklich ankommen.
|
||||
|
||||
Wunsch Filipe (05.09.2026), mit dem Bild eines "Benachrichtigungen
|
||||
aus"-Knopfes: *"ich will auch sowas und dass es perfekt funktioniert
|
||||
auf der seite fuer jeden."*
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WAS DAS LOEST
|
||||
|
||||
Bis hierher erscheinen Hinweise NUR, wenn man die Seite aufruft. Wer
|
||||
nicht hinschaut, verpasst sie -- und genau das ist der Normalfall:
|
||||
Man macht den Workspace auf, wenn man ohnehin daran denkt. Der
|
||||
Perfektionsplan sagt dazu: *"Das Deck verspricht Erinnerungen. Wer
|
||||
nicht hinschaut, verpasst sie."*
|
||||
|
||||
---------------------------------------------------------------------
|
||||
OHNE FREMDEN DIENST, OHNE KONTO, OHNE GEBUEHR
|
||||
|
||||
Push API und Service Worker sind Browserstandard. Der Server braucht
|
||||
nur ein eigenes Schluesselpaar (VAPID); die Nachricht wird fuer jedes
|
||||
Geraet einzeln verschluesselt. Google, Mozilla und Apple leiten sie
|
||||
weiter, sehen aber nur Zeichensalat -- der Inhalt bleibt zwischen
|
||||
diesem Server und dem Browser.
|
||||
|
||||
Die Rechnung dafuer steht in workspace-push-krypto.js und ist gegen
|
||||
die Testvektoren von RFC 8291 und 8292 geprueft.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
VIER REGELN, DIE DARUEBER ENTSCHEIDEN, OB ES BENUTZT WIRD
|
||||
|
||||
1. NICHTS ZWEIMAL. Jede Erinnerung wird je Person und Sache genau
|
||||
einmal verschickt (Tabelle push_verschickt). Viermal am Tag
|
||||
dieselbe Meldung ist der schnellste Weg, dass jemand alles
|
||||
abschaltet -- und dann fehlt auch die wichtige.
|
||||
|
||||
2. NACHTS NICHT. Zwischen 22 und 7 Uhr wird nichts zugestellt.
|
||||
Aufgehoben wird nichts: Was morgens noch gilt, geht morgens raus;
|
||||
was sich erledigt hat, ist zu Recht weg.
|
||||
|
||||
3. JE ART ABSCHALTBAR, nicht alles oder nichts.
|
||||
|
||||
4. EINE BENACHRICHTIGUNG HAT IMMER EIN ZIEL. Wer sie antippt, landet
|
||||
an der Stelle, um die es geht -- nicht auf der Startseite.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WICHTIG FUERS IPHONE
|
||||
|
||||
Dort funktioniert Push NUR, wenn die Seite zum Home-Bildschirm
|
||||
hinzugefuegt wurde. Im Safari-Reiter gibt es keine Berechtigung --
|
||||
der Knopf sagt das dann auch genau so, statt einen Fehler zu zeigen.
|
||||
===================================================================== */
|
||||
|
||||
import express from "express";
|
||||
import {
|
||||
db, protokolliere, echteIp, sitzungLesen, istDogFather,
|
||||
einstellung, einstellungSetzen, heuteLokal,
|
||||
} from "./workspace.js";
|
||||
import { schluesselErzeugen, schicken } from "./workspace-push-krypto.js";
|
||||
|
||||
export const pushRouter = express.Router();
|
||||
|
||||
const jetzt = () => new Date().toISOString();
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
DIE ARTEN. Was es gibt, steht hier -- nicht in der Datenbank: Es ist
|
||||
eine bewusste Auswahl, keine Nutzdaten. Wer eine neue Art braucht,
|
||||
traegt sie hier ein und sie erscheint von selbst in den
|
||||
Einstellungen.
|
||||
------------------------------------------------------------------ */
|
||||
export const ARTEN = [
|
||||
{ schluessel: "aufgabe_faellig", name: "Aufgabe wird morgen fällig", vorgabe: true },
|
||||
{ schluessel: "aufgabe_ueberfaellig", name: "Aufgabe ist überfällig", vorgabe: true },
|
||||
{ schluessel: "termin_gleich", name: "Termin in einer Stunde", vorgabe: true },
|
||||
{ schluessel: "protokoll_fehlt", name: "Gespräch ohne Protokoll", vorgabe: true },
|
||||
{ schluessel: "followup", name: "Follow-up in der Pipeline fällig", vorgabe: true },
|
||||
{ schluessel: "test", name: "Probe-Benachrichtigung", vorgabe: true },
|
||||
];
|
||||
const ARTEN_SCHLUESSEL = new Set(ARTEN.map((a) => a.schluessel));
|
||||
|
||||
/* Ruhezeit. Nichts zwischen 22 und 7 Uhr. */
|
||||
const RUHE_AB = 22, RUHE_BIS = 7;
|
||||
export function istRuhezeit(d = new Date()) {
|
||||
const s = d.getHours();
|
||||
return s >= RUHE_AB || s < RUHE_BIS;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Das Schluesselpaar des Servers. Einmal erzeugt, danach in den
|
||||
Einstellungen -- NICHT im Code und nicht in einer Datei im Repo.
|
||||
Der private Teil verlaesst den Server nie.
|
||||
------------------------------------------------------------------ */
|
||||
export function paarHolen() {
|
||||
let roh = einstellung("vapid_paar");
|
||||
if (roh) {
|
||||
try {
|
||||
const p = JSON.parse(roh);
|
||||
if (p?.oeffentlich && p?.privat) return p;
|
||||
} catch { /* kaputt -- unten neu erzeugen */ }
|
||||
}
|
||||
const neu = schluesselErzeugen();
|
||||
einstellungSetzen("vapid_paar", JSON.stringify(neu));
|
||||
console.log("[push] Neues VAPID-Schluesselpaar erzeugt.");
|
||||
return neu;
|
||||
}
|
||||
|
||||
/** Die Absenderangabe fuer VAPID. Der Push-Dienst schreibt hierhin,
|
||||
* wenn dauerhaft etwas nicht stimmt. */
|
||||
const ABSENDER = process.env.PUSH_ABSENDER || "mailto:[email protected]";
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Will diese Person diese Art bekommen?
|
||||
------------------------------------------------------------------ */
|
||||
export function willHaben(personId, art) {
|
||||
const z = db().prepare(
|
||||
"SELECT an FROM push_einstellungen WHERE person_id = ? AND art = ?").get(personId, art);
|
||||
if (z) return !!z.an;
|
||||
return ARTEN.find((a) => a.schluessel === art)?.vorgabe ?? true;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
VERSCHICKEN — der einzige Weg nach draussen.
|
||||
|
||||
Alles laeuft hier durch: Ruhezeit, Einstellung, Doppelsperre,
|
||||
Aufraeumen toter Anmeldungen. Eine zweite Stelle, die selbst
|
||||
verschickt, wuerde eine dieser Regeln irgendwann vergessen.
|
||||
------------------------------------------------------------------ */
|
||||
export async function benachrichtige(personId, art, { titel, text, ziel, merkmal }) {
|
||||
if (!ARTEN_SCHLUESSEL.has(art)) return { verschickt: 0, grund: "unbekannte_art" };
|
||||
if (!willHaben(personId, art)) return { verschickt: 0, grund: "abgeschaltet" };
|
||||
|
||||
/* Die Probe darf immer durch -- sie beantwortet die Frage "kommt
|
||||
ueberhaupt etwas an?", und die stellt man sich auch um 23 Uhr. */
|
||||
if (art !== "test" && istRuhezeit()) return { verschickt: 0, grund: "ruhezeit" };
|
||||
|
||||
/* Schon einmal geschickt? */
|
||||
if (merkmal) {
|
||||
const da = db().prepare(
|
||||
"SELECT 1 FROM push_verschickt WHERE person_id = ? AND merkmal = ?").get(personId, merkmal);
|
||||
if (da) return { verschickt: 0, grund: "schon_geschickt" };
|
||||
}
|
||||
|
||||
const anmeldungen = db().prepare(
|
||||
"SELECT * FROM push_anmeldungen WHERE person_id = ?").all(personId);
|
||||
if (!anmeldungen.length) return { verschickt: 0, grund: "keine_geraete" };
|
||||
|
||||
const paar = paarHolen();
|
||||
const inhalt = JSON.stringify({ titel, text, ziel: ziel || "/workspace/start.html", art });
|
||||
|
||||
let raus = 0;
|
||||
for (const a of anmeldungen) {
|
||||
const e = await schicken(a, inhalt, paar, ABSENDER);
|
||||
if (e.ok) {
|
||||
raus++;
|
||||
db().prepare("UPDATE push_anmeldungen SET zuletzt_ok = ?, fehler = 0 WHERE endpunkt = ?")
|
||||
.run(jetzt(), a.endpunkt);
|
||||
} else if (e.weg) {
|
||||
/* Der Push-Dienst sagt: dieses Geraet gibt es nicht mehr.
|
||||
Aufheben waere sinnlos und wuerde bei jedem Lauf erneut
|
||||
scheitern. */
|
||||
db().prepare("DELETE FROM push_anmeldungen WHERE endpunkt = ?").run(a.endpunkt);
|
||||
} else {
|
||||
db().prepare("UPDATE push_anmeldungen SET fehler = fehler + 1 WHERE endpunkt = ?")
|
||||
.run(a.endpunkt);
|
||||
}
|
||||
}
|
||||
|
||||
if (raus && merkmal) {
|
||||
db().prepare("INSERT OR REPLACE INTO push_verschickt (person_id, merkmal, zeit) VALUES (?,?,?)")
|
||||
.run(personId, merkmal, jetzt());
|
||||
}
|
||||
return { verschickt: raus, grund: raus ? "ok" : "nicht_zugestellt" };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
WAS STEHT AN? — der Takt.
|
||||
|
||||
Fragt dieselben Daten ab, aus denen auch die Hinweisliste auf der
|
||||
Startseite entsteht. Bewusst KEINE eigenen Daten: Was erledigt wird,
|
||||
verschwindet von selbst, und es gibt nichts zu pflegen, das
|
||||
veralten kann.
|
||||
------------------------------------------------------------------ */
|
||||
export async function laufen() {
|
||||
const d = db();
|
||||
const heute = heuteLokal();
|
||||
const morgen = new Date(Date.parse(heute + "T00:00:00Z") + 86400_000).toISOString().slice(0, 10);
|
||||
let gesamt = 0;
|
||||
|
||||
const senden = async (personId, art, sache) => {
|
||||
const e = await benachrichtige(personId, art, sache);
|
||||
gesamt += e.verschickt;
|
||||
};
|
||||
|
||||
try {
|
||||
/* --- Aufgaben, die morgen faellig sind ---------------------------- */
|
||||
for (const a of d.prepare(`
|
||||
SELECT a.id, a.titel, a.verantwortlich_id FROM aufgaben a
|
||||
WHERE a.status <> 'erledigt' AND a.frist = ? AND a.verantwortlich_id IS NOT NULL`).all(morgen)) {
|
||||
await senden(a.verantwortlich_id, "aufgabe_faellig", {
|
||||
titel: "Morgen fällig",
|
||||
text: a.titel,
|
||||
ziel: "/workspace/aufgaben.html",
|
||||
merkmal: `aufgabe_faellig:${a.id}:${morgen}`,
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Aufgaben, die ueberfaellig sind ------------------------------ */
|
||||
for (const a of d.prepare(`
|
||||
SELECT a.id, a.titel, a.frist, a.verantwortlich_id FROM aufgaben a
|
||||
WHERE a.status <> 'erledigt' AND a.frist IS NOT NULL AND a.frist < ?
|
||||
AND a.verantwortlich_id IS NOT NULL`).all(heute)) {
|
||||
await senden(a.verantwortlich_id, "aufgabe_ueberfaellig", {
|
||||
titel: "Überfällig",
|
||||
text: a.titel,
|
||||
ziel: "/workspace/aufgaben.html",
|
||||
/* Das Datum gehoert ins Merkmal: Eine Aufgabe, die drei Wochen
|
||||
liegen bleibt, soll nicht dreimal am Tag erinnern -- aber
|
||||
einmal am Tag schon. */
|
||||
merkmal: `aufgabe_ueberfaellig:${a.id}:${heute}`,
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Termine in der naechsten Stunde ------------------------------ */
|
||||
const gleich = new Date(Date.now() + 60 * 60_000);
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
const bis = `${gleich.getFullYear()}-${p(gleich.getMonth() + 1)}-${p(gleich.getDate())}`
|
||||
+ `T${p(gleich.getHours())}:${p(gleich.getMinutes())}`;
|
||||
const jetztText = (() => {
|
||||
const n = new Date();
|
||||
return `${n.getFullYear()}-${p(n.getMonth() + 1)}-${p(n.getDate())}`
|
||||
+ `T${p(n.getHours())}:${p(n.getMinutes())}`;
|
||||
})();
|
||||
|
||||
for (const t of d.prepare(`
|
||||
SELECT t.id, t.titel, t.beginn, t.art FROM termine t
|
||||
WHERE t.erledigt = 0 AND t.beginn >= ? AND t.beginn <= ?`).all(jetztText, bis)) {
|
||||
/* An ALLE Teilnehmer, nicht nur an das Haupt-Gegenueber -- seit
|
||||
ein Termin mehrere haben kann (05.09.2026). */
|
||||
const leute = d.prepare(
|
||||
"SELECT person_id FROM termin_teilnehmer WHERE termin_id = ?").all(t.id).map((r) => r.person_id);
|
||||
for (const pid of new Set(leute)) {
|
||||
await senden(pid, "termin_gleich", {
|
||||
titel: t.art === "call" ? "Call in Kürze" : "Termin in Kürze",
|
||||
text: `${t.titel} · ${t.beginn.slice(11, 16)} Uhr`,
|
||||
ziel: t.art === "call" ? "/workspace/calls.html" : "/workspace/kalender.html",
|
||||
merkmal: `termin_gleich:${t.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Gespraeche ohne Protokoll ------------------------------------ */
|
||||
for (const t of d.prepare(`
|
||||
SELECT t.id, t.titel, t.erstellt_von FROM termine t
|
||||
LEFT JOIN protokolle pr ON pr.termin_id = t.id
|
||||
WHERE t.art IN ('call','review') AND t.beginn < ? AND pr.id IS NULL
|
||||
AND t.beginn > ? AND t.erstellt_von IS NOT NULL`)
|
||||
.all(jetztText, heute + "T00:00")) {
|
||||
await senden(t.erstellt_von, "protokoll_fehlt", {
|
||||
titel: "Protokoll fehlt",
|
||||
text: t.titel,
|
||||
ziel: "/workspace/calls.html",
|
||||
merkmal: `protokoll_fehlt:${t.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Faellige Follow-ups in der Pipeline --------------------------- */
|
||||
for (const l of d.prepare(`
|
||||
SELECT l.id, l.name, l.scout_id FROM leads l
|
||||
WHERE l.naechster_followup IS NOT NULL AND l.naechster_followup <= ?
|
||||
AND l.status NOT IN ('uebergeben','abgelehnt') AND l.scout_id IS NOT NULL`).all(heute)) {
|
||||
await senden(l.scout_id, "followup", {
|
||||
titel: "Nachfassen",
|
||||
text: l.name,
|
||||
ziel: "/workspace/scouting.html",
|
||||
merkmal: `followup:${l.id}:${heute}`,
|
||||
});
|
||||
}
|
||||
|
||||
/* Alte Merkmale aufraeumen -- nach 60 Tagen braucht sie niemand
|
||||
mehr, und die Tabelle soll nicht ewig wachsen. */
|
||||
const grenze = new Date(Date.now() - 60 * 86400_000).toISOString();
|
||||
d.prepare("DELETE FROM push_verschickt WHERE zeit < ?").run(grenze);
|
||||
} catch (fehler) {
|
||||
console.error("[push] Lauf:", fehler?.message);
|
||||
}
|
||||
return gesamt;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Der Takt. Wie bei der Sicherung: im Prozess, kein Systemdienst, kein
|
||||
Cron -- eine Sache weniger, die stillschweigend ausfallen kann.
|
||||
------------------------------------------------------------------ */
|
||||
let takt = null;
|
||||
export function pushStarten() {
|
||||
if (takt) return;
|
||||
/* Alle fuenf Minuten. Feiner braucht es nicht: Die feinste
|
||||
Erinnerung ist "Termin in einer Stunde". */
|
||||
takt = setInterval(() => { laufen().catch(() => {}); }, 5 * 60_000);
|
||||
takt.unref?.();
|
||||
setTimeout(() => { laufen().catch(() => {}); }, 20_000).unref?.();
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Die Schnittstellen
|
||||
===================================================================== */
|
||||
|
||||
function angemeldet(req, res, next) {
|
||||
const person = sitzungLesen(req);
|
||||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||||
req.person = person;
|
||||
next();
|
||||
}
|
||||
|
||||
function gleicheHerkunft(req, res, next) {
|
||||
const herkunft = req.get("origin");
|
||||
if (!herkunft) return next();
|
||||
let erlaubt;
|
||||
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
|
||||
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
|
||||
next();
|
||||
}
|
||||
|
||||
pushRouter.use("/workspace/api/push", angemeldet);
|
||||
|
||||
/** Was der Browser braucht, um sich anzumelden: der oeffentliche
|
||||
* Schluessel. Der private bleibt hier. */
|
||||
pushRouter.get("/workspace/api/push/schluessel", (req, res) => {
|
||||
try {
|
||||
res.json({ schluessel: paarHolen().oeffentlich });
|
||||
} catch (fehler) {
|
||||
console.error("[push] Schluessel:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Der eigene Stand: angemeldete Geraete und die Einstellungen. */
|
||||
pushRouter.get("/workspace/api/push/stand", (req, res) => {
|
||||
try {
|
||||
const geraete = db().prepare(`
|
||||
SELECT endpunkt, geraet, erstellt, zuletzt_ok FROM push_anmeldungen
|
||||
WHERE person_id = ? ORDER BY erstellt`).all(req.person.id);
|
||||
const arten = ARTEN.map((a) => ({
|
||||
...a, an: willHaben(req.person.id, a.schluessel),
|
||||
}));
|
||||
res.json({
|
||||
/* Nur die Anzahl und ein Kuerzel nach aussen -- der volle
|
||||
Endpunkt ist die Zustelladresse dieses Geraets und hat in der
|
||||
Oberflaeche nichts zu suchen. */
|
||||
geraete: geraete.map((g) => ({
|
||||
kennung: g.endpunkt.slice(-12),
|
||||
geraet: g.geraet || "Unbekanntes Gerät",
|
||||
erstellt: g.erstellt, zuletzt_ok: g.zuletzt_ok,
|
||||
})),
|
||||
arten,
|
||||
ruhe: { ab: RUHE_AB, bis: RUHE_BIS, jetzt: istRuhezeit() },
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[push] Stand:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Ein Geraet anmelden. */
|
||||
pushRouter.post("/workspace/api/push/anmelden", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||||
try {
|
||||
const { endpunkt, p256dh, auth, geraet } = req.body || {};
|
||||
if (typeof endpunkt !== "string" || !/^https:\/\//.test(endpunkt) || endpunkt.length > 1000) {
|
||||
return res.status(400).json({ fehler: "Die Adresse sieht nicht richtig aus." });
|
||||
}
|
||||
if (typeof p256dh !== "string" || typeof auth !== "string" || !p256dh || !auth) {
|
||||
return res.status(400).json({ fehler: "Es fehlen die Schlüssel des Browsers." });
|
||||
}
|
||||
|
||||
/* INSERT OR REPLACE auf dem Endpunkt: Meldet sich derselbe Browser
|
||||
erneut, wird der Eintrag ersetzt statt verdoppelt -- sonst kaeme
|
||||
jede Nachricht mehrfach an. */
|
||||
db().prepare(`
|
||||
INSERT OR REPLACE INTO push_anmeldungen
|
||||
(endpunkt, person_id, p256dh, auth, geraet, erstellt, zuletzt_ok, fehler)
|
||||
VALUES (?,?,?,?,?,?,NULL,0)`).run(
|
||||
endpunkt, req.person.id, p256dh, auth,
|
||||
String(geraet || "").slice(0, 80) || null, jetzt());
|
||||
|
||||
protokolliere("push_angemeldet", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: String(geraet || "").slice(0, 60),
|
||||
});
|
||||
res.status(201).json({ ok: true });
|
||||
} catch (fehler) {
|
||||
console.error("[push] Anmelden:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Ein Geraet abmelden. */
|
||||
pushRouter.post("/workspace/api/push/abmelden", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||||
try {
|
||||
const endpunkt = String(req.body?.endpunkt || "");
|
||||
/* NUR die eigenen -- sonst koennte man fremde Geraete stumm
|
||||
schalten, wenn man deren Adresse kennt. */
|
||||
const e = db().prepare("DELETE FROM push_anmeldungen WHERE endpunkt = ? AND person_id = ?")
|
||||
.run(endpunkt, req.person.id);
|
||||
protokolliere("push_abgemeldet", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
});
|
||||
res.json({ ok: true, entfernt: Number(e.changes) });
|
||||
} catch (fehler) {
|
||||
console.error("[push] Abmelden:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Eine Art an- oder abschalten. */
|
||||
pushRouter.put("/workspace/api/push/art", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||||
try {
|
||||
const art = String(req.body?.art || "");
|
||||
if (!ARTEN_SCHLUESSEL.has(art)) return res.status(400).json({ fehler: "Diese Art gibt es nicht." });
|
||||
const an = req.body?.an ? 1 : 0;
|
||||
db().prepare(`INSERT INTO push_einstellungen (person_id, art, an) VALUES (?,?,?)
|
||||
ON CONFLICT(person_id, art) DO UPDATE SET an = excluded.an`).run(req.person.id, art, an);
|
||||
res.json({ ok: true, art, an: !!an });
|
||||
} catch (fehler) {
|
||||
console.error("[push] Art:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Eine Probe an die eigenen Geraete. Beantwortet die einzige Frage,
|
||||
* die man wirklich hat: Kommt etwas an? */
|
||||
pushRouter.post("/workspace/api/push/probe", gleicheHerkunft, async (req, res) => {
|
||||
try {
|
||||
const e = await benachrichtige(req.person.id, "test", {
|
||||
titel: "Es funktioniert",
|
||||
text: "So sieht eine Benachrichtigung aus dem Workspace aus.",
|
||||
ziel: "/workspace/start.html",
|
||||
});
|
||||
res.json(e);
|
||||
} catch (fehler) {
|
||||
console.error("[push] Probe:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
+287
-1
@@ -225,6 +225,27 @@ function umstellungen(d) {
|
||||
["aufgaben", "verantwortlich_extern", "TEXT"],
|
||||
["eintraege", "creator_extern", "TEXT"],
|
||||
["dateien", "creator_extern", "TEXT"],
|
||||
|
||||
/* ABBRECHEN (05.09.2026). Wunsch: *"ich will dass man in jedem
|
||||
status die aufgaben auch abbrechen kann."*
|
||||
|
||||
Drei Spalten, weil "abgebrochen" allein zu wenig sagt:
|
||||
abbruch_grund WARUM. Pflichtfeld in der Oberflaeche -- ohne
|
||||
Grund weiss in vier Wochen niemand mehr, warum
|
||||
etwas wegfiel, und es sieht aus wie Loeschen.
|
||||
abgebrochen_am WANN.
|
||||
abbruch_von WER. Nur Leitung und Scouts duerfen es, und
|
||||
wer es war, gehoert dazu.
|
||||
status_vorher IN WELCHEM ZUSTAND. Diese Spalte ist der Grund,
|
||||
warum "abgebrochen" ein eigener Status wurde
|
||||
und nicht bloss ein Haken: Ohne sie ginge beim
|
||||
Wechsel verloren, ob die Arbeit schon lief.
|
||||
"Im Review abgebrochen" ist eine ganz andere
|
||||
Aussage als "nie angefangen". */
|
||||
["aufgaben", "abbruch_grund", "TEXT"],
|
||||
["aufgaben", "abgebrochen_am", "TEXT"],
|
||||
["aufgaben", "abbruch_von", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"],
|
||||
["aufgaben", "status_vorher", "TEXT"],
|
||||
]) {
|
||||
try {
|
||||
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
|
||||
@@ -321,6 +342,108 @@ function umstellungen(d) {
|
||||
console.error("[workspace] Content-Arten:", fehler?.message);
|
||||
}
|
||||
|
||||
/* ---- Status "abgebrochen" erlauben (05.09.2026) ----
|
||||
|
||||
Der CHECK-Constraint einer Tabelle laesst sich in SQLite nicht
|
||||
aendern -- kein ALTER TABLE der Welt hilft, die Tabelle muss neu
|
||||
gebaut werden. Deshalb derselbe Weg wie bei der Rolle "manager"
|
||||
darunter: erst sichern, dann tauschen, danach die Verweise pruefen.
|
||||
|
||||
WARUM UEBERHAUPT EIN NEUER STATUS und nicht bloss ein Haken
|
||||
"abgebrochen": Weil die vier Spalten des Bretts nach Status
|
||||
gruppieren. Eine abgebrochene Aufgabe faellt damit von selbst aus
|
||||
allen vieren heraus -- ohne dass irgendeine Abfrage angefasst
|
||||
werden muss. Ein zusaetzlicher Haken haette bedeutet, JEDE Abfrage
|
||||
um "AND nicht abgebrochen" zu ergaenzen, und die eine vergessene
|
||||
waere ein stiller Fehler gewesen: Die Aufgabe stuende weiter im
|
||||
Brett, und niemand wuesste warum. */
|
||||
const aufgabenPlan = d.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'aufgaben'").get()?.sql || "";
|
||||
if (aufgabenPlan && !aufgabenPlan.includes("'abgebrochen'")) {
|
||||
const sicherung = `${DB_PFAD}.vor-abbruch-${jetztStempel}`;
|
||||
try {
|
||||
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
|
||||
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
|
||||
return;
|
||||
}
|
||||
|
||||
d.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
/* Die Spalten stehen hier vollstaendig, weil die Schleife oben
|
||||
sie zu diesem Zeitpunkt schon ergaenzt hat. Wer hier eine
|
||||
vergisst, verliert ihren Inhalt still -- deshalb wird nach dem
|
||||
Tausch die Zeilenzahl verglichen. */
|
||||
const vorher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
|
||||
d.exec("BEGIN");
|
||||
d.exec(`
|
||||
CREATE TABLE aufgaben_neu (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
titel TEXT NOT NULL,
|
||||
beschreibung TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'offen'
|
||||
CHECK (status IN ('offen','arbeit','review','erledigt','abgebrochen')),
|
||||
prioritaet TEXT NOT NULL DEFAULT 'mittel'
|
||||
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
|
||||
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
frist TEXT,
|
||||
erstellt TEXT NOT NULL,
|
||||
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
geaendert TEXT,
|
||||
erledigt_am TEXT,
|
||||
creator_extern TEXT,
|
||||
verantwortlich_extern TEXT,
|
||||
abbruch_grund TEXT,
|
||||
abgebrochen_am TEXT,
|
||||
abbruch_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
status_vorher TEXT
|
||||
);
|
||||
INSERT INTO aufgaben_neu
|
||||
(id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id,
|
||||
frist, erstellt, erstellt_von, geaendert, erledigt_am,
|
||||
creator_extern, verantwortlich_extern,
|
||||
abbruch_grund, abgebrochen_am, abbruch_von, status_vorher)
|
||||
SELECT id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id,
|
||||
frist, erstellt, erstellt_von, geaendert, erledigt_am,
|
||||
creator_extern, verantwortlich_extern,
|
||||
abbruch_grund, abgebrochen_am, abbruch_von, status_vorher
|
||||
FROM aufgaben;
|
||||
DROP TABLE aufgaben;
|
||||
ALTER TABLE aufgaben_neu RENAME TO aufgaben;
|
||||
CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id);
|
||||
`);
|
||||
/* Nach dem RENAME heisst die neue Tabelle wieder "aufgaben" --
|
||||
gezaehlt wird also unter dem alten Namen, und der Vergleich
|
||||
laeuft noch INNERHALB der Transaktion. Stimmt er nicht, ist
|
||||
ein ROLLBACK noch moeglich. */
|
||||
const nachher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
|
||||
/* Stimmt die Zahl nicht, wird NICHT bestaetigt. Lieber laeuft das
|
||||
Abbrechen noch nicht, als dass eine Aufgabe verschwindet. */
|
||||
if (nachher !== vorher) {
|
||||
d.exec("ROLLBACK");
|
||||
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Aufgaben vorher, `
|
||||
+ `${nachher} nachher. Sicherung: ${sicherung}`);
|
||||
} else {
|
||||
d.exec("COMMIT");
|
||||
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
|
||||
if (kaputt.length) {
|
||||
console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length,
|
||||
"verwaiste Verweise. Sicherung liegt unter", sicherung);
|
||||
} else {
|
||||
console.log(`[workspace] Status 'abgebrochen' freigeschaltet, ${vorher} Aufgaben, Verweise geprueft.`);
|
||||
}
|
||||
}
|
||||
} catch (fehler) {
|
||||
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
|
||||
console.error("[workspace] Umstellung 'abgebrochen' fehlgeschlagen:", fehler?.message);
|
||||
} finally {
|
||||
d.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Rolle "manager" erlauben ---- */
|
||||
const bauplan = d.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || "";
|
||||
@@ -435,7 +558,7 @@ export function db() {
|
||||
titel TEXT NOT NULL,
|
||||
beschreibung TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'offen'
|
||||
CHECK (status IN ('offen','arbeit','review','erledigt')),
|
||||
CHECK (status IN ('offen','arbeit','review','erledigt','abgebrochen')),
|
||||
prioritaet TEXT NOT NULL DEFAULT 'mittel'
|
||||
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
|
||||
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
@@ -461,6 +584,71 @@ export function db() {
|
||||
von INTEGER REFERENCES personen(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
/* BENACHRICHTIGUNGEN (05.09.2026) ---------------------------------
|
||||
|
||||
Wunsch Filipe, mit dem Bild eines "Benachrichtigungen aus"-
|
||||
Knopfes: *"ich will auch sowas und dass es perfekt funktioniert
|
||||
auf der seite fuer jeden."*
|
||||
|
||||
Eine Anmeldung ist die Adresse, unter der ein bestimmter
|
||||
BROWSER auf einem bestimmten GERAET erreichbar ist -- nicht die
|
||||
Person. Wer den Workspace auf dem Rechner und auf dem Handy
|
||||
benutzt, hat zwei; beide sollen klingeln, und beide muessen
|
||||
einzeln abschaltbar sein.
|
||||
|
||||
Der Endpunkt ist der Schluessel, nicht eine eigene Nummer: Meldet
|
||||
sich derselbe Browser erneut an (nach dem Leeren der
|
||||
Website-Daten etwa), soll daraus KEIN zweiter Eintrag werden,
|
||||
sonst kaeme jede Nachricht doppelt.
|
||||
|
||||
p256dh und auth sind die Schluessel des Browsers. Ohne sie
|
||||
laesst sich nichts verschluesseln -- und ohne Verschluesselung
|
||||
nimmt kein Push-Dienst etwas an. */
|
||||
CREATE TABLE IF NOT EXISTS push_anmeldungen (
|
||||
endpunkt TEXT PRIMARY KEY,
|
||||
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
geraet TEXT,
|
||||
erstellt TEXT NOT NULL,
|
||||
zuletzt_ok TEXT,
|
||||
fehler INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_person ON push_anmeldungen (person_id);
|
||||
|
||||
/* Was jemand bekommen WILL -- je Art einzeln.
|
||||
|
||||
Der Plan ist an dieser Stelle deutlich: *"je Person abschaltbar
|
||||
-- pro Art, nicht alles oder nichts. Eine Benachrichtigung, die
|
||||
nervt, wird abgeschaltet und dann fehlt auch die wichtige."*
|
||||
|
||||
Fehlt eine Zeile, gilt die Voreinstellung aus workspace-push.js
|
||||
(an). Damit muss niemand erst etwas einstellen, um etwas zu
|
||||
bekommen -- und wer etwas abstellt, bekommt genau das nicht
|
||||
mehr. */
|
||||
CREATE TABLE IF NOT EXISTS push_einstellungen (
|
||||
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
||||
art TEXT NOT NULL,
|
||||
an INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (person_id, art)
|
||||
);
|
||||
|
||||
/* Was schon verschickt wurde. Ohne dieses Gedaechtnis bekaeme
|
||||
jemand dieselbe Erinnerung bei jedem Lauf erneut -- viermal am
|
||||
Tag "Aufgabe ist ueberfaellig" ist der schnellste Weg, dass
|
||||
jemand Benachrichtigungen komplett abschaltet.
|
||||
|
||||
Das Merkmal ist die Sache selbst (z. B. "aufgabe-faellig:42"),
|
||||
nicht der Zeitpunkt: Dieselbe Aufgabe erinnert einmal, nicht
|
||||
einmal je Stunde. */
|
||||
CREATE TABLE IF NOT EXISTS push_verschickt (
|
||||
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
||||
merkmal TEXT NOT NULL,
|
||||
zeit TEXT NOT NULL,
|
||||
PRIMARY KEY (person_id, merkmal)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_verschickt_zeit ON push_verschickt (zeit);
|
||||
|
||||
/* Rueckmeldungen zu einer Aufgabe (Konzept: "Aufgaben & Feedback").
|
||||
Ohne sie endet jede Rueckfrage ausserhalb des Systems -- in
|
||||
WhatsApp, und damit ausserhalb dessen, was spaeter noch
|
||||
@@ -1073,9 +1261,37 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
|
||||
.prepare("SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen WHERE rolle = ? AND aktiv = 1")
|
||||
.all(rolle);
|
||||
|
||||
/* EIN KAPUTTER DATENSATZ DARF NICHT ALLE AUSSPERREN (05.09.2026).
|
||||
|
||||
Hier stand die Schleife ohne Absicherung. Wirft `hashe()` bei
|
||||
EINER Person -- etwa weil ihr code_n keine Zweierpotenz ist und
|
||||
scrypt "Invalid scrypt params" meldet --, dann flog die ganze
|
||||
Anmeldung in den catch am Ende: 503 "nicht_verfuegbar", für
|
||||
JEDEN mit dieser Rolle, auch für die, deren Daten in Ordnung
|
||||
sind.
|
||||
|
||||
Aufgefallen ist es beim Bau der Abbruchpruefung, wo ich zum
|
||||
Testen versehentlich eine Person mit code_n = 1 angelegt hatte.
|
||||
Ab da kam kein einziger DogFather mehr herein -- und die Meldung
|
||||
sagte "nicht verfügbar", nicht "ein Datensatz ist defekt".
|
||||
Danach hätte man lange gesucht.
|
||||
|
||||
Ein Datenfehler bei einer Person ist jetzt ein Problem DIESER
|
||||
Person: Sie wird übersprungen, der Rest der Anmeldung läuft
|
||||
normal weiter. Und sie wird laut protokolliert, denn sie kann
|
||||
sich selbst nicht mehr anmelden -- das muss auffallen. */
|
||||
let gefunden = null;
|
||||
for (const k of kandidaten) {
|
||||
try {
|
||||
if (gleich(hashe(code, k.code_salt, k.code_n), k.code_hash)) { gefunden = k; break; }
|
||||
} catch (f) {
|
||||
console.error(`[workspace] Zugangsdaten von Person #${k.id} (${k.rolle}) sind defekt `
|
||||
+ `-- sie kann sich nicht anmelden. Grund: ${f?.message}`);
|
||||
protokolliere("zugangsdaten_defekt", {
|
||||
personId: k.id, rolle: k.rolle, ip,
|
||||
detail: String(f?.message || "").slice(0, 80),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!gefunden) {
|
||||
@@ -1362,6 +1578,76 @@ export function sichtbarePersonenIds(person) {
|
||||
return [...new Set([person.id, ...creator, ...scouts])];
|
||||
}
|
||||
|
||||
/** WER BETREUT MICH -- die Blickrichtung nach oben.
|
||||
*
|
||||
* betreuteIds() fragt "wen führe ich?". Diese Funktion fragt das
|
||||
* Gegenteil: "wer führt mich?" -- also der eigene Scout, dessen
|
||||
* Manager, und bei einem Scout sein Manager.
|
||||
*
|
||||
* Es gab die Richtung bisher nicht, weil sie für Sichtbarkeit nie
|
||||
* gebraucht wurde: Ein Creator soll die Daten seines Scouts nicht
|
||||
* sehen. Für eine EINLADUNG ist die Frage aber eine andere -- siehe
|
||||
* einladbareIds(). */
|
||||
export function betreuerIds(person) {
|
||||
if (!person) return [];
|
||||
try {
|
||||
const d = db();
|
||||
if (person.rolle === "creator") {
|
||||
const betreuer = d.prepare("SELECT betreuer_id FROM betreuung WHERE creator_id = ?")
|
||||
.all(person.id).map((z) => z.betreuer_id);
|
||||
if (!betreuer.length) return [];
|
||||
/* Über den Scout auch dessen Manager: Wer mit seinem Scout einen
|
||||
Call hat, hat ihn oft mit dessen Manager zusammen. */
|
||||
const manager = d.prepare(
|
||||
`SELECT manager_id FROM scout_zuteilung
|
||||
WHERE scout_id IN (${betreuer.map(() => "?").join(",")})`)
|
||||
.all(...betreuer).map((z) => z.manager_id);
|
||||
return [...new Set([...betreuer, ...manager])];
|
||||
}
|
||||
if (person.rolle === "scout") {
|
||||
return d.prepare("SELECT manager_id FROM scout_zuteilung WHERE scout_id = ?")
|
||||
.all(person.id).map((z) => z.manager_id);
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return []; // im Zweifel niemand -- nie mehr, als sicher ist
|
||||
}
|
||||
}
|
||||
|
||||
/** WEN DARF ICH ZU EINEM TERMIN DAZUSTELLEN?
|
||||
*
|
||||
* Eingeführt am 05.09.2026 auf den Wunsch, die Teilnehmerwahl solle
|
||||
* *"für jeden verfügbar sein"*.
|
||||
*
|
||||
* WARUM NICHT EINFACH sichtbarePersonenIds: Die Frage ist eine
|
||||
* andere. Sichtbarkeit heißt "wessen Daten darf ich sehen" und zeigt
|
||||
* bewusst nach unten -- ein Creator sieht dort nur sich selbst.
|
||||
* Einladen heißt "mit wem arbeite ich zusammen" und geht in BEIDE
|
||||
* Richtungen: Ein Creator lädt seinen Scout ein, ein Scout seinen
|
||||
* Manager.
|
||||
*
|
||||
* Und warum nicht die eine Funktion erweitern: sichtbarePersonenIds
|
||||
* hängt auch an der AUFGABENZUWEISUNG. Wer dort einen Betreuer
|
||||
* hinzufügt, gibt einem Creator nebenbei die Möglichkeit, seinem
|
||||
* Scout Aufgaben zu erteilen. Zwei Fragen, zwei Funktionen.
|
||||
*
|
||||
* Was ein Einladen bewirkt, ist eng: Die Person sieht diesen einen
|
||||
* Termin in ihrem Kalender. Keine Daten, keine Rechte, kein Zugriff.
|
||||
* Deshalb ist die weitere Menge hier vertretbar.
|
||||
*
|
||||
* null = alle (nur DogFather), wie überall in dieser Datei. */
|
||||
export function einladbareIds(person) {
|
||||
if (!person) return [];
|
||||
if (istDogFather(person)) return null;
|
||||
const sichtbar = sichtbarePersonenIds(person) || [];
|
||||
/* DogFather gehört immer dazu: Er ist der Einzige, mit dem jede
|
||||
Rolle zu tun hat, und ihn nicht einladen zu können wäre der erste
|
||||
Fall, der auffällt. */
|
||||
const chefs = db().prepare("SELECT id FROM personen WHERE rolle = 'admin' AND aktiv = 1")
|
||||
.all().map((z) => z.id);
|
||||
return [...new Set([...sichtbar, ...betreuerIds(person), ...chefs])];
|
||||
}
|
||||
|
||||
/** SQL-Baustein daraus: "diese Spalte ist eine Person, die ich sehen
|
||||
* darf". Gibt null zurück, wenn nicht eingeschränkt werden muss. */
|
||||
export function personenWo(person, spalte) {
|
||||
|
||||
@@ -511,3 +511,148 @@
|
||||
.call-raum { align-items: flex-start; }
|
||||
.call-raum button { margin-left: 0; }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
ABBRECHEN — Knopf, Bereich und Dialog
|
||||
=====================================================================
|
||||
|
||||
Wunsch Filipe, 05.09.2026: *"ich will dass man in jedem status die
|
||||
aufgaben auch abbrechen kann."*
|
||||
|
||||
GESTALTUNGSREGEL HIER: zurückhaltend. Abbrechen ist die seltene
|
||||
Handlung neben "starten" und "erledigt" -- sie darf auf der Karte
|
||||
nicht lauter sein als die beiden, die man täglich braucht. Kein
|
||||
Signalrot, kein Ausrufezeichen: ein gedeckter Ton, der sich vom
|
||||
Weiterschalten unterscheidet, ohne die Karte an sich zu reißen.
|
||||
(Augenschonend ist hier ohnehin Hausregel.) */
|
||||
|
||||
/* Ein warmer, gedämpfter Ton statt Alarmrot. --f ist die Farbvariable,
|
||||
die .schritt in gate.css für Rand, Schrift und Schimmer benutzt. */
|
||||
.schritt--ab { --f: #c98a7d; }
|
||||
.schritt--ab:hover { color: color-mix(in srgb, #c98a7d 40%, #ffffff); }
|
||||
|
||||
/* ---------- Der Bereich unter dem Brett ---------------------------------- */
|
||||
|
||||
.abgebrochen {
|
||||
margin: 22px 0 0;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, .022);
|
||||
}
|
||||
|
||||
.abgebrochen__kopf {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 13px 17px;
|
||||
cursor: pointer;
|
||||
font-size: .82rem; font-weight: 700;
|
||||
letter-spacing: .06em; text-transform: uppercase;
|
||||
color: var(--text-still);
|
||||
/* Das eigene Dreieck von <summary> weg -- es wird unten selbst
|
||||
gezeichnet, damit es links steht und sich mitdreht. */
|
||||
list-style: none;
|
||||
}
|
||||
.abgebrochen__kopf::-webkit-details-marker { display: none; }
|
||||
.abgebrochen__kopf:hover { color: var(--text-leise); }
|
||||
.abgebrochen__kopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: -2px; border-radius: 14px; }
|
||||
|
||||
/* Das Dreieck. Dreht sich beim Aufklappen -- die Bewegung sagt, dass
|
||||
der Inhalt darunter erscheint und nicht woanders. */
|
||||
.abgebrochen__kopf::before {
|
||||
content: ""; flex: none;
|
||||
width: 0; height: 0;
|
||||
border-left: 5px solid currentColor;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
transition: transform .18s ease;
|
||||
}
|
||||
.abgebrochen[open] .abgebrochen__kopf::before { transform: rotate(90deg); }
|
||||
|
||||
.abgebrochen__name { flex: 1; }
|
||||
.abgebrochen__zahl {
|
||||
padding: 2px 9px; border-radius: 999px;
|
||||
font-size: .78rem; font-weight: 700; letter-spacing: 0;
|
||||
color: var(--text-leise);
|
||||
background: rgba(255, 255, 255, .06);
|
||||
border: 1px solid var(--rand);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.abgebrochen__unter {
|
||||
margin: 0; padding: 0 17px 4px;
|
||||
font-size: .8rem; color: var(--text-still); max-width: 72ch;
|
||||
}
|
||||
|
||||
.abgebrochen__liste {
|
||||
display: grid; gap: 10px;
|
||||
/* Mehrspaltig, sobald Platz ist: Abgebrochenes liest man am Stück,
|
||||
nicht einzeln -- eine lange Spalte zwänge zum Scrollen durch etwas,
|
||||
das man ohnehin nur überfliegt. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
padding: 10px 17px 17px;
|
||||
}
|
||||
|
||||
.abbruch-karte {
|
||||
padding: 13px 15px;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, .028);
|
||||
}
|
||||
.abbruch-karte__kopf {
|
||||
display: flex; align-items: baseline; gap: 9px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
/* Durchgestrichen sagt "gilt nicht mehr" schneller als jedes Wort --
|
||||
und bleibt lesbar, anders als bloßes Ausgrauen. */
|
||||
.abbruch-karte__titel {
|
||||
margin: 0; flex: 1;
|
||||
font-size: .92rem; font-weight: 600;
|
||||
color: var(--text-leise);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: color-mix(in srgb, var(--text-still) 70%, transparent);
|
||||
}
|
||||
/* Wo es stand, als es abgebrochen wurde. */
|
||||
.abbruch-karte__vorher {
|
||||
flex: none;
|
||||
padding: 2px 8px; border-radius: 999px;
|
||||
font-size: .68rem; font-weight: 700;
|
||||
letter-spacing: .04em; text-transform: uppercase;
|
||||
color: var(--text-still);
|
||||
background: rgba(255, 255, 255, .05);
|
||||
border: 1px solid var(--rand);
|
||||
}
|
||||
.abbruch-karte__grund {
|
||||
margin: 0 0 7px;
|
||||
font-size: .84rem; color: var(--text-leise);
|
||||
/* Der Grund ist der eigentliche Inhalt dieser Karte -- er steht in
|
||||
normaler Textfarbe, während der Titel durchgestrichen zurücktritt. */
|
||||
}
|
||||
.abbruch-karte__wer {
|
||||
margin: 0 0 9px;
|
||||
font-size: .74rem; color: var(--text-still);
|
||||
}
|
||||
.abbruch-karte .schritt { font-size: .8rem; padding: 7px 13px; }
|
||||
|
||||
/* ---------- Der Dialog --------------------------------------------------- */
|
||||
|
||||
.dialog__unter {
|
||||
margin: -4px 0 14px;
|
||||
font-size: .85rem; color: var(--text-still);
|
||||
max-width: 60ch; line-height: 1.5;
|
||||
}
|
||||
|
||||
#abbruch-grund {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.abgebrochen__kopf::before { transition: none; }
|
||||
}
|
||||
|
||||
/* Ohne Farbverläufe trägt die Kante allein. Der Durchstrich bleibt --
|
||||
er ist hier das eigentliche Merkmal und keine Deko. */
|
||||
@media (forced-colors: active) {
|
||||
.abgebrochen, .abbruch-karte { border: 1px solid CanvasText; }
|
||||
.abgebrochen__zahl, .abbruch-karte__vorher { border: 1px solid CanvasText; }
|
||||
}
|
||||
|
||||
@@ -672,3 +672,133 @@
|
||||
bleibt das Unterscheidungsmerkmal einer pausierten Serie. */
|
||||
.k-serie[data-aktiv="nein"] { opacity: 1; border-style: dashed; }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
WER IST NOCH DABEI — die Teilnehmerwahl
|
||||
=====================================================================
|
||||
|
||||
Vorgeschichte (05.09.2026, Filipe mit Bildschirmfoto): *"das soll
|
||||
viel besser aussehen bitte und dass soll für jeden verfügbar sein."*
|
||||
|
||||
Auf dem Bild klebten die Namen aneinander -- "DogfatherDogFather",
|
||||
"CigdemManager", "BananaStiftScout". Zwei Fehler auf einmal:
|
||||
|
||||
1. DAS CSS WAR GAR NICHT DA. Die Wahl benutzte `.wahl__knopf` aus
|
||||
dateien.css -- und kalender.html bindet dateien.css nicht ein.
|
||||
Ohne `display:inline-flex` und `gap` stehen zwei <span> eben
|
||||
direkt nebeneinander. Vierzig grüne Prüfungen zur Teilnehmerwahl
|
||||
hatten das nicht gemerkt: Keine davon hat je gemessen, ob es auch
|
||||
AUSSIEHT wie gedacht. Deshalb gibt es jetzt eigene Klassen im
|
||||
eigenen Stylesheet -- eine Seite, die ihr CSS selbst mitbringt,
|
||||
kann es nicht verlieren.
|
||||
|
||||
2. DIE ROLLE STAND AN JEDEM NAMEN. Auch mit Abstand bliebe es eine
|
||||
lange graue Wurst, in der man niemanden findet. Jetzt tragen die
|
||||
GRUPPEN die Rolle ("Manager", "Scouts"), die Namen darunter nur
|
||||
noch den Namen. Das Wort steht einmal statt neunmal, und man
|
||||
sucht nicht mehr, sondern springt zur richtigen Gruppe.
|
||||
|
||||
AUGENSCHONEND (Projektregel): gedeckte Rollentöne, kein Neon, keine
|
||||
harten Weißflächen. Ausgewählt wird über Rand, Häkchen UND Schrift
|
||||
erkennbar -- nicht über Farbe allein, sonst ist es für
|
||||
Farbfehlsichtige nicht zu unterscheiden. */
|
||||
|
||||
.k-dabei { margin-top: 6px; }
|
||||
|
||||
.k-dabei__gruppe + .k-dabei__gruppe { margin-top: 12px; }
|
||||
|
||||
.k-dabei__titel {
|
||||
margin: 0 0 6px;
|
||||
font-size: .68rem; font-weight: 700;
|
||||
letter-spacing: .08em; text-transform: uppercase;
|
||||
color: var(--text-still);
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
}
|
||||
/* Der Punkt trägt die Rollenfarbe -- ein ruhiger Anker fürs Auge,
|
||||
ohne die ganze Überschrift einzufärben. */
|
||||
.k-dabei__punkt {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--r, var(--text-still)); flex: none;
|
||||
}
|
||||
|
||||
.k-dabei__liste { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
|
||||
.k-dabei__knopf {
|
||||
--r: rgba(255, 255, 255, .5);
|
||||
display: inline-flex; align-items: center; gap: 9px;
|
||||
/* 40px Mindesthöhe: am Handy mit dem Daumen sicher zu treffen. */
|
||||
min-height: 40px;
|
||||
padding: 5px 14px 5px 6px;
|
||||
cursor: pointer; font: inherit;
|
||||
color: var(--text-leise);
|
||||
background: rgba(255, 255, 255, .035);
|
||||
border: 1px solid var(--rand); border-radius: 999px;
|
||||
transition: color .16s ease, border-color .16s ease, background .16s ease;
|
||||
}
|
||||
.k-dabei__knopf:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, .24);
|
||||
background: rgba(255, 255, 255, .06);
|
||||
}
|
||||
.k-dabei__knopf:focus-visible {
|
||||
outline: 2px solid var(--r); outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Die Initiale. Ersetzt ein Bild, das es hier nicht gibt, und macht
|
||||
aus einer Textzeile ein Gesicht, das man wiedererkennt. */
|
||||
.k-dabei__zeichen {
|
||||
flex: none;
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
display: grid; place-items: center;
|
||||
font-size: .76rem; font-weight: 700; line-height: 1;
|
||||
color: var(--r);
|
||||
background: color-mix(in srgb, var(--r) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--r) 34%, transparent);
|
||||
}
|
||||
.k-dabei__name { font-size: .86rem; font-weight: 550; }
|
||||
|
||||
/* Das Häkchen steht NEBEN der Farbe, nicht statt ihr. Wer Farben
|
||||
schlecht unterscheidet, sieht trotzdem sofort, wer dabei ist. */
|
||||
.k-dabei__haken {
|
||||
flex: none; width: 14px; height: 14px;
|
||||
opacity: 0; transform: scale(.7);
|
||||
transition: opacity .16s ease, transform .16s ease;
|
||||
}
|
||||
.k-dabei__haken path { stroke: var(--r); stroke-width: 2.4; fill: none;
|
||||
stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.k-dabei__knopf[data-an="ja"] {
|
||||
color: var(--text); font-weight: 600;
|
||||
border-color: color-mix(in srgb, var(--r) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--r) 13%, transparent);
|
||||
}
|
||||
.k-dabei__knopf[data-an="ja"] .k-dabei__zeichen {
|
||||
color: #12151b;
|
||||
background: var(--r);
|
||||
border-color: var(--r);
|
||||
}
|
||||
.k-dabei__knopf[data-an="ja"] .k-dabei__haken { opacity: 1; transform: none; }
|
||||
|
||||
/* Rollentöne. Dieselben wie in personen.css -- eine Farbe pro Rolle im
|
||||
ganzen Workspace, sonst muss man sie auf jeder Seite neu lernen. */
|
||||
.k-dabei__gruppe[data-rolle="admin"],
|
||||
.k-dabei__knopf[data-rolle="admin"] { --r: #e8c07d; }
|
||||
.k-dabei__gruppe[data-rolle="manager"],
|
||||
.k-dabei__knopf[data-rolle="manager"] { --r: #8a76ff; }
|
||||
.k-dabei__gruppe[data-rolle="scout"],
|
||||
.k-dabei__knopf[data-rolle="scout"] { --r: #5fc99a; }
|
||||
.k-dabei__gruppe[data-rolle="creator"],
|
||||
.k-dabei__knopf[data-rolle="creator"] { --r: #3fbdf5; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.k-dabei__knopf, .k-dabei__haken { transition: none; }
|
||||
}
|
||||
|
||||
/* Ohne Farbverläufe trägt die Kante allein -- und das Häkchen, das
|
||||
hier zum eigentlichen Merkmal wird. */
|
||||
@media (forced-colors: active) {
|
||||
.k-dabei__knopf { border: 1px solid CanvasText; }
|
||||
.k-dabei__knopf[data-an="ja"] { border-width: 2px; }
|
||||
.k-dabei__zeichen { border: 1px solid CanvasText; }
|
||||
.k-dabei__haken path { stroke: CanvasText; }
|
||||
}
|
||||
|
||||
@@ -2785,6 +2785,7 @@ body.start #protokoll {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sicht__schild {
|
||||
flex: none;
|
||||
/* 0,75 rem = 12 px. Nicht kleiner: Unter 11,5 px meldet die
|
||||
@@ -2879,6 +2880,47 @@ body[data-fremde-sicht="ja"]::after {
|
||||
.sicht { padding: 3px 4px; }
|
||||
.wer__name { display: none; }
|
||||
}
|
||||
|
||||
/* SEHR SCHMAL: die Kopfleiste darf umbrechen (05.09.2026).
|
||||
|
||||
Die Rechnung darueber ging genau auf, solange rechts vier Dinge
|
||||
standen. Mit der Glocke sind es fuenf, und bei 320 px (iPhone SE)
|
||||
passte es nicht mehr: Die Lupe des Suchknopfes lag danach mitten auf
|
||||
dem Sicht-Umschalter -- ein Knopf, der auf zwoelf Seiten ins Leere
|
||||
tippt. Gemeldet von pruef-handy, bevor es jemand gesehen hat.
|
||||
|
||||
WARUM UMBRECHEN und nicht weiter schrumpfen: Jedes Stueck hier hat
|
||||
seine Mindestgroesse aus einem guten Grund -- 104 px, damit der
|
||||
Umschalter mit dem Daumen zu treffen ist, und die Glocke ist bereits
|
||||
auf das blosse Zeichen zusammengefallen. Wer hier weiter kuerzt,
|
||||
tauscht ein sichtbares Problem gegen ein unsichtbares: Knoepfe, die
|
||||
zwar nebeneinander liegen, aber zu klein zum Treffen sind.
|
||||
|
||||
Eine zweite Zeile auf einem 320-Pixel-Schirm kostet 44 Pixel Hoehe.
|
||||
Das ist der guenstigste Preis, der hier zu haben ist -- und er faellt
|
||||
nur auf Geraeten an, die wirklich so schmal sind. */
|
||||
@media (max-width: 380px) {
|
||||
.kopfleiste { flex-wrap: wrap; row-gap: 8px; }
|
||||
/* Die Marke nimmt die erste Zeile ganz ein, die Bedienelemente
|
||||
ruecken geschlossen in die zweite. Ohne das brechen sie mitten
|
||||
zwischen Suche und Glocke um. */
|
||||
.kopfleiste > .marke { flex: 1 0 100%; }
|
||||
.kopfleiste__rechts {
|
||||
width: 100%; justify-content: flex-end;
|
||||
/* AUCH DIE ZWEITE ZEILE DARF UMBRECHEN. Ohne das reichten die
|
||||
294 Pixel weiterhin nicht: Umschalter (104) + Suche (47) +
|
||||
Glocke (37) + Plakette + Abmelden sind zusammen mehr. Die
|
||||
Elemente wurden dann zwar nicht mehr ueberdeckt, aber der
|
||||
Umschalter ragte aus seinem eigenen Kasten heraus -- ein
|
||||
stilles Ueberlaufen, das keine Pruefung meldet und trotzdem
|
||||
falsch aussieht. */
|
||||
flex-wrap: wrap; row-gap: 6px;
|
||||
}
|
||||
/* Und der Umschalter behaelt seine Breite, statt gequetscht zu
|
||||
werden: Sein Inhalt kann ohnehin nicht unter 104 px, also waere
|
||||
jedes Schrumpfen nur ein Ueberlauf mit anderem Namen. */
|
||||
.sicht { flex: 0 0 auto; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
body[data-fremde-sicht="ja"]::after { border-color: Highlight; }
|
||||
}
|
||||
@@ -3012,3 +3054,100 @@ body[data-fremde-sicht="ja"]::after {
|
||||
@media (forced-colors: active) {
|
||||
.kachel::after { display: none; }
|
||||
}
|
||||
|
||||
/* ---------- Der Benachrichtigungs-Knopf ------------------------------
|
||||
Wunsch Filipe (05.09.2026): ein Knopf wie "Benachrichtigungen aus",
|
||||
auf jeder Seite erreichbar.
|
||||
|
||||
Gebaut wie `.abmelden` daneben, damit die Kopfleiste eine Sprache
|
||||
spricht -- und mit denselben Umbruchregeln: Auf schmalen Schirmen
|
||||
bleibt nur die Glocke stehen, der Text weicht. Genau daran ist die
|
||||
Kopfleiste am 31.08.2026 schon einmal gescheitert (214 px aus dem
|
||||
Bild); deshalb hier von vornherein `min-width: 0` und ein Text, der
|
||||
verschwinden darf. */
|
||||
.glocke {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
min-width: 0; flex: none;
|
||||
padding: 7px 13px;
|
||||
background: rgba(255, 255, 255, .04);
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 999px;
|
||||
color: var(--text-leise);
|
||||
font: inherit; font-size: .82rem;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--tempo), background var(--tempo), color var(--tempo);
|
||||
}
|
||||
.glocke:hover:not(:disabled) { background: rgba(255, 255, 255, .07); border-color: var(--rand-hell); }
|
||||
.glocke:disabled { opacity: .55; cursor: not-allowed; }
|
||||
|
||||
.glocke__zeichen { width: 17px; height: 17px; flex: none; }
|
||||
.glocke__zeichen path {
|
||||
fill: none; stroke: currentColor; stroke-width: 1.7;
|
||||
stroke-linecap: round; stroke-linejoin: round;
|
||||
}
|
||||
.glocke__strich { opacity: 0; }
|
||||
|
||||
/* AN: die Glocke bekommt Farbe, der Strich bleibt weg. */
|
||||
.glocke[data-lage="an"] {
|
||||
color: var(--akzent);
|
||||
border-color: color-mix(in srgb, var(--akzent) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--akzent) 10%, transparent);
|
||||
}
|
||||
|
||||
/* AUS: durchgestrichene Glocke -- man sieht auf einen Blick, dass
|
||||
nichts kommt, ohne den Text zu lesen. */
|
||||
.glocke[data-lage="aus"] .glocke__strich { opacity: .85; }
|
||||
|
||||
/* Die drei Faelle, in denen der Knopf nichts schalten kann, sind
|
||||
gedaempft und tragen ebenfalls den Strich: Sie sind naeher an "aus"
|
||||
als an "an", und genau so soll es aussehen. */
|
||||
.glocke[data-lage="iphone"], .glocke[data-lage="verboten"],
|
||||
.glocke[data-lage="geht-nicht"] {
|
||||
color: var(--text-still);
|
||||
}
|
||||
.glocke[data-lage="iphone"] .glocke__strich,
|
||||
.glocke[data-lage="verboten"] .glocke__strich,
|
||||
.glocke[data-lage="geht-nicht"] .glocke__strich { opacity: .7; }
|
||||
|
||||
/* Schmale Schirme: nur die Glocke. Der Text steht weiterhin im
|
||||
aria-label, Vorleseprogramme verlieren also nichts.
|
||||
|
||||
900 PIXEL, NICHT 760 (korrigiert 05.09.2026). Die erste Fassung
|
||||
nahm 760 -- und ein Tablet hochkant hat 768. Dort stand der Text
|
||||
also noch, die Glocke war rund 200 statt 37 Pixel breit, und der
|
||||
Abmelden-Knopf wurde um bis zu 15 Pixel aus dem Bild geschoben. Auf
|
||||
vier Seiten, gemeldet von pruef-breiten.
|
||||
|
||||
Die Grenze liegt jetzt auf derselben Stufe wie die uebrigen Regeln
|
||||
fuer die enge Kopfleiste (der Sicht-Umschalter wird ab 900 px
|
||||
ebenfalls schmaler). Eine eigene Schwelle daneben waere genau die
|
||||
Art Zahl, die beim naechsten Mal wieder knapp danebenliegt. */
|
||||
@media (max-width: 900px) {
|
||||
.glocke { padding: 7px 9px; }
|
||||
.glocke__text { display: none; }
|
||||
}
|
||||
|
||||
/* ERKLÄRSATZ UNTER EINER ÜBERSCHRIFT — der Grundwert.
|
||||
|
||||
Gefunden am 05.09.2026 von pruef-css-klassen.mjs, beim allerersten
|
||||
Lauf. `.block__frage` war viermal fast gleich definiert -- in
|
||||
automation.css, calls.css, report.css und wissen.css -- und wurde
|
||||
auf content.html benutzt, das keine dieser vier Dateien lädt. Der
|
||||
Absatz stand dort seit jeher unformatiert.
|
||||
|
||||
Genau dasselbe Muster wie bei der Teilnehmerwahl am selben Tag:
|
||||
Das CSS existiert, es sieht im Editor richtig aus, und auf der Seite
|
||||
kommt nichts an. Solange es nur in Seiten-Stylesheets steht, findet
|
||||
es niemand.
|
||||
|
||||
Hier steht deshalb der Grundwert, den jede Seite bekommt. Die vier
|
||||
Seiten-Stylesheets werden NACH start.css geladen und behalten damit
|
||||
ihre Feinheiten (12, 13 oder 14 Pixel Abstand) -- sie bleiben
|
||||
unverändert. Neu ist nur, dass eine Seite ohne eigene Fassung nicht
|
||||
mehr leer ausgeht. */
|
||||
.block__frage {
|
||||
margin: 0 0 13px;
|
||||
font-size: .85rem;
|
||||
color: var(--text-still);
|
||||
max-width: 76ch;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,17 @@
|
||||
const WEITER = { offen: 'arbeit', arbeit: 'review', review: 'erledigt' };
|
||||
const ZURUECK = { arbeit: 'offen', review: 'arbeit', erledigt: 'review' };
|
||||
|
||||
/* Wer darf abbrechen (05.09.2026): *"nur ich die manager und scouts
|
||||
sollen auch die aufgaben abbrechen können."* Ein Creator nicht.
|
||||
|
||||
Dieselbe Menge steht im Server (DARF_ABBRECHEN in
|
||||
workspace-aufgaben.js) und DORT sitzt die Sperre. Hier geht es nur
|
||||
darum, niemandem einen Knopf hinzustellen, der dann 403 sagt. */
|
||||
const DARF_ABBRECHEN = new Set(['admin', 'manager', 'scout']);
|
||||
const STATUS_NAME = {
|
||||
offen: 'Offen', arbeit: 'In Arbeit', review: 'Review', erledigt: 'Erledigt',
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
/* ORTSZEIT, nicht UTC -- sonst gilt nachts eine heute faellige Aufgabe
|
||||
noch nicht als faellig, und "Heute faellig" zeigt den Vortag. */
|
||||
@@ -181,6 +192,20 @@
|
||||
const schritte = el('div', 'karte__schritte');
|
||||
if (ZURUECK[a.status]) schritte.append(knopf('◀ zurück', a, ZURUECK[a.status]));
|
||||
if (WEITER[a.status]) schritte.append(knopf(beschriftung(a.status), a, WEITER[a.status]));
|
||||
/* ABBRECHEN — in JEDEM Status (05.09.2026), auch im erledigten:
|
||||
Auch etwas Abgehaktes kann sich als hinfällig herausstellen.
|
||||
|
||||
Nur DogFather, Manager und Scouts. Ein Creator sieht den Knopf
|
||||
gar nicht erst -- die eigentliche Sperre sitzt aber im Server,
|
||||
hier geht es nur darum, niemandem etwas anzubieten, das dann
|
||||
abgelehnt wird. */
|
||||
if (DARF_ABBRECHEN.has(ich.rolle)) {
|
||||
const ab = el('button', 'schritt schritt--ab', 'abbrechen');
|
||||
ab.type = 'button';
|
||||
ab.setAttribute('aria-label', `„${a.titel}" abbrechen`);
|
||||
ab.addEventListener('click', () => abbruchOeffnen(a));
|
||||
schritte.append(ab);
|
||||
}
|
||||
if (schritte.childElementCount) k.append(schritte);
|
||||
|
||||
/* Rückmeldungen ("Aufgaben & Feedback" aus dem Konzept). Der Knopf
|
||||
@@ -202,6 +227,127 @@
|
||||
return k;
|
||||
}
|
||||
|
||||
/* ---------- Abbrechen ----------------------------------------------------
|
||||
|
||||
Wunsch 05.09.2026: *"ich will dass man in jedem status die aufgaben
|
||||
auch abbrechen kann."*
|
||||
|
||||
MIT PFLICHT-GRUND. Ohne ihn weiss in vier Wochen niemand mehr,
|
||||
warum etwas wegfiel -- und dann ist ein Abbruch von einem Loeschen
|
||||
nicht mehr zu unterscheiden. Der Grund steht spaeter an der
|
||||
Aufgabe, zusammen mit dem Namen dessen, der abgebrochen hat.
|
||||
|
||||
Ein <dialog> und kein confirm(): confirm blockiert die ganze Seite,
|
||||
nimmt kein Textfeld auf und sieht auf jedem Rechner anders aus. */
|
||||
|
||||
function abbruchOeffnen(a) {
|
||||
const dlg = $('abbruch-dialog');
|
||||
if (!dlg) return;
|
||||
dlg.dataset.id = String(a.id);
|
||||
$('abbruch-titel').textContent = `„${a.titel}" abbrechen?`;
|
||||
/* Sagt, was gleich passiert -- und was NICHT passiert. Die Sorge
|
||||
bei so einem Knopf ist immer, ob etwas verloren geht. */
|
||||
$('abbruch-was').textContent = `Die Aufgabe steht danach unter „Abgebrochen" `
|
||||
+ `statt in der Spalte ${STATUS_NAME[a.status] || a.status}. `
|
||||
+ `Sie wird nicht gelöscht und lässt sich jederzeit wieder aufnehmen.`;
|
||||
$('abbruch-grund').value = '';
|
||||
$('abbruch-fehler').textContent = '';
|
||||
dlg.showModal();
|
||||
$('abbruch-grund').focus();
|
||||
}
|
||||
|
||||
async function abbruchSenden(e) {
|
||||
e.preventDefault();
|
||||
const dlg = $('abbruch-dialog');
|
||||
const id = Number(dlg.dataset.id);
|
||||
const grund = $('abbruch-grund').value.trim();
|
||||
if (grund.length < 3) {
|
||||
$('abbruch-fehler').textContent = 'Bitte kurz sagen, warum.';
|
||||
$('abbruch-grund').focus();
|
||||
return;
|
||||
}
|
||||
const knopf = $('abbruch-ok');
|
||||
knopf.disabled = true;
|
||||
try {
|
||||
const antwort = await hole(`/workspace/api/aufgaben/${id}/abbrechen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ grund }),
|
||||
});
|
||||
if (!antwort.ok) {
|
||||
const d = await antwort.json().catch(() => ({}));
|
||||
$('abbruch-fehler').textContent = d.fehler || 'Hat nicht geklappt.';
|
||||
return;
|
||||
}
|
||||
dlg.close();
|
||||
await laden();
|
||||
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
|
||||
}
|
||||
|
||||
async function wiederAufnehmen(a, b) {
|
||||
b.disabled = true;
|
||||
melde('');
|
||||
try {
|
||||
const antwort = await hole(`/workspace/api/aufgaben/${a.id}/wiederaufnehmen`,
|
||||
{ method: 'POST' });
|
||||
if (!antwort.ok) {
|
||||
const d = await antwort.json().catch(() => ({}));
|
||||
melde(d.fehler || 'Hat nicht geklappt.');
|
||||
b.disabled = false;
|
||||
return;
|
||||
}
|
||||
await laden();
|
||||
} catch { b.disabled = false; }
|
||||
}
|
||||
|
||||
/** Der Bereich UNTER dem Brett -- zugeklappt, mit Anzahl im Titel.
|
||||
*
|
||||
* Bewusst keine fuenfte Spalte: Am Handy werden dadurch alle fuenf
|
||||
* so schmal, dass keine mehr lesbar ist. Und abgebrochene Aufgaben
|
||||
* sind nichts, was man taeglich sieht -- aber sie duerfen auch nicht
|
||||
* verschwinden, sonst waere es Loeschen mit Zwischenschritt. */
|
||||
function abgebrocheneZeigen(alle) {
|
||||
const kasten = $('abgebrochen');
|
||||
if (!kasten) return;
|
||||
const eigene = alle.filter((a) => a.status === 'abgebrochen');
|
||||
|
||||
if (!eigene.length) { kasten.hidden = true; return; }
|
||||
kasten.hidden = false;
|
||||
|
||||
$('abgebrochen-zahl').textContent = String(eigene.length);
|
||||
const liste = $('abgebrochen-liste');
|
||||
liste.textContent = '';
|
||||
|
||||
for (const a of eigene) {
|
||||
const k = el('article', 'abbruch-karte');
|
||||
const kopf = el('div', 'abbruch-karte__kopf');
|
||||
kopf.append(el('h3', 'abbruch-karte__titel', a.titel));
|
||||
/* Wo es stand, als es abgebrochen wurde. "Im Review abgebrochen"
|
||||
ist eine andere Aussage als "nie angefangen". */
|
||||
if (a.status_vorher) {
|
||||
kopf.append(el('span', 'abbruch-karte__vorher',
|
||||
STATUS_NAME[a.status_vorher] || a.status_vorher));
|
||||
}
|
||||
k.append(kopf);
|
||||
|
||||
if (a.abbruch_grund) k.append(el('p', 'abbruch-karte__grund', a.abbruch_grund));
|
||||
|
||||
const wer = [];
|
||||
if (a.abbruch_von_name) wer.push(a.abbruch_von_name);
|
||||
if (a.abgebrochen_am) wer.push(datumHuebsch(a.abgebrochen_am.slice(0, 10)));
|
||||
if (wer.length) k.append(el('p', 'abbruch-karte__wer', wer.join(' · ')));
|
||||
|
||||
if (DARF_ABBRECHEN.has(ich.rolle)) {
|
||||
const zurueck = el('button', 'schritt', 'wieder aufnehmen ↩');
|
||||
zurueck.type = 'button';
|
||||
zurueck.setAttribute('aria-label', `„${a.titel}" wieder aufnehmen`);
|
||||
zurueck.addEventListener('click', () => wiederAufnehmen(a, zurueck));
|
||||
k.append(zurueck);
|
||||
}
|
||||
liste.append(k);
|
||||
}
|
||||
}
|
||||
|
||||
const beschriftung = (status) => ({
|
||||
offen: 'starten ▶', arbeit: 'zum Review ▶', review: 'erledigt ▶',
|
||||
})[status];
|
||||
@@ -262,7 +408,11 @@
|
||||
let nurCreator = 0; /* 0 = alle */
|
||||
let alleAufgaben = [];
|
||||
|
||||
const istOffen = (a) => a.status !== 'erledigt';
|
||||
/* Abgebrochenes zählt NICHT als offen (05.09.2026). Ohne diese
|
||||
Ergänzung stünde eine abgebrochene Aufgabe weiter in "3 Aufgaben
|
||||
stehen an" und in "Überfällig" -- und würde als etwas gemahnt, das
|
||||
ausdrücklich niemand mehr tun soll. */
|
||||
const istOffen = (a) => a.status !== 'erledigt' && a.status !== 'abgebrochen';
|
||||
const istFaellig = (a) => istOffen(a) && a.frist && a.frist < heute;
|
||||
const istHeute = (a) => istOffen(a) && a.frist === heute;
|
||||
const istMeine = (a) => a.verantwortlich_id === ich.id;
|
||||
@@ -395,6 +545,13 @@
|
||||
ausschnitteBauen(alleAufgaben);
|
||||
creatorReiheBauen(alleAufgaben);
|
||||
zeichne(alleAufgaben.filter(passt));
|
||||
/* Der Bereich folgt dem Creator-Filter mit -- wer sich eine Person
|
||||
herausgreift, will auch nur deren Abgebrochenes sehen. Die
|
||||
Ausschnitte (heute/überfällig) gelten dagegen nicht: Sie fragen
|
||||
nach Fristen, und eine abgebrochene Aufgabe hat keine mehr. */
|
||||
abgebrocheneZeigen(nurCreator
|
||||
? alleAufgaben.filter((a) => a.creator_id === nurCreator)
|
||||
: alleAufgaben);
|
||||
}
|
||||
|
||||
function zeichne(aufgaben) {
|
||||
@@ -543,6 +700,12 @@
|
||||
|
||||
$('b-abbrechen').addEventListener('click', () => dialog.close());
|
||||
|
||||
/* Der Abbruch-Dialog. Am submit des Formulars und nicht am Knopf:
|
||||
So löst auch Enter im Textfeld aus, und Esc schließt von selbst
|
||||
(beides bringt <dialog> mit, wenn man es lässt). */
|
||||
$('abbruch-form').addEventListener('submit', abbruchSenden);
|
||||
$('abbruch-zurueck').addEventListener('click', () => $('abbruch-dialog').close());
|
||||
|
||||
$('b-speichern').addEventListener('click', async () => {
|
||||
const daten = {
|
||||
titel: $('b-titel').value,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/* =====================================================================
|
||||
Der Benachrichtigungs-Knopf — auf JEDER Seite, in der Kopfleiste.
|
||||
|
||||
Wunsch Filipe (05.09.2026), mit dem Bild eines "Benachrichtigungen
|
||||
aus"-Knopfes: *"ich will auch sowas und dass es perfekt funktioniert
|
||||
auf der seite fuer jeden."*
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WARUM IN DER KOPFLEISTE UND NICHT AUF EINER EINSTELLUNGSSEITE
|
||||
|
||||
Dasselbe Argument wie bei der Suche, die hier schon so gebaut ist:
|
||||
Was man nur auf einer Extraseite findet, benutzt niemand. Der Knopf
|
||||
sagt jederzeit, woran man ist -- Glocke an oder durchgestrichen.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
DER KNOPF SAGT IMMER DIE WAHRHEIT, auch wenn sie unbequem ist
|
||||
|
||||
Es gibt mehr Zustaende als "an" und "aus", und jeder braucht einen
|
||||
anderen Satz. Ein Knopf, der bei einem abgelehnten Recht einfach
|
||||
nichts tut, ist der sichere Weg zu "das funktioniert nicht":
|
||||
|
||||
geht nicht Der Browser kann kein Push (aeltere Fassungen).
|
||||
iPhone Safari kann es NUR nach "Zum Home-Bildschirm".
|
||||
Im Reiter gibt es die Berechtigung ueberhaupt nicht.
|
||||
verboten Schon einmal abgelehnt. Das laesst sich per
|
||||
JavaScript NICHT zuruecknehmen -- man muss es in den
|
||||
Browsereinstellungen tun. Also wird genau das gesagt.
|
||||
aus / an der Normalfall.
|
||||
===================================================================== */
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
/* Nur im Workspace, und nicht auf der Anmeldeseite -- dort ist
|
||||
niemand angemeldet, den man benachrichtigen koennte. */
|
||||
if (!location.pathname.startsWith('/workspace/')) return;
|
||||
if (/\/workspace\/(index\.html)?$/.test(location.pathname)) return;
|
||||
|
||||
const el = (tag, klasse, text) => {
|
||||
const k = document.createElement(tag);
|
||||
if (klasse) k.className = klasse;
|
||||
if (text !== undefined) k.textContent = text;
|
||||
return k;
|
||||
};
|
||||
|
||||
const kannPush = 'serviceWorker' in navigator && 'PushManager' in window
|
||||
&& 'Notification' in window;
|
||||
|
||||
/* Laeuft die Seite als installierte App? Auf dem iPhone ist das die
|
||||
Bedingung dafuer, dass es ueberhaupt geht. */
|
||||
const alsApp = window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.navigator.standalone === true;
|
||||
const istApple = /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
let anmeldung = null;
|
||||
let knopf = null;
|
||||
|
||||
const b64uZuBytes = (s) => {
|
||||
const roh = atob(String(s).replace(/-/g, '+').replace(/_/g, '/')
|
||||
+ '='.repeat((4 - String(s).length % 4) % 4));
|
||||
return Uint8Array.from(roh, (c) => c.charCodeAt(0));
|
||||
};
|
||||
|
||||
const bytesZuB64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
/** Ein Name, an dem man sein Geraet in der Liste wiedererkennt. */
|
||||
function geraetName() {
|
||||
const u = navigator.userAgent;
|
||||
const os = /Windows/.test(u) ? 'Windows'
|
||||
: /Android/.test(u) ? 'Android'
|
||||
: istApple ? (/iPad/.test(u) ? 'iPad' : 'iPhone')
|
||||
: /Mac/.test(u) ? 'Mac' : /Linux/.test(u) ? 'Linux' : 'Gerät';
|
||||
const browser = /Edg\//.test(u) ? 'Edge'
|
||||
: /Chrome\//.test(u) ? 'Chrome'
|
||||
: /Firefox\//.test(u) ? 'Firefox'
|
||||
: /Safari\//.test(u) ? 'Safari' : 'Browser';
|
||||
return `${os} · ${browser}${alsApp ? ' (App)' : ''}`;
|
||||
}
|
||||
|
||||
/** Was ist gerade der Fall? */
|
||||
function lage() {
|
||||
if (!kannPush) return 'geht-nicht';
|
||||
if (istApple && !alsApp) return 'iphone';
|
||||
if (Notification.permission === 'denied') return 'verboten';
|
||||
return anmeldung ? 'an' : 'aus';
|
||||
}
|
||||
|
||||
const TEXTE = {
|
||||
'geht-nicht': { text: 'Hier nicht möglich', titel: 'Dieser Browser kann keine Benachrichtigungen.' },
|
||||
iphone: {
|
||||
text: 'Erst zum Home-Bildschirm',
|
||||
titel: 'Auf dem iPhone gehen Benachrichtigungen nur, wenn die Seite '
|
||||
+ 'zum Home-Bildschirm hinzugefügt wurde: Teilen-Knopf → „Zum Home-Bildschirm“. '
|
||||
+ 'Danach hier noch einmal antippen.',
|
||||
},
|
||||
verboten: {
|
||||
text: 'Vom Browser blockiert',
|
||||
titel: 'Du hast Benachrichtigungen für diese Seite einmal abgelehnt. Das lässt sich '
|
||||
+ 'nur in den Browsereinstellungen wieder erlauben — bei Chrome über das Symbol '
|
||||
+ 'links in der Adresszeile.',
|
||||
},
|
||||
aus: { text: 'Benachrichtigungen aus', titel: 'Antippen, um Erinnerungen zu bekommen.' },
|
||||
an: { text: 'Benachrichtigungen an', titel: 'Antippen, um sie auf diesem Gerät wieder abzuschalten.' },
|
||||
};
|
||||
|
||||
function zeichnen() {
|
||||
if (!knopf) return;
|
||||
const l = lage();
|
||||
const t = TEXTE[l];
|
||||
knopf.dataset.lage = l;
|
||||
knopf.title = t.titel;
|
||||
knopf.setAttribute('aria-label', t.titel);
|
||||
knopf.querySelector('.glocke__text').textContent = t.text;
|
||||
/* aria-pressed sagt einem Vorleseprogramm, dass es ein Schalter
|
||||
ist und wie er gerade steht. */
|
||||
knopf.setAttribute('aria-pressed', l === 'an' ? 'true' : 'false');
|
||||
knopf.disabled = l === 'geht-nicht';
|
||||
}
|
||||
|
||||
async function anmelden() {
|
||||
const recht = await Notification.requestPermission();
|
||||
if (recht !== 'granted') { zeichnen(); return; }
|
||||
|
||||
const reg = await navigator.serviceWorker.register('/workspace/sw.js', { scope: '/workspace/' });
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
const a = await fetch('/workspace/api/push/schluessel', { credentials: 'same-origin' });
|
||||
const { schluessel } = await a.json();
|
||||
|
||||
const abo = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: b64uZuBytes(schluessel),
|
||||
});
|
||||
|
||||
const roh = abo.toJSON();
|
||||
await fetch('/workspace/api/push/anmelden', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpunkt: abo.endpoint,
|
||||
p256dh: roh.keys?.p256dh || bytesZuB64u(abo.getKey('p256dh')),
|
||||
auth: roh.keys?.auth || bytesZuB64u(abo.getKey('auth')),
|
||||
geraet: geraetName(),
|
||||
}),
|
||||
});
|
||||
|
||||
anmeldung = abo;
|
||||
zeichnen();
|
||||
|
||||
/* Sofort eine Probe. Das ist der Unterschied zwischen "sollte
|
||||
gehen" und "geht": Wer den Knopf drueckt, will WISSEN, dass es
|
||||
ankommt -- und nicht bis morgen warten. */
|
||||
await fetch('/workspace/api/push/probe', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function abmelden() {
|
||||
try {
|
||||
if (anmeldung) {
|
||||
await fetch('/workspace/api/push/abmelden', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpunkt: anmeldung.endpoint }),
|
||||
});
|
||||
await anmeldung.unsubscribe();
|
||||
}
|
||||
} catch { /* der Servereintrag ist weg, das genuegt */ }
|
||||
anmeldung = null;
|
||||
zeichnen();
|
||||
}
|
||||
|
||||
async function bauen() {
|
||||
const kopf = document.querySelector('.kopfleiste__rechts');
|
||||
if (!kopf || document.getElementById('glocke')) return;
|
||||
|
||||
knopf = el('button', 'glocke');
|
||||
knopf.type = 'button';
|
||||
knopf.id = 'glocke';
|
||||
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', 'glocke__zeichen');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
svg.innerHTML = '<path class="glocke__koerper" d="M12 4a5 5 0 0 0-5 5v3.5L5.5 15h13L17 12.5V9a5 5 0 0 0-5-5z"/>'
|
||||
/* "kloeppel" ohne Umlaut: Ein ö in einem Klassennamen ist erlaubt,
|
||||
aber es überlebt nicht jede Datei, die zwischendurch in einer
|
||||
anderen Kodierung gespeichert wird -- und im ganzen Haus wird
|
||||
ohnehin "oe"/"ue" geschrieben. */
|
||||
+ '<path class="glocke__kloeppel" d="M10 17.5a2 2 0 0 0 4 0"/>'
|
||||
+ '<path class="glocke__strich" d="M4 4l16 16"/>';
|
||||
knopf.append(svg);
|
||||
knopf.append(el('span', 'glocke__text', '…'));
|
||||
|
||||
/* VOR dem Abmelden-Knopf, nicht dahinter: Abmelden ist die letzte
|
||||
Handlung auf einer Seite und bleibt ganz rechts. */
|
||||
const abmeldenKnopf = kopf.querySelector('.abmelden');
|
||||
if (abmeldenKnopf) kopf.insertBefore(knopf, abmeldenKnopf);
|
||||
else kopf.append(knopf);
|
||||
|
||||
knopf.addEventListener('click', async () => {
|
||||
const l = lage();
|
||||
if (l === 'geht-nicht') return;
|
||||
/* Bei 'iphone' und 'verboten' nichts tun ausser erklaeren -- der
|
||||
Titel steht schon dran, und ein Klick, der nichts bewirkt und
|
||||
nichts sagt, wirkt kaputt. */
|
||||
if (l === 'iphone' || l === 'verboten') { window.alert(TEXTE[l].titel); return; }
|
||||
knopf.disabled = true;
|
||||
try {
|
||||
if (l === 'an') await abmelden(); else await anmelden();
|
||||
} catch (f) {
|
||||
console.warn('[glocke]', f);
|
||||
zeichnen();
|
||||
}
|
||||
knopf.disabled = false;
|
||||
});
|
||||
|
||||
/* Steht hier schon eine Anmeldung? */
|
||||
if (kannPush && Notification.permission === 'granted') {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration('/workspace/');
|
||||
anmeldung = reg ? await reg.pushManager.getSubscription() : null;
|
||||
} catch { anmeldung = null; }
|
||||
}
|
||||
zeichnen();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bauen);
|
||||
} else bauen();
|
||||
})();
|
||||
+135
-18
@@ -83,9 +83,37 @@
|
||||
let alleLeute = [];
|
||||
|
||||
const ROLLENKURZ = { admin: 'DogFather', manager: 'Manager', scout: 'Scout', creator: 'Creator' };
|
||||
/* Mehrzahl für die Gruppenüberschriften. "Scout" über vier Namen
|
||||
liest sich falsch -- über der Gruppe steht, was die Leute SIND. */
|
||||
const ROLLENGRUPPE = {
|
||||
admin: 'DogFather', manager: 'Manager', scout: 'Scouts', creator: 'Creator',
|
||||
};
|
||||
const ROLLENFOLGE = ['admin', 'manager', 'scout', 'creator'];
|
||||
|
||||
/** Baut die Schalterreihe. `gewaehlt` sind die Nummern, die an
|
||||
* sein sollen -- beim Bearbeiten die des Termins, sonst nichts. */
|
||||
/** Ein bis zwei Buchstaben, die als Zeichen im Kreis stehen.
|
||||
* "Filipe Fernandes Ribeiro" -> "FR", "Luna" -> "L".
|
||||
* Absichtlich Anfang UND Ende: Bei "Patrick von Hohenstein" wäre
|
||||
* "PV" nichtssagend, "PH" nicht. */
|
||||
function initialen(name) {
|
||||
const teile = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (!teile.length) return '?';
|
||||
/* [...] statt [0]: Ein Name kann mit einem Zeichen beginnen, das
|
||||
aus zwei Code-Einheiten besteht (Emoji, manche Schriften).
|
||||
name[0] würde davon die Hälfte nehmen und Bruch anzeigen. */
|
||||
const erst = [...teile[0]][0] || '';
|
||||
const letzt = teile.length > 1 ? ([...teile[teile.length - 1]][0] || '') : '';
|
||||
return (erst + letzt).toLocaleUpperCase('de-DE');
|
||||
}
|
||||
|
||||
/** Baut die Wahl -- nach Rollen gruppiert.
|
||||
*
|
||||
* `gewaehlt` sind die Nummern, die an sein sollen: beim Bearbeiten
|
||||
* die des Termins, sonst nichts.
|
||||
*
|
||||
* WARUM GRUPPIERT: Vorher stand die Rolle an JEDEM Namen, neunmal
|
||||
* hintereinander, als eine lange graue Reihe. Jetzt trägt die
|
||||
* Überschrift das Wort einmal, und man springt zur Gruppe statt zu
|
||||
* suchen. */
|
||||
function baueDabei(gewaehlt = []) {
|
||||
const ziel = $('dabei-wahl');
|
||||
if (!ziel) return;
|
||||
@@ -95,29 +123,76 @@
|
||||
$('dabei-hinweis').textContent = '';
|
||||
return;
|
||||
}
|
||||
for (const p of alleLeute) {
|
||||
|
||||
for (const rolle of ROLLENFOLGE) {
|
||||
const leute = alleLeute.filter((p) => p.rolle === rolle);
|
||||
if (!leute.length) continue; // leere Gruppe gar nicht erst zeigen
|
||||
|
||||
const gruppe = document.createElement('div');
|
||||
gruppe.className = 'k-dabei__gruppe';
|
||||
gruppe.dataset.rolle = rolle;
|
||||
|
||||
const titel = document.createElement('p');
|
||||
titel.className = 'k-dabei__titel';
|
||||
const punkt = document.createElement('span');
|
||||
punkt.className = 'k-dabei__punkt';
|
||||
punkt.setAttribute('aria-hidden', 'true');
|
||||
titel.append(punkt, document.createTextNode(ROLLENGRUPPE[rolle] || rolle));
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'k-dabei__liste';
|
||||
/* Die Gruppe ist eine Gruppe von Schaltern -- ein Vorleseprogramm
|
||||
soll die Überschrift dazu ansagen, sonst hört man neun Namen
|
||||
ohne zu wissen, wovon. */
|
||||
liste.setAttribute('role', 'group');
|
||||
liste.setAttribute('aria-label', ROLLENGRUPPE[rolle] || rolle);
|
||||
|
||||
for (const p of leute) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'wahl__knopf';
|
||||
b.className = 'k-dabei__knopf';
|
||||
b.dataset.id = String(p.id);
|
||||
b.dataset.rolle = p.rolle;
|
||||
if (an.has(p.id)) b.dataset.an = 'ja';
|
||||
const n = document.createElement('span'); n.className = 'wahl__name'; n.textContent = p.name;
|
||||
const r = document.createElement('span'); r.className = 'wahl__rolle';
|
||||
r.textContent = ROLLENKURZ[p.rolle] || p.rolle;
|
||||
b.append(n, r);
|
||||
|
||||
const z = document.createElement('span');
|
||||
z.className = 'k-dabei__zeichen';
|
||||
z.setAttribute('aria-hidden', 'true'); // der Name steht direkt daneben
|
||||
z.textContent = initialen(p.name);
|
||||
|
||||
const n = document.createElement('span');
|
||||
n.className = 'k-dabei__name';
|
||||
n.textContent = p.name;
|
||||
|
||||
const haken = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
haken.setAttribute('class', 'k-dabei__haken');
|
||||
haken.setAttribute('viewBox', '0 0 16 16');
|
||||
haken.setAttribute('aria-hidden', 'true');
|
||||
haken.innerHTML = '<path d="M3 8.5l3.2 3.2L13 5"/>';
|
||||
|
||||
b.append(z, n, haken);
|
||||
/* aria-pressed sagt einem Vorleseprogramm, dass das ein
|
||||
Schalter ist und wie er steht -- ohne das hört man nur
|
||||
"Knopf, Cigdem" und weiß nie, ob sie dabei ist. */
|
||||
b.setAttribute('aria-pressed', an.has(p.id) ? 'true' : 'false');
|
||||
b.addEventListener('click', () => {
|
||||
if (b.dataset.an === 'ja') delete b.dataset.an; else b.dataset.an = 'ja';
|
||||
const jetztAn = b.dataset.an !== 'ja';
|
||||
if (jetztAn) b.dataset.an = 'ja'; else delete b.dataset.an;
|
||||
b.setAttribute('aria-pressed', jetztAn ? 'true' : 'false');
|
||||
dabeiHinweis();
|
||||
});
|
||||
ziel.append(b);
|
||||
liste.append(b);
|
||||
}
|
||||
|
||||
gruppe.append(titel, liste);
|
||||
ziel.append(gruppe);
|
||||
}
|
||||
dabeiHinweis();
|
||||
}
|
||||
|
||||
/** Die Nummern, die gerade angehakt sind. */
|
||||
const dabeiGewaehlt = () =>
|
||||
[...$('dabei-wahl').querySelectorAll('.wahl__knopf[data-an="ja"]')].map((b) => Number(b.dataset.id));
|
||||
[...$('dabei-wahl').querySelectorAll('.k-dabei__knopf[data-an="ja"]')].map((b) => Number(b.dataset.id));
|
||||
|
||||
function dabeiHinweis() {
|
||||
const n = dabeiGewaehlt().length;
|
||||
@@ -1178,10 +1253,16 @@
|
||||
/* Ein frei eingetragener Name statt einer Person. Der Server
|
||||
raeumt dann creator_id ab -- siehe workspace-kalender.js. */
|
||||
koerper.teilnehmer_extern = $('f-teilnehmer').dataset.extern || null;
|
||||
/* Wer sonst noch dabei ist. Das Haupt-Gegenueber muss hier NICHT
|
||||
mitgeschickt werden -- der Server legt es ohnehin dazu. */
|
||||
koerper.teilnehmer = dabeiGewaehlt();
|
||||
}
|
||||
/* Wer sonst noch dabei ist -- fuer JEDE Rolle, nicht nur die
|
||||
Leitung. Stand vorher im Block darueber und wurde damit bei
|
||||
Scouts und Creators nie mitgeschickt.
|
||||
|
||||
Das Haupt-Gegenueber gehoert hier NICHT hinein: Der Server legt
|
||||
es ohnehin dazu (teilnehmerSetzen mit hauptId), und zweimal
|
||||
dieselbe Nummer zu schicken hiesse, sich auf die Reihenfolge zu
|
||||
verlassen. */
|
||||
if (!$('feld-dabei').hidden) koerper.teilnehmer = dabeiGewaehlt();
|
||||
/* Mit Rhythmus entsteht keine einzelne Zeile, sondern eine Regel --
|
||||
der Server legt daraus die Termine an, den ersten eingeschlossen.
|
||||
Deshalb wird hier nichts zusätzlich abgeschickt: Sonst stünde der
|
||||
@@ -1226,9 +1307,31 @@
|
||||
} catch { return; }
|
||||
window.werZeigen?.(ich);
|
||||
|
||||
if (LEITUNG.has(ich.rolle)) {
|
||||
/* WER DARF WEN MARKIEREN — und wer entscheidet das.
|
||||
|
||||
"Mit wem" (die Haupt-Zuordnung) bleibt der Leitung vorbehalten:
|
||||
Das verschiebt einen Termin in einen fremden Kalender als dessen
|
||||
Eigentümer, und das ist eine Zuordnung, keine Einladung.
|
||||
|
||||
"Wer ist noch dabei?" bekommt dagegen JEDE Rolle (05.09.2026:
|
||||
*"das soll für jeden verfügbar sein"*). Sicher ist das, weil
|
||||
nicht diese Zeile über die Auswahl entscheidet, sondern der
|
||||
Server an zwei Stellen:
|
||||
* /api/personen liefert über sichtbarePersonenIds nur die
|
||||
zugeteilten Namen -- ein Creator sieht seine Betreuer, keine
|
||||
fremden Creator.
|
||||
* teilnehmerSetzen() prüft jede Nummer noch einmal gegen
|
||||
darfEintragen() und lässt Unerlaubtes still weg. Wer die
|
||||
Anfrage von Hand baut, kommt damit keinen Schritt weiter.
|
||||
Die Oberfläche zu verstecken war also nie die Sicherung -- sie
|
||||
hat nur denen etwas weggenommen, die es brauchen. */
|
||||
const darfZuordnen = LEITUNG.has(ich.rolle);
|
||||
|
||||
/* "Mit wem" -- die Haupt-Zuordnung. Nur Leitung: Sie macht die
|
||||
andere Person zum Gegenüber des Termins, das ist eine Zuordnung
|
||||
und keine Einladung. Liste: /api/personen (zeigt nach unten). */
|
||||
if (darfZuordnen) {
|
||||
$('feld-teilnehmer').hidden = false;
|
||||
$('feld-dabei').hidden = false;
|
||||
try {
|
||||
const { personen } = await (await hole('/workspace/api/personen')).json();
|
||||
for (const p of personen) {
|
||||
@@ -1237,12 +1340,26 @@
|
||||
o.textContent = p.name + ' (' + p.rolle + ')';
|
||||
$('f-teilnehmer').append(o);
|
||||
}
|
||||
alleLeute = personen;
|
||||
baueDabei();
|
||||
} catch { /* Zuordnung bleibt leer */ }
|
||||
window.personenwahl?.('f-teilnehmer', { hinweis: "Kein Konto im Workspace – der Name steht nur an diesem Eintrag." });
|
||||
}
|
||||
|
||||
/* "Wer ist noch dabei?" -- für jede Rolle. Eigene Liste über
|
||||
/api/einladbar, die auch nach oben zeigt: Ein Creator findet
|
||||
dort seinen Scout, ein Scout seinen Manager. */
|
||||
try {
|
||||
const { personen } = await (await hole('/workspace/api/einladbar')).json();
|
||||
/* Sich selbst braucht niemand zu markieren -- der eigene Termin
|
||||
steht ohnehin im eigenen Kalender. Ein Knopf, der nichts
|
||||
bewirkt, verwirrt nur. */
|
||||
alleLeute = (personen || []).filter((p) => p.id !== ich.id);
|
||||
/* Bleibt niemand übrig (jemand ganz ohne Zuordnung), hat das
|
||||
Feld keinen Zweck -- ein leerer Kasten mit Überschrift sieht
|
||||
aus wie ein Fehler. Sonst: zeigen. */
|
||||
$('feld-dabei').hidden = !alleLeute.length;
|
||||
baueDabei();
|
||||
} catch { $('feld-dabei').hidden = true; }
|
||||
|
||||
/* Vom Bericht kommend: VOR dem ersten Laden auf die Liste stellen,
|
||||
sonst baut die Seite erst den Monat auf und wirft ihn gleich
|
||||
wieder weg. Der Ansichtsknopf wird mitgesetzt -- sonst zeigt die
|
||||
|
||||
+65
-7
@@ -8,9 +8,9 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -104,6 +104,32 @@
|
||||
<p class="leise" id="laedt">Aufgaben werden geladen …</p>
|
||||
</div>
|
||||
|
||||
<!-- ABGEBROCHENE AUFGABEN (05.09.2026).
|
||||
|
||||
Ein eigener Bereich UNTER dem Brett, nicht als fünfte Spalte:
|
||||
Am Handy wären dann alle fünf so schmal, dass keine mehr lesbar
|
||||
ist. Und Abgebrochenes sieht man nicht täglich.
|
||||
|
||||
Verschwinden darf es aber auch nicht -- sonst wäre der Abbruch
|
||||
ein Löschen mit Zwischenschritt, und genau das soll er nicht
|
||||
sein. Deshalb: zugeklappt, mit der Anzahl im Titel, einen Klick
|
||||
entfernt.
|
||||
|
||||
<details> und nicht selbstgebaut: bringt Tastaturbedienung,
|
||||
aria-expanded und das Verhalten von Suchfunktionen im Browser
|
||||
von sich aus mit. -->
|
||||
<details class="abgebrochen" id="abgebrochen" hidden>
|
||||
<summary class="abgebrochen__kopf">
|
||||
<span class="abgebrochen__name">Abgebrochen</span>
|
||||
<span class="abgebrochen__zahl" id="abgebrochen-zahl">0</span>
|
||||
</summary>
|
||||
<p class="abgebrochen__unter">
|
||||
Nicht gelöscht – nur beiseitegelegt. Mit Grund, damit später
|
||||
nachvollziehbar bleibt, warum. Jederzeit wieder aufnehmbar.
|
||||
</p>
|
||||
<div class="abgebrochen__liste" id="abgebrochen-liste"></div>
|
||||
</details>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Bearbeiten. Als natives <dialog>: bringt Fokusfang, Schließen mit Esc
|
||||
@@ -153,9 +179,41 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202609051419" defer></script>
|
||||
<!-- ABBRECHEN MIT GRUND (05.09.2026).
|
||||
|
||||
Kein confirm(): Das blockiert die ganze Seite, nimmt kein Textfeld
|
||||
auf und sieht auf jedem Rechner anders aus.
|
||||
|
||||
Der Grund ist PFLICHT. Ohne ihn weiß in vier Wochen niemand mehr,
|
||||
warum etwas wegfiel -- und dann ist ein Abbruch von einem Löschen
|
||||
nicht mehr zu unterscheiden. Der Server verlangt ihn ebenfalls;
|
||||
hier steht er, damit man es merkt, bevor man abschickt. -->
|
||||
<dialog class="dialog" id="abbruch-dialog">
|
||||
<form method="dialog" id="abbruch-form" class="neu neu--blank">
|
||||
<h2 class="dialog__titel" id="abbruch-titel">Aufgabe abbrechen?</h2>
|
||||
<!-- Sagt, was passiert UND was nicht passiert. Die Sorge bei so
|
||||
einem Knopf ist immer, ob etwas verloren geht. -->
|
||||
<p class="dialog__unter" id="abbruch-was"></p>
|
||||
|
||||
<div class="neu__zeile">
|
||||
<label class="feld-schild" for="abbruch-grund">Warum wird sie abgebrochen?</label>
|
||||
<textarea id="abbruch-grund" rows="3" maxlength="500"
|
||||
placeholder="z. B. Kampagne wurde verschoben – kommt im Oktober neu."></textarea>
|
||||
</div>
|
||||
|
||||
<p class="fehler" id="abbruch-fehler" role="alert" aria-live="polite"></p>
|
||||
|
||||
<div class="dialog__knoepfe">
|
||||
<button type="submit" class="knopf knopf--klein" id="abbruch-ok">Abbrechen & beiseitelegen</button>
|
||||
<button type="button" class="abmelden" id="abbruch-zurueck">Doch nicht</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -133,9 +133,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/automation.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/automation.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+13
-12
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -116,13 +116,14 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/ampel.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/schulung.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/ampel.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/schulung.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-9
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -75,10 +75,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/calls.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/calls.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-10
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/content.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/content.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -165,11 +165,12 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/content.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/content.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -94,9 +94,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="gate">
|
||||
@@ -144,6 +144,6 @@
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/gate.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/gate.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+23
-13
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -112,14 +112,23 @@
|
||||
|
||||
Einzelne Schalter statt eines <select multiple>: Das verliert
|
||||
bei einem Fehlklick die ganze Auswahl und ist am Handy kaum zu
|
||||
bedienen. Genau so ist es bei den Dateien schon gebaut, die
|
||||
Bedienung ist also bekannt.
|
||||
bedienen.
|
||||
|
||||
Das Feld erscheint nur für die Leitung -- wer keine Zuordnungen
|
||||
verschieben darf, bekommt es gar nicht erst zu sehen. -->
|
||||
FÜR JEDE ROLLE, nicht nur für die Leitung (05.09.2026:
|
||||
*"das soll für jeden verfügbar sein"*). Das ist ohne Risiko,
|
||||
weil nicht die Oberfläche entscheidet, wen man auswählen darf,
|
||||
sondern der Server: /api/personen liefert jeder Rolle nur die
|
||||
zugeteilten Namen, und teilnehmerSetzen() lässt alles andere
|
||||
still weg. Ein Creator sieht hier also seine Betreuer, keine
|
||||
fremden Creator.
|
||||
|
||||
Die Klassen heißen k-dabei__* und stehen in kalender.css.
|
||||
Vorher waren es die .wahl__*-Klassen aus dateien.css -- die
|
||||
diese Seite gar nicht einbindet, weshalb die Namen ohne jeden
|
||||
Abstand aneinanderklebten. -->
|
||||
<div id="feld-dabei" hidden>
|
||||
<p class="feld-schild">Wer ist noch dabei?</p>
|
||||
<div class="wahl" id="dabei-wahl"></div>
|
||||
<div class="k-dabei" id="dabei-wahl"></div>
|
||||
<p class="leise" id="dabei-hinweis"></p>
|
||||
</div>
|
||||
|
||||
@@ -196,9 +205,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -122,9 +122,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/personen.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/personen.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-9
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -200,10 +200,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/profil.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/profil.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-9
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -92,10 +92,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/report.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/report.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -125,9 +125,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -100,9 +100,10 @@
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/start.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/start.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -71,10 +71,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/startcheck.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/startcheck.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -112,9 +112,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* =====================================================================
|
||||
Service Worker des Creator Workspace.
|
||||
|
||||
Er hat GENAU EINE Aufgabe: Benachrichtigungen entgegennehmen und
|
||||
anzeigen.
|
||||
|
||||
⚠️ BEWUSST OHNE ZWISCHENSPEICHER. Der naheliegende Zusatz waere,
|
||||
hier auch Seiten und Dateien vorzuhalten ("offline verfuegbar").
|
||||
Genau das waere hier falsch:
|
||||
|
||||
* Der Workspace liegt hinter einer Anmeldung. Ein Service Worker,
|
||||
der Antworten aufhebt, hebt damit AUCH persoenliche Daten auf --
|
||||
auf einem Geraet, das jemand anderem gehoeren kann.
|
||||
|
||||
* Die Seiten aendern sich haeufig. Ein Zwischenspeicher, der etwas
|
||||
Falsches zeigt, ist schlimmer als gar keiner -- man glaubt ihm
|
||||
ja. Die Versionsstempel loesen das Problem bereits.
|
||||
|
||||
Wer spaeter Offline-Faehigkeit will, faengt hier NICHT einfach an,
|
||||
sondern klaert vorher, was zwischengespeichert werden darf.
|
||||
===================================================================== */
|
||||
|
||||
/* Beim Einbauen sofort uebernehmen, statt auf das Schliessen aller
|
||||
Reiter zu warten. Ohne diese zwei Zeilen wuerde eine neue Fassung
|
||||
erst Tage spaeter aktiv -- und niemand wuesste, welche gerade laeuft. */
|
||||
self.addEventListener("install", (e) => { self.skipWaiting(); });
|
||||
self.addEventListener("activate", (e) => { e.waitUntil(self.clients.claim()); });
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Eine Benachrichtigung kommt an.
|
||||
------------------------------------------------------------------ */
|
||||
self.addEventListener("push", (e) => {
|
||||
/* Ohne Daten trotzdem etwas anzeigen: Manche Push-Dienste schicken
|
||||
im Zweifel eine leere Nachricht, und ein Browser, der eine
|
||||
Push-Berechtigung hat, MUSS etwas anzeigen -- sonst entzieht er
|
||||
sie nach mehrmaligem Schweigen wieder. */
|
||||
let d = { titel: "Creator Workspace", text: "Es gibt etwas Neues.", ziel: "/workspace/start.html" };
|
||||
try {
|
||||
if (e.data) d = { ...d, ...e.data.json() };
|
||||
} catch {
|
||||
try { d.text = e.data.text() || d.text; } catch { /* dann die Vorgabe */ }
|
||||
}
|
||||
|
||||
e.waitUntil(self.registration.showNotification(d.titel, {
|
||||
body: d.text,
|
||||
icon: "/assets/img/app-symbole/workspace-192.png",
|
||||
badge: "/assets/img/app-symbole/workspace-192.png",
|
||||
/* Gleiche Art ersetzt sich gegenseitig, statt sich zu stapeln --
|
||||
drei Meldungen "Aufgabe ueberfaellig" untereinander helfen
|
||||
niemandem. */
|
||||
tag: d.art || "workspace",
|
||||
renotify: false,
|
||||
data: { ziel: d.ziel || "/workspace/start.html" },
|
||||
/* Kein `requireInteraction`: Eine Benachrichtigung, die stehen
|
||||
bleibt, bis man sie wegklickt, ist eine Zumutung. */
|
||||
}));
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Jemand tippt die Benachrichtigung an.
|
||||
|
||||
Ist der Workspace schon offen, wird DIESES Fenster benutzt und dort
|
||||
hingesteuert -- sonst sammeln sich mit der Zeit fuenf Reiter mit
|
||||
derselben Seite. Nur wenn keines offen ist, wird eines geoeffnet.
|
||||
------------------------------------------------------------------ */
|
||||
self.addEventListener("notificationclick", (e) => {
|
||||
e.notification.close();
|
||||
const ziel = e.notification.data?.ziel || "/workspace/start.html";
|
||||
|
||||
e.waitUntil((async () => {
|
||||
const fenster = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
for (const f of fenster) {
|
||||
if (f.url.includes("/workspace/")) {
|
||||
await f.focus();
|
||||
if ("navigate" in f) { try { await f.navigate(ziel); } catch { /* schon dort */ } }
|
||||
return;
|
||||
}
|
||||
}
|
||||
await self.clients.openWindow(ziel);
|
||||
})());
|
||||
});
|
||||
@@ -8,9 +8,9 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -58,9 +58,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/uebersicht.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/uebersicht.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202609051419" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609051732" />
|
||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202609051732" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -176,9 +176,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wissen.js?v=202609051419" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609051732" defer></script>
|
||||
<script src="assets/js/wissen.js?v=202609051732" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user