Ein Zugang, den ausser DogFather niemand bemerkt

Aus dem Anforderungsdokument (Master-Blueprint V3.0) fehlte die Rolle
"Modi" ganz -- es gab nur spicy, admin, manager, scout, creator. Filipes
Bedingung dazu: "dass die keine neue eingangs kachel bekommen wie spicy
dogfather und so sondern einfach einen code. damit die von der workspace
auch nicht mal sehen dass die modis von mir einen eigenen zugang haben."

DER EINGANG. Es gibt keine sechste Kachel und es laesst sich auch keine
erzwingen: Wer von aussen `rolle: "modi"` schickt, bekommt wortgleich
dieselbe Antwort wie bei einer erfundenen Rolle. Ein Modi tippt auf
irgendeine vorhandene Kachel -- welche, ist gleichgueltig -- und gibt
seinen Code ein. Der Code allein entscheidet.

Moeglich macht das eine neue Spalte `code_kennung`: ein HMAC ueber den
Code, in Mikrosekunden nachgeschlagen. Zwei naheliegende Wege wurden
verworfen, weil man sie finden kann: ein Merkmal im Code ("M-...") waere
ein sichtbares Kennzeichen auf dem Zettel des Modis, eine eigene Adresse
(/modi.html) eine Seite, die man aufrufen kann. Der Suchschluessel steht
VOR dem gewohnten Weg, nicht dahinter -- ein Rueckfall nach einem
Fehlversuch haette genau die Fehlversuche verlaengert, und daran waere
es zu erkennen gewesen.

Unbedenklich, weil nachgemessen: Ein Code hat 16 Zeichen aus einem
32er-Alphabet, also 80 Bit Zufall. Der Suchschluessel sagt ausserdem nur,
WEN man pruefen soll -- ob der Code stimmt, sagt weiterhin scrypt.

DIE UNSICHTBARKEIT sitzt in verborgeneIds(), also an derselben einen
Stelle wie die Regel fuer den zweiten Admin-Zugang, und nicht in den
rund 170 Abfragen, die Personen lesen. Sie haengt dabei an der ROLLE und
nicht an einer Nummer -- die Schwachstelle, die im Kommentar der alten
Regel offen dasteht (wird Zugang 1 geloescht, rueckt der naechste nach),
kann einer Rolle nicht passieren.

Nach Filipes Entscheidungen: Die Modis sehen sich untereinander (Kapitel
7.2 des Dokuments), VanVan sieht sie mit (Kapitel 3, sie traegt dieselbe
Rolle), Codes gibt Filipe selbst weiter -- kein Einladelink, der in einem
Verlauf landen kann.

ZWEIMAL WAERE DAS WORT "MODI" BEINAHE IN EINER DATEI GELANDET, DIE JEDER
BEKOMMT: in den Rollenlisten von start.js und personen.js. Ein Blick in
den Quelltext haette genuegt. Der Anzeigename kommt jetzt aus
/workspace/api/ich (beschreibt immer nur den Angemeldeten selbst), die
Rollenauswahl aus der Antwort des Servers und nur an die DogFather-Rolle.

GEPRUEFT mit pruef-modi-verborgen.mjs (45 Pruefungen): fuenf Kacheln
fuehren mit dem Modi-Code hinein, ein Manager-Code auf fremder Kachel
weiterhin nicht (sonst waere nebenbei die Rollenpruefung abgeschafft),
und ueber fuenf Schnittstellen sieht ausser DogFather, VanVan und den
Modis niemand etwas -- auch nicht die ZAHL daneben.

Die Gegenprobe steht bewusst ganz unten, weil sie eine Person absichtlich
aus der Regel aushaengt: Weiter oben haette sie jeden Abschnitt danach
verfaelscht. Beim ersten Anlauf stand sie in der Mitte, und prompt tauchte
die Person in einer spaeteren Managerliste auf.

Abschnitt 5 prueft den Weg durch die Anwendung selbst (DogFather legt an,
der Modi meldet sich an). Die Abschnitte davor tragen die Personen von
Hand ein und rechnen den Suchschluessel selbst aus -- damit waere NICHT
bewiesen, dass personAnlegen() ihn im Betrieb schreibt. Ohne ihn kaeme
kein einziger echter Modi herein, und oben waere trotzdem alles gruen.

Die Umstellung der Rollenliste in der Datenbank steht ab jetzt einmal in
rollenRegelUmstellen() statt zum dritten Mal abgeschrieben. Jede Abschrift
waere eine Gelegenheit, eine der vier Absicherungen zu vergessen: Sicherung
vorher, Zaehlung innerhalb der Transaktion, Spaltenliste aus der Tabelle,
Pruefung auf verwaiste Verweise danach.

pruef-personen-formular erwartet jetzt sechs Rollen statt fuenf und prueft
die Liste statt nur die Anzahl -- sechs Karten koennten auch fuenf richtige
und eine doppelte sein. Dass die sechste dort auftaucht, ist gleichzeitig
der Nachweis, dass der Weg ueber den Server funktioniert.

Unveraendert bestanden: pruef-spicy (60), pruef-rollen (97),
pruef-verborgen, pruef-personen-liste, pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-10 00:11:39 +02:00
co-authored by Claude Opus 5
parent 88e2b5cd5d
commit 7be488e0ca
27 changed files with 1103 additions and 383 deletions
+326
View File
@@ -0,0 +1,326 @@
/* =====================================================================
DER VERBORGENE MODI-ZUGANG
Zwei Fragen, und sie sind nicht dieselbe:
1. KOMMT EIN MODI HEREIN, ohne dass es dafuer eine Kachel gibt?
2. MERKT SONST JEMAND ETWAS DAVON?
Die zweite ist die schwierigere. Eine Liste zu filtern ist leicht;
uebersehen wird die ZAHL daneben, die weiter alles mitzaehlt -- und
eine Personenzahl, die nicht zur Liste passt, verraet genauso viel
wie ein Name.
MIT GEGENPROBE. Ganz unten wird die Regel absichtlich ausgehebelt
(ein Modi bekommt in der Datenbank eine andere Rolle), und dann MUSS
der Manager ihn sehen. Ohne diesen Teil waere nicht bewiesen, dass
die Pruefung ueberhaupt anschlagen kann -- eine Pruefung, die immer
bestaetigt, bestaetigt nichts.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-modi-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4359";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
/* NACH index.js: Der Server haengt dort zwei Auffangnetze ein, die
einen Absturz nur protokollieren. Fuer den Betrieb richtig, fuer eine
Pruefung fatal -- ohne diese Zeile bliebe der Prozess ewig stehen. */
notbremse(180_000, "pruef-modi-verborgen");
await new Promise((r) => setTimeout(r, 700));
const BASIS = "http://127.0.0.1:4359";
let fehler = 0;
let geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* Der dritte Ausgang: Konnte nicht nachgesehen werden. Kein Befund,
aber auch keine Bestaetigung -- und es wird gesagt, damit niemand
glaubt, hier sei etwas geprueft worden. */
let offen = 0;
const unklar = (t) => { offen++; console.log(" -- " + t); };
/* ---------- Personen anlegen ---------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
/* Der Suchschluessel muss VOR dem Anlegen dastehen -- die Personen
werden hier von Hand eingetragen, nicht ueber personAnlegen(). */
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
const kennung = (code) => createHmac("sha256", schluessel).update(code).digest("hex");
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, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, kennung(code), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idVan = anlegen("VanVan", "admin", "CODE-VAN-0001");
const idSpicy = anlegen("Cigdem", "spicy", "CODE-SPIC-0001");
const idMan = anlegen("Schulle", "manager", "CODE-MAN-0001");
const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
const idCreat = anlegen("Tili", "creator", "CODE-ANNA-0001");
const idModi1 = anlegen("Marina", "modi", "CODE-MODI-0001");
const idModi2 = anlegen("Kim", "modi", "CODE-MODI-0002");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
.run(idCreat, idScout, jetzt);
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)")
.run(idScout, idMan, jetzt);
d.close();
const MODIS = [idModi1, idModi2];
void idSpicy;
/* ---------- Anmelden ueber die Schnittstelle ------------------------- */
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 daten = await a.json().catch(() => ({}));
const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
return { status: a.status, daten, keks };
}
console.log("\n=== 1. Der Eingang: eine Kachel gibt es nicht ===\n");
for (const kachel of ["creator", "scout", "manager", "spicy", "admin"]) {
const a = await anmelden(kachel, "CODE-MODI-0001");
ok(a.status === 200 && a.daten?.rolle === "modi",
`Kachel "${kachel}" + Modi-Code -> angemeldet als ${a.daten?.rolle || a.status}`);
}
/* Der Umkehrschluss, und er ist der wichtigere: Der stille Weg darf
NICHT dazu fuehren, dass auch alle anderen die Kachel ignorieren
duerfen. Ein Manager mit der Kachel "creator" muss weiterhin
scheitern -- sonst haette ich nebenbei die Rollenpruefung
abgeschafft, ohne es zu merken. */
{
const a = await anmelden("creator", "CODE-MAN-0001");
ok(a.status === 401, `Manager-Code auf der Creator-Kachel bleibt abgewiesen (${a.status})`);
}
/* Wortgleichheit: "modi" von aussen anzugeben darf nicht anders klingen
als eine erfundene Rolle. */
{
const m = await anmelden("modi", "FALSCH-FALSCH-1");
const x = await anmelden("gibtesnicht", "FALSCH-FALSCH-1");
ok(m.status === 401 && x.status === 401
&& JSON.stringify(m.daten) === JSON.stringify(x.daten),
"Rolle \"modi\" von aussen klingt wie eine erfundene "
+ `(${m.status} ${JSON.stringify(m.daten)} vs. ${x.status} ${JSON.stringify(x.daten)})`);
}
{
const a = await anmelden("creator", "CODE-MODI-FALSCH");
ok(a.status === 401, `ein falscher Modi-Code wird abgewiesen (${a.status})`);
}
/* ---------- 2. Die Unsichtbarkeit ------------------------------------ */
console.log("\n=== 2. Wer sieht die Modis? ===\n");
/* Jede Schnittstelle, die Personen ausgibt. Dieselbe Landkarte wie in
pruef-verborgen.mjs -- wer eine neue baut, traegt sie in BEIDE ein. */
const WEGE = [
["Chat-Partner", "/workspace/api/chat/partner", (x) => x.personen],
["Personenwahl", "/workspace/api/personen", (x) => x.personen],
["Personenverwaltung", "/workspace/api/verwaltung/personen", (x) => x.personen],
["Betreuer-Auswahl", "/workspace/api/verwaltung/personen", (x) => x.betreuer],
/* NACHGESEHEN, NICHT ANGENOMMEN: Die Segmente der Zentrale tragen
KEINE Nummer -- nur `{ name, rolle, stufe, heute, alt }`, und der
Name ist auf den Vornamen gekuerzt. Wer hier nur nach `id` sucht,
bekommt einen gruenen Haken, ohne etwas angesehen zu haben. Genau
deshalb vergleicht der Treffer unten BEIDES. */
["Zentrale (Team)", "/workspace/api/zentrale", (x) => x.segmente],
];
/* Die Namen der Modis -- fuer die Wege, die keine Nummern liefern. */
const MODI_NAMEN = ["Marina", "Kim"];
const LEUTE = [
["DogFather", "admin", "CODE-DOGI-0001", true],
["VanVan", "admin", "CODE-VAN-0001", true],
["Spicy Media", "spicy", "CODE-SPIC-0001", false],
["Manager", "manager", "CODE-MAN-0001", false],
["Scout", "scout", "CODE-SCOU-0001", false],
["Creator", "creator", "CODE-ANNA-0001", false],
["Modi Marina", "creator", "CODE-MODI-0001", true],
];
async function hole(keks, pfad) {
const a = await fetch(BASIS + pfad, { headers: { cookie: keks } });
if (!a.ok) return { status: a.status, daten: null };
return { status: 200, daten: await a.json().catch(() => null) };
}
for (const [name, kachel, code, darf] of LEUTE) {
const an = await anmelden(kachel, code);
if (an.status !== 200) { unklar(`${name}: Anmeldung misslang (${an.status})`); continue; }
for (const [was, pfad, ziehen] of WEGE) {
const { status, daten } = await hole(an.keks, pfad);
if (status !== 200) {
unklar(`${name.padEnd(12)} ${was.padEnd(20)} kein Zugriff (${status})`);
continue;
}
let liste = [];
try { liste = ziehen(daten) || []; } catch { liste = []; }
const drin = liste.filter((z) => z
&& (MODIS.includes(z.id ?? z.person_id)
|| MODI_NAMEN.includes(String(z.name || "").trim().split(/\s+/)[0])));
if (darf) {
ok(true, `${name.padEnd(12)} ${was.padEnd(20)} sieht ${drin.length} Modi(s)`);
} else {
ok(drin.length === 0,
`${name.padEnd(12)} ${was.padEnd(20)} `
+ (drin.length ? `SIEHT ${drin.length} MODI -- FEHLER` : "sieht keinen Modi"));
}
}
}
/* ---------- 3. Die Zahl daneben -------------------------------------- */
console.log("\n=== 3. Auch die ZAHL darf sie nicht mitzaehlen ===\n");
{
const an = await anmelden("manager", "CODE-MAN-0001");
const { status, daten } = await hole(an.keks, "/workspace/api/personen");
if (status !== 200 || !Array.isArray(daten?.personen)) {
unklar(`Personenwahl fuer den Manager nicht lesbar (${status})`);
} else {
const namen = daten.personen.map((z) => z.name);
/* Die Zahl der geprueften Dinge gehoert in die BEDINGUNG, nicht nur
in den Meldetext: Eine leere Liste wuerde sonst als "keine Modis
drin" durchgehen, und die Pruefung waere wertlos. */
ok(namen.length > 0 && !namen.includes("Marina") && !namen.includes("Kim"),
`der Manager sieht ${namen.length} Personen, keine davon ein Modi `
+ `(${namen.join(", ")})`);
}
}
/* ---------- 4. Sehen die Modis einander? ----------------------------- */
console.log("\n=== 4. Die Modis sind untereinander ein Team ===\n");
{
const an = await anmelden("creator", "CODE-MODI-0001");
const { status, daten } = await hole(an.keks, "/workspace/api/personen");
if (status !== 200 || !Array.isArray(daten?.personen)) {
unklar(`Personenwahl fuer den Modi nicht lesbar (${status})`);
} else {
const ids = daten.personen.map((z) => z.id);
ok(ids.includes(idModi2), "Marina sieht Kim");
ok(ids.includes(idDogi), "Marina sieht DogFather");
/* VanVan bleibt fuer alle ausser DogFather verborgen -- das ist die
Regel vom 09.09.2026, und sie gilt auch gegenueber Modis. */
ok(!ids.includes(idVan), "Marina sieht VanVan NICHT (verborgener Zugang)");
ok(!ids.includes(idMan) && !ids.includes(idScout) && !ids.includes(idCreat),
"Marina sieht weder Manager noch Scout noch Creator");
}
}
/* ---------- 5. Der echte Weg durch die Anwendung --------------------- */
console.log("\n=== 5. Anlegen wie im Betrieb: DogFather legt einen Modi an ===\n");
{
const an = await anmelden("admin", "CODE-DOGI-0001");
const kopf = { "Content-Type": "application/json", cookie: an.keks };
/* a) Steht die Rolle ueberhaupt zur Auswahl -- und kommt sie vom
Server, nicht aus einer Datei im Browser? */
const { status, daten } = await hole(an.keks, "/workspace/api/verwaltung/personen");
if (status !== 200) {
unklar(`Verwaltung fuer DogFather nicht lesbar (${status})`);
} else {
const zusatz = daten?.zusatzrollen || [];
ok(zusatz.some((z) => z.wert === "modi"),
"DogFather bekommt die Rolle zur Auswahl angeboten "
+ `(${zusatz.map((z) => z.wert).join(", ") || "keine"})`);
}
/* b) Anlegen -- ueber genau den Weg, den die Oberflaeche benutzt. */
const a = await fetch(BASIS + "/workspace/api/verwaltung/personen", {
method: "POST", headers: kopf,
body: JSON.stringify({ name: "Testmodi Neu", rolle: "modi" }),
});
const neuDaten = await a.json().catch(() => ({}));
ok(a.status === 201 && neuDaten?.rolle === "modi" && typeof neuDaten?.code === "string",
`angelegt (${a.status}, Rolle ${neuDaten?.rolle}, `
+ `Code ${neuDaten?.code ? "erhalten" : "FEHLT"})`);
/* c) DER EIGENTLICHE PUNKT: Kommt der so angelegte Modi auch herein?
Die Abschnitte oben haben die Personen VON HAND eingetragen und
den Suchschluessel selbst ausgerechnet. Damit ist nicht bewiesen,
dass personAnlegen() ihn im Betrieb auch wirklich schreibt -- und
ohne ihn findet der stille Zugang niemanden. Waere die Zeile dort
vergessen worden, waeren oben trotzdem alle Haken gruen und kein
einziger echter Modi kaeme herein. */
if (neuDaten?.code) {
const b = await anmelden("creator", neuDaten.code);
ok(b.status === 200 && b.daten?.rolle === "modi",
"und meldet sich mit seinem Code an, ueber eine fremde Kachel "
+ `(${b.status}, ${b.daten?.rolle})`);
} else {
unklar("ohne Code laesst sich die Anmeldung nicht pruefen");
}
/* d) Und ist auch der Neue fuer den Manager unsichtbar? */
const m = await anmelden("manager", "CODE-MAN-0001");
const sicht = await hole(m.keks, "/workspace/api/personen");
if (sicht.status !== 200 || !Array.isArray(sicht.daten?.personen)) {
unklar(`Personenwahl fuer den Manager nicht lesbar (${sicht.status})`);
} else {
const namen = sicht.daten.personen.map((z) => z.name);
ok(namen.length > 0 && !namen.includes("Testmodi Neu"),
`der Manager sieht ihn nicht (${namen.length} Personen: ${namen.join(", ")})`);
}
}
/* ---------- 6. Die Gegenprobe ----------------------------------------
Sie steht ABSICHTLICH GANZ UNTEN: Sie haengt eine Person
absichtlich aus der Regel aus. Lief sie weiter oben, tauchte
diese Person in jeder Liste DANACH auf -- und jeder spaetere
Abschnitt maesse einen Zustand, den es im Betrieb nicht gibt.
-------------------------------------------------------------------- */
console.log("\n=== 6. Gegenprobe: kann diese Pruefung ueberhaupt anschlagen? ===\n");
{
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
d2.prepare("UPDATE personen SET rolle = 'creator' WHERE id = ?").run(idModi1);
/* Betreuung dazu, sonst waere der Creator fuer den Manager auch ohne
die Verbergen-Regel unsichtbar -- und die Gegenprobe pruefte nichts. */
d2.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
.run(idModi1, idScout, jetzt);
d2.close();
const an = await anmelden("manager", "CODE-MAN-0001");
const { status, daten } = await hole(an.keks, "/workspace/api/personen");
if (status !== 200 || !Array.isArray(daten?.personen)) {
unklar(`Gegenprobe nicht durchfuehrbar (${status})`);
} else {
const sichtbar = daten.personen.some((z) => z.id === idModi1);
ok(sichtbar, sichtbar
? "ohne die Rolle 'modi' sieht der Manager dieselbe Person sehr wohl "
+ "-- die Pruefung oben kann also anschlagen"
: "GEGENPROBE FEHLGESCHLAGEN: auch ohne die Regel bleibt die Person "
+ "unsichtbar. Die Pruefung oben beweist damit nichts.");
}
}
console.log(`\n${fehler ? "FEHLGESCHLAGEN" : "BESTANDEN"} -- `
+ `${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler ? 1 : 0);
+21 -5
View File
@@ -108,17 +108,33 @@ const mass = await dogi.evaluate(() => {
wahlOben: Math.round(w.top), wahlOben: Math.round(w.top),
listeOben: dn ? Math.round(dn.top) : null, listeOben: dn ? Math.round(dn.top) : null,
karten: karten.length, karten: karten.length,
kartenRollen: karten.map((k) => k.dataset.rolle),
kartenBreiten: karten.map((k) => Math.round(k.getBoundingClientRect().width)), kartenBreiten: karten.map((k) => Math.round(k.getBoundingClientRect().width)),
/* Liegt eine Karte weiter rechts als das Formular breit ist? */ /* Liegt eine Karte weiter rechts als das Formular breit ist? */
ueberRand: karten.some((k) => k.getBoundingClientRect().right > f.right + 1), ueberRand: karten.some((k) => k.getBoundingClientRect().right > f.right + 1),
}; };
}); });
/* FÜNF, seit es Spicy Media gibt. Die Zahl steht hier absichtlich fest /* SECHS, seit es die verborgene Modi-Rolle gibt (09.09.2026) -- vorher
und wird nicht aus der Oberfläche abgeleitet: Sie ist die Erwartung, fünf, seit es Spicy Media gibt. Die Erwartung steht hier absichtlich
gegen die gemessen wird. Fiele eine Rolle aus der Wahl, müsste das fest und wird nicht aus der Oberfläche abgeleitet: Fiele eine Rolle
auffallen -- eine Zahl, die sich selbst nachzählt, kann das nicht. */ aus der Wahl, müsste das auffallen -- eine Zahl, die sich selbst
ok(mass.karten === 5, `alle fünf Rollen stehen zur Wahl (${mass.karten})`); nachzählt, kann das nicht.
DIE SECHSTE IST NICHT WIE DIE ANDEREN. Die fünf stehen in
assets/js/personen.js, also in einer Datei, die jeder bekommt, der
die Seite öffnet. "Modi" darf dort nicht stehen -- ein Blick in den
Quelltext würde sonst genügen, um den verborgenen Zugang zu
entdecken. Sie kommt deshalb aus der Antwort des Servers und nur an
die DogFather-Rolle. Dass sie hier auftaucht, ist gleichzeitig der
Nachweis, dass dieser Weg funktioniert.
Geprüft wird ab jetzt die LISTE und nicht nur die Anzahl: Sechs
Karten könnten auch fünf richtige und eine doppelte sein. */
ok(mass.karten === 6, `alle sechs Rollen stehen zur Wahl (${mass.karten})`);
ok(JSON.stringify([...mass.kartenRollen].sort())
=== JSON.stringify(["admin", "creator", "manager", "modi", "scout", "spicy"]),
`und es sind die richtigen (${mass.kartenRollen.join(", ")})`);
ok(mass.wahlUnten <= mass.formUnten, ok(mass.wahlUnten <= mass.formUnten,
`die Rollenauswahl endet INNERHALB des Formulars (${mass.wahlUnten} ≤ ${mass.formUnten})`); `die Rollenauswahl endet INNERHALB des Formulars (${mass.wahlUnten} ≤ ${mass.formUnten})`);
ok(!mass.ueberRand, "keine Karte ragt seitlich aus dem Formular"); ok(!mass.ueberRand, "keine Karte ragt seitlich aus dem Formular");
+44 -1
View File
@@ -211,6 +211,33 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => {
`SELECT id, name, rolle FROM personen `SELECT id, name, rolle FROM personen
WHERE rolle IN ('admin', 'manager', 'scout') AND aktiv = 1 WHERE rolle IN ('admin', 'manager', 'scout') AND aktiv = 1
ORDER BY ${ROLLEN_SORTIERUNG}, name`).all().filter((z) => !weg.has(z.id)), ORDER BY ${ROLLEN_SORTIERUNG}, name`).all().filter((z) => !weg.has(z.id)),
/* =============================================================
ROLLEN, DIE NICHT IM BROWSER STEHEN DUERFEN (09.09.2026)
Die Rollenauswahl im Formular kommt sonst aus einer Liste in
assets/js/personen.js. Diese Datei bekommt JEDER ausgeliefert,
der die Seite oeffnet -- auch ein Manager, ein Scout, ein
Creator. Steht dort `modi: 'Modi'`, genuegt ein Blick in den
Quelltext, und der ganze verborgene Zugang ist verraten.
Deshalb kommt dieser eine Eintrag vom Server und nur an die
DogFather-Rolle. Wer die Rolle nicht hat, bekommt eine leere
Liste -- nicht eine gefilterte Liste, sondern gar keine
Angabe. Ein Feld, das mal Inhalt hat und mal nicht, verraet
nichts; ein Feld mit einem "darfst du nicht" darin schon.
Text und Zeichen stehen deshalb ebenfalls hier und nicht im
Browser. `schutz` ist das vorhandene Schild-Zeichen -- es passt
zur Moderation und spart ein neues, das in der Zeichenliste
wieder fuer alle sichtbar waere. */
zusatzrollen: istDogFather(req.person)
? [{
wert: "modi", name: "Modi", symbol: "schutz",
text: "Moderation und Team-Aufgaben. Sichtbar nur fuer die "
+ "DogFather-Rolle und fuer die Modis untereinander.",
}]
: [],
}); });
} catch (fehler) { } catch (fehler) {
console.error("[workspace] Personen lesen:", fehler?.message); console.error("[workspace] Personen lesen:", fehler?.message);
@@ -502,7 +529,23 @@ personenRouter.post("/workspace/api/verwaltung/personen", gleicheHerkunft, (req,
const rolle = String(req.body?.rolle ?? ""); const rolle = String(req.body?.rolle ?? "");
if (name.length < 2) return res.status(400).json({ fehler: "Name fehlt." }); if (name.length < 2) return res.status(400).json({ fehler: "Name fehlt." });
if (name.length > NAME_MAX) return res.status(400).json({ fehler: "Name ist zu lang." }); if (name.length > NAME_MAX) return res.status(400).json({ fehler: "Name ist zu lang." });
if (!ROLLEN.includes(rolle)) return res.status(400).json({ fehler: "Unbekannte Rolle." }); /* 'MODI' IST FUER ALLE AUSSER DOGFATHER KEINE BEKANNTE ROLLE
(09.09.2026).
EHRLICHERWEISE: Diese Zeile schliesst heute keine Luecke. Der
ganze Zweig /workspace/api/verwaltung haengt an nurAdmin, und
nurAdmin laesst nur die DogFather-Rolle durch -- weiter kommt
ohnehin niemand. Sie steht hier fuer den Tag, an dem jemand
nurAdmin lockert, etwa damit Manager Leute verwalten duerfen.
Dann waere sonst genau hier der stille Weg zu einem Modi-Zugang.
WARUM DIESELBE ANTWORT WIE BEI EINER ERFUNDENEN ROLLE: Ein
"das darfst du nicht" waere schlimmer als das Recht selbst -- es
verriete, dass es die Rolle gibt. Deshalb wortgleich, mit
demselben Statuscode. */
const unbekannt = !ROLLEN.includes(rolle)
|| (rolle === "modi" && !istDogFather(req.person));
if (unbekannt) return res.status(400).json({ fehler: "Unbekannte Rolle." });
/* ERSTER VORBEHALT: Eine Leitung anlegen darf nur DogFather. /* ERSTER VORBEHALT: Eine Leitung anlegen darf nur DogFather.
Duerfte ein Manager das, koennte er sich einen zweiten Zugang mit Duerfte ein Manager das, koennte er sich einen zweiten Zugang mit
+383 -103
View File
@@ -21,7 +21,7 @@
import express from "express"; import express from "express";
import { import {
randomBytes, scryptSync, timingSafeEqual, createHash, randomBytes, scryptSync, timingSafeEqual, createHash, createHmac,
} from "node:crypto"; } from "node:crypto";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
@@ -57,19 +57,34 @@ const COOKIE = "dfw_sitzung";
const SITZUNG_STUNDEN = 12; const SITZUNG_STUNDEN = 12;
const VERSUCHE_MAX = 8; // pro IP const VERSUCHE_MAX = 8; // pro IP
const VERSUCHE_FENSTER_MIN = 10; const VERSUCHE_FENSTER_MIN = 10;
const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator"]); const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator", "modi"]);
/* Die Reihenfolge, in der Rollen ueberall erscheinen: Spicy Media /* Die Reihenfolge, in der Rollen ueberall erscheinen: Spicy Media
zuerst, dann DogFather, Manager, Scout, Creator. Steht hier einmal, zuerst, dann DogFather, Manager, Scout, Creator. Steht hier einmal,
damit keine Liste eine eigene Reihenfolge erfindet. */ damit keine Liste eine eigene Reihenfolge erfindet. */
export const ROLLEN_REIHE = ["spicy", "admin", "manager", "scout", "creator"]; export const ROLLEN_REIHE = ["spicy", "admin", "manager", "scout", "creator", "modi"];
/* DIE ROLLEN MIT EINER KACHEL AUF DER ANMELDESEITE.
'modi' steht hier NICHT drin, und das ist der Kern des verborgenen
Zugangs: Es gibt keine sechste Kachel, und es laesst sich auch keine
erzwingen. Wer von aussen `rolle: "modi"` schickt, bekommt genau
dieselbe Antwort wie bei einer erfundenen Rolle -- er erfaehrt also
nicht einmal, dass es sie gibt.
Zwei Listen statt einer, weil es zwei verschiedene Fragen sind:
ROLLEN sagt, welche Rollen es GIBT (die Datenbank laesst nur diese
zu). Diese hier sagt, mit welchen man sich ANMELDEN kann, indem man
sie anklickt. Waere es eine Liste, haette 'modi' entweder eine Kachel
-- oder es gaebe die Rolle gar nicht. */
const ROLLEN_KACHEL = new Set(["spicy", "admin", "manager", "scout", "creator"]);
/* Als SQL-Ausdruck fuer ORDER BY. "ORDER BY rolle" waere alphabetisch /* Als SQL-Ausdruck fuer ORDER BY. "ORDER BY rolle" waere alphabetisch
(admin, creator, manager, scout, spicy) -- also fast genau falsch (admin, creator, manager, scout, spicy) -- also fast genau falsch
herum. */ herum. */
export const ROLLEN_SORTIERUNG = export const ROLLEN_SORTIERUNG =
"CASE rolle WHEN 'spicy' THEN 0 WHEN 'admin' THEN 1 WHEN 'manager' THEN 2" "CASE rolle WHEN 'spicy' THEN 0 WHEN 'admin' THEN 1 WHEN 'manager' THEN 2"
+ " WHEN 'scout' THEN 3 ELSE 4 END"; + " WHEN 'scout' THEN 3 WHEN 'creator' THEN 4 ELSE 5 END";
/* LEITUNG = DogFather und Manager. Ein Manager darf alles, was /* LEITUNG = DogFather und Manager. Ein Manager darf alles, was
DogFather darf -- mit genau zwei Ausnahmen, die in DogFather darf -- mit genau zwei Ausnahmen, die in
@@ -168,6 +183,7 @@ export const ROLLEN_NAME = {
manager: "Manager", manager: "Manager",
scout: "Scout", scout: "Scout",
creator: "Creator", creator: "Creator",
modi: "Modi",
}; };
/* scrypt-Parameter. N=2^15 braucht auf diesem Server rund 150 ms — spürbar /* scrypt-Parameter. N=2^15 braucht auf diesem Server rund 150 ms — spürbar
@@ -194,6 +210,122 @@ let _dbFehler = null;
SQLite selbst und in sich konsistent, anders als ein Dateikopie SQLite selbst und in sich konsistent, anders als ein Dateikopie
waehrend laufender Schreibvorgaenge). waehrend laufender Schreibvorgaenge).
===================================================================== */ ===================================================================== */
/* =====================================================================
DIE ROLLENLISTE DER DATENBANK ERWEITERN (09.09.2026)
Welche Rollen es geben darf, steht als CHECK-Regel in der Tabelle
`personen`. SQLite kann eine CHECK-Regel nicht aendern -- der einzige
saubere Weg ist: neue Tabelle, Daten hinueber, alte weg, neue
umbenennen. Das ist der Moment, in dem eine Datenbank kaputtgehen
kann. Deshalb steht der Ablauf ab jetzt EINMAL hier.
WARUM ALS FUNKTION: Er stand vorher zweimal da -- einmal fuer
'manager', einmal fuer 'spicy'. Fuer 'modi' waere es die dritte
Abschrift geworden, und jede Abschrift ist eine Gelegenheit, eine der
vier Absicherungen zu vergessen: die Sicherung VORHER, die Zaehlung
INNERHALB der Transaktion, die Spaltenliste aus der Tabelle statt aus
dem Gedaechtnis, die Pruefung auf verwaiste Verweise DANACH. Der
manager-Block darueber bleibt unangetastet: Er baut die Tabelle mit
einer fest hingeschriebenen Spaltenliste auf und ist damit ein
anderer Fall -- ihn mit einzufangen waere ein zweiter Umbau an einer
Stelle, an der ein Fehler Daten kostet.
GEPRUEFT WIRD DIE REGEL, NICHT DER TEXT (07.09.2026, gefunden von
pruef-spicy): SQLite hebt den CREATE-Text woertlich auf, mitsamt
Kommentaren. Ein erklaerender Satz mit dem Wort 'spicy' genuegte, und
die Umstellung hielt die Tabelle fuer schon umgestellt, obwohl die
CHECK-Regel noch die alte war. Deshalb wird die Regel herausgeschnitten
und NUR darin gesucht.
@param marker Die Rolle, an der erkannt wird, ob schon umgestellt ist.
@param rollen Die vollstaendige neue Liste erlaubter Rollen.
===================================================================== */
function rollenRegelUmstellen(d, marker, rollen, jetztStempel) {
const rollenPlan = d.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || "";
const regel = rollenPlan.match(/rolle\s+IN\s*\(([^)]*)\)/i)?.[1] || "";
/* Keine Regel gefunden heisst: Es gibt nichts umzustellen. Nicht
etwa "dann bauen wir sie neu" -- eine Tabelle ohne CHECK ist ein
Zustand, den jemand ansehen sollte, kein Fall fuer Automatik. */
if (!rollenPlan || !regel || regel.includes(`'${marker}'`)) return;
const sicherung = `${DB_PFAD}.vor-${marker}-${jetztStempel}`;
try {
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
} catch (fehler) {
/* Ohne Sicherung wird NICHT umgestellt. Lieber laeuft die neue Rolle
noch nicht, als dass Daten ohne Netz angefasst werden. */
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
return;
}
/* Nur die Rollenliste ersetzen -- und pruefen, dass es geklappt hat.
`IF NOT EXISTS` MUSS MIT INS MUSTER (07.09.2026, gefunden von
pruef-spicy). Die Tabelle wurde mit `CREATE TABLE IF NOT EXISTS
personen` angelegt, also steht das auch in sqlite_master. Ohne diese
drei Woerter im Muster griff die Ersetzung nicht, der Bauplan blieb
unveraendert, die Schutzabfrage darunter schlug an -- und die
Umstellung waere auf dem echten Server NIE gelaufen. Sie haette es
gesagt (das ist der Wert der Abfrage), aber sie waere nie gelaufen. */
const neuerPlan = rollenPlan
.replace(/CREATE TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?personen["'`]?/i,
"CREATE TABLE personen_neu")
.replace(/rolle\s+IN\s*\([^)]*\)/i,
`rolle IN (${rollen.map((r) => `'${r}'`).join(",")})`);
if (!neuerPlan.includes(`'${marker}'`) || !neuerPlan.includes("personen_neu")) {
console.error("[workspace] Umstellung abgebrochen: Der Bauplan liess sich nicht "
+ "umschreiben. Steht die CHECK-Regel noch so da wie erwartet?");
return;
}
/* Die Spaltenliste kommt aus der Tabelle, nicht aus dem Gedaechtnis. */
const spalten = d.prepare("PRAGMA table_info(personen)").all().map((z) => z.name);
if (!spalten.length) {
console.error("[workspace] Umstellung abgebrochen: keine Spalten gefunden.");
return;
}
const liste = spalten.map((n) => `"${n}"`).join(", ");
/* Fremdschluessel muessen aus sein, weil andere Tabellen auf
personen(id) zeigen -- und das laesst sich nicht innerhalb einer
Transaktion umschalten. */
d.exec("PRAGMA foreign_keys = OFF");
try {
const vorher = d.prepare("SELECT COUNT(*) AS n FROM personen").get().n;
d.exec("BEGIN");
d.exec(neuerPlan);
d.exec(`INSERT INTO personen_neu (${liste}) SELECT ${liste} FROM personen;`);
const nachher = d.prepare("SELECT COUNT(*) AS n FROM personen_neu").get().n;
/* Die Zaehlung steht INNERHALB der Transaktion -- stimmt sie nicht,
wird zurueckgerollt und die alte Tabelle bleibt unberuehrt. */
if (nachher !== vorher) {
d.exec("ROLLBACK");
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Personen vorher, `
+ `${nachher} nachher. Sicherung: ${sicherung}`);
} else {
d.exec("DROP TABLE personen;");
d.exec("ALTER TABLE personen_neu RENAME TO personen;");
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] Rolle '${marker}' freigeschaltet, ${nachher} Personen, `
+ `${spalten.length} Spalten uebernommen, Verweise geprueft.`);
}
}
} catch (fehler) {
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message,
"-- Sicherung:", sicherung);
} finally {
d.exec("PRAGMA foreign_keys = ON");
}
}
function umstellungen(d) { function umstellungen(d) {
/* MIT SEKUNDEN, nicht nur mit Minuten (07.09.2026). /* MIT SEKUNDEN, nicht nur mit Minuten (07.09.2026).
@@ -458,6 +590,30 @@ function umstellungen(d) {
naheliegende Abkuerzung gewesen -- und faellt in dem Moment um, naheliegende Abkuerzung gewesen -- und faellt in dem Moment um,
in dem jemand den Titel einer uebernommenen Aufgabe aendert. */ in dem jemand den Titel einer uebernommenen Aufgabe aendert. */
["aufgaben", "vorlage", "TEXT"], ["aufgaben", "vorlage", "TEXT"],
/* DER SUCHSCHLUESSEL ZUM CODE (09.09.2026).
Bisher gab es nur den scrypt-Hash, und der ist mit Absicht
langsam (rund 150 ms). Wer eine Person AM CODE finden will, muss
deshalb alle Kandidaten der Reihe nach durchrechnen -- die
Anmeldung tut genau das, und im Code steht seit langem der
Hinweis, dass das ab etwa 50 Codes je Rolle spuerbar wird.
Diese Spalte ist die schnelle Antwort auf "zu welcher Person
gehoert dieser Code?": ein HMAC ueber den Code, in Mikrosekunden
berechnet und mit einem Index hinterlegt.
WARUM DAS UNBEDENKLICH IST -- nachgemessen, nicht angenommen: Ein
Code besteht aus 16 Zeichen eines 32er-Alphabets, das sind 80 Bit
Zufall. Selbst wenn jemand die ganze Datenbank haette, waere
Durchprobieren aussichtslos, egal wie schnell die Rechnung ist.
Der Schluessel steckt zusaetzlich nicht im Code, sondern in den
Einstellungen (siehe kennungSchluessel).
DER SCRYPT-HASH BLEIBT DIE ENTSCHEIDUNG. Der Suchschluessel sagt
nur, WEN man pruefen soll; ob der Code stimmt, sagt weiterhin
allein scrypt. Ein Treffer hier allein laesst niemanden herein. */
["personen", "code_kennung", "TEXT"],
]) { ]) {
try { try {
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
@@ -475,6 +631,16 @@ function umstellungen(d) {
die ganze Termintabelle. Er steht hier unten und nicht oben im die ganze Termintabelle. Er steht hier unten und nicht oben im
Bauplan, weil die beiden Spalten erst durch die Schleife darüber Bauplan, weil die beiden Spalten erst durch die Schleife darüber
entstehen -- oben gäbe es sie beim ersten Start noch nicht. */ entstehen -- oben gäbe es sie beim ersten Start noch nicht. */
try {
/* Ohne Index waere der Suchschluessel sinnlos -- SQLite laese die
ganze Personentabelle, und genau das sollte er ersparen. Steht
aus demselben Grund hier unten wie der Termin-Index: Die Spalte
entsteht erst durch die Schleife darueber. */
d.exec("CREATE INDEX IF NOT EXISTS idx_personen_kennung ON personen (code_kennung)");
} catch (fehler) {
console.error("[workspace] Index idx_personen_kennung:", fehler?.message);
}
try { try {
d.exec("CREATE INDEX IF NOT EXISTS idx_termine_serie ON termine (serie_id, serie_tag)"); d.exec("CREATE INDEX IF NOT EXISTS idx_termine_serie ON termine (serie_id, serie_tag)");
} catch (fehler) { } catch (fehler) {
@@ -980,91 +1146,14 @@ function umstellungen(d) {
} }
} }
const rollenPlan = d.prepare( /* Welche Rollen die Datenbank zulaesst. Der Ablauf steht in
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || ""; rollenRegelUmstellen() -- einmal, nicht je Rolle abgeschrieben.
/* GEPRUEFT WIRD DIE REGEL, NICHT DER TEXT (07.09.2026). Beide Aufrufe stehen hier: Auf einer Datenbank, die 'spicy' schon
kennt, tut der erste nichts und nur der zweite laeuft. */
Hier stand `rollenPlan.includes("'spicy'")`. Das ist eine Suche im rollenRegelUmstellen(d, "spicy",
ganzen gespeicherten Bauplan -- und SQLite hebt den woertlich auf, ["spicy", "admin", "manager", "scout", "creator"], jetztStempel);
mitsamt allen Kommentaren darin. Ein erklaerender Satz mit dem Wort rollenRegelUmstellen(d, "modi",
'spicy' genuegte, und die Umstellung hielt die Tabelle fuer schon ["spicy", "admin", "manager", "scout", "creator", "modi"], jetztStempel);
umgestellt, obwohl die CHECK-Regel noch die alte war. Beim Bauen
genau so passiert.
Jetzt wird die Regel selbst herausgeschnitten und NUR darin gesucht.
Findet sich keine, gibt es nichts umzustellen. */
const regel = rollenPlan.match(/rolle\s+IN\s*\(([^)]*)\)/i)?.[1] || "";
if (rollenPlan && regel && !regel.includes("'spicy'")) {
const sicherung = `${DB_PFAD}.vor-spicy-${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;
}
/* Nur die Rollenliste ersetzen -- und pruefen, dass es geklappt hat. */
const neuerPlan = rollenPlan
/* `IF NOT EXISTS` MUSS MIT INS MUSTER (07.09.2026, gefunden von
pruef-spicy). SQLite hebt den CREATE-Text WOERTLICH auf -- die
Tabelle wurde mit `CREATE TABLE IF NOT EXISTS personen` angelegt,
also steht das auch in sqlite_master. Ohne diese drei Woerter im
Muster griff die Ersetzung nicht, der Bauplan blieb unveraendert,
die Schutzabfrage darunter schlug an -- und die Umstellung waere
auf dem echten Server NIE gelaufen. Sie haette es gesagt (das ist
der Wert der Abfrage), aber sie waere nie gelaufen. */
.replace(/CREATE TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?personen["'`]?/i,
"CREATE TABLE personen_neu")
.replace(/rolle\s+IN\s*\([^)]*\)/i, "rolle IN ('spicy','admin','manager','scout','creator')");
if (!neuerPlan.includes("'spicy'") || !neuerPlan.includes("personen_neu")) {
console.error("[workspace] Umstellung abgebrochen: Der Bauplan liess sich nicht "
+ "umschreiben. Steht die CHECK-Regel noch so da wie erwartet?");
return;
}
/* Die Spaltenliste kommt aus der Tabelle, nicht aus dem Gedaechtnis. */
const spalten = d.prepare("PRAGMA table_info(personen)").all().map((z) => z.name);
if (!spalten.length) {
console.error("[workspace] Umstellung abgebrochen: keine Spalten gefunden.");
return;
}
const liste = spalten.map((n) => `"${n}"`).join(", ");
d.exec("PRAGMA foreign_keys = OFF");
try {
const vorher = d.prepare("SELECT COUNT(*) AS n FROM personen").get().n;
d.exec("BEGIN");
d.exec(neuerPlan);
d.exec(`INSERT INTO personen_neu (${liste}) SELECT ${liste} FROM personen;`);
const nachher = d.prepare("SELECT COUNT(*) AS n FROM personen_neu").get().n;
/* Die Zaehlung steht INNERHALB der Transaktion -- stimmt sie nicht,
wird zurueckgerollt und die alte Tabelle bleibt unberuehrt. */
if (nachher !== vorher) {
d.exec("ROLLBACK");
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Personen vorher, `
+ `${nachher} nachher. Sicherung: ${sicherung}`);
} else {
d.exec("DROP TABLE personen;");
d.exec("ALTER TABLE personen_neu RENAME TO personen;");
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] Rolle 'spicy' freigeschaltet, ${nachher} Personen, `
+ `${spalten.length} Spalten uebernommen, Verweise geprueft.`);
}
}
} catch (fehler) {
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message,
"-- Sicherung:", sicherung);
} finally {
d.exec("PRAGMA foreign_keys = ON");
}
}
} }
export function db() { export function db() {
@@ -1094,7 +1183,7 @@ export function db() {
CREATE TABLE IF NOT EXISTS personen ( CREATE TABLE IF NOT EXISTS personen (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
rolle TEXT NOT NULL CHECK (rolle IN ('spicy','admin','manager','scout','creator')), rolle TEXT NOT NULL CHECK (rolle IN ('spicy','admin','manager','scout','creator','modi')),
code_hash TEXT NOT NULL, code_hash TEXT NOT NULL,
code_salt TEXT NOT NULL, code_salt TEXT NOT NULL,
code_n INTEGER NOT NULL, code_n INTEGER NOT NULL,
@@ -1838,6 +1927,62 @@ function hashe(code, salt, N) {
{ N, r: SCRYPT.r, p: SCRYPT.p, maxmem: 256 * 1024 * 1024 }).toString("hex"); { N, r: SCRYPT.r, p: SCRYPT.p, maxmem: 256 * 1024 * 1024 }).toString("hex");
} }
/* =====================================================================
DER SUCHSCHLUESSEL ZU EINEM CODE (09.09.2026)
Beantwortet in Mikrosekunden, WEN man pruefen soll -- nicht, ob der
Code stimmt. Das bleibt Sache von scrypt darunter.
WARUM EIN HMAC UND KEIN EINFACHER HASH: Ein einfacher Hash ueber den
Code waere ueberall gleich. Mit einem Schluessel, der nur in dieser
einen Datenbank steht, ist der Wert ausserhalb wertlos -- er laesst
sich nirgends nachschlagen und nirgends wiedererkennen.
WARUM DER SCHLUESSEL IN DIE DATENBANK GEHOERT UND NICHT IN DIE
UMGEBUNG: Eine neue Pflichtangabe in server/.env ist eine Angabe, die
beim naechsten Aufsetzen fehlen kann. Sie wuerde nicht laut scheitern,
sondern leise: Der stille Zugang fuende niemanden mehr, und die
Anmeldung saehe aus wie ein falscher Code. Aus der Datenbank kann sie
nicht verschwinden -- sie wird mitgesichert wie alles andere.
OHNE SCHLUESSEL GIBT ES KEINE ANTWORT, nicht etwa eine schlechtere:
`null` heisst "konnte nicht nachsehen", und die Anmeldung behandelt
das wie "nicht gefunden". Ein Rueckfall auf einen ungeschluesselten
Wert waere die stille Verschlechterung, die niemandem auffaellt. */
let _kennungSchluessel = null;
function kennungSchluessel() {
if (_kennungSchluessel) return _kennungSchluessel;
try {
const d = db();
const lies = () => d.prepare(
"SELECT wert FROM einstellungen WHERE schluessel = 'code_kennung_schluessel'").get()?.wert;
let wert = lies();
if (!wert) {
/* Bewusst NICHT ueber einstellungSetzen(): Das schriebe einen
Eintrag ins Protokoll, und ein Protokolleintrag ist genau die
Spur, die es hier nicht geben soll. */
d.prepare(`INSERT INTO einstellungen (schluessel, wert, geaendert, von)
VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING`)
.run("code_kennung_schluessel", randomBytes(32).toString("hex"),
new Date().toISOString());
/* Danach nochmal LESEN statt den erzeugten Wert zu benutzen:
Legen zwei Vorgaenge ihn im selben Augenblick an, gewinnt einer
-- und beide muessen mit demselben weiterrechnen. */
wert = lies();
}
_kennungSchluessel = wert || null;
return _kennungSchluessel;
} catch {
return null;
}
}
export function codeKennung(code) {
const schluessel = kennungSchluessel();
if (!schluessel || !code) return null;
return createHmac("sha256", schluessel).update(String(code)).digest("hex");
}
/* Vergleich in konstanter Zeit. Ein normaler ===-Vergleich bricht beim /* Vergleich in konstanter Zeit. Ein normaler ===-Vergleich bricht beim
ersten abweichenden Zeichen ab; aus den Laufzeitunterschieden lässt sich ersten abweichenden Zeichen ab; aus den Laufzeitunterschieden lässt sich
ein Geheimnis Zeichen für Zeichen erraten. */ ein Geheimnis Zeichen für Zeichen erraten. */
@@ -2075,6 +2220,33 @@ workspaceRouter.use((req, res, next) => {
return next(); return next();
}); });
/**
* Gehoert dieser Code zu einem verborgenen Zugang?
*
* Gibt die Person zurueck oder null. `null` heisst hier ausdruecklich
* "nein oder nicht feststellbar" -- ohne Suchschluessel (etwa weil die
* Einstellung fehlt) gibt codeKennung() null zurueck, und dann wird
* niemand angemeldet. Das ist die richtige Richtung: Im Zweifel kommt
* niemand herein, statt dass im Zweifel jemand hereinkommt.
*/
function stillerZugang(code) {
try {
const kennung = codeKennung(code);
if (!kennung) return null;
const k = db().prepare(
"SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen "
+ "WHERE code_kennung = ? AND rolle = 'modi' AND aktiv = 1").get(kennung);
if (!k) return null;
/* Trotz Treffer wird geprueft. Der Suchschluessel ist ein
Wegweiser, kein Ausweis -- und ein Wegweiser, dem man blind
folgt, ist eine Hintertuer. */
return gleich(hashe(code, k.code_salt, k.code_n), k.code_hash) ? k : null;
} catch (fehler) {
console.error("[workspace] Stiller Zugang nicht pruefbar:", fehler?.message);
return null;
}
}
workspaceRouter.post("/workspace/api/anmelden", (req, res) => { workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
const ip = echteIp(req); const ip = echteIp(req);
try { try {
@@ -2085,7 +2257,56 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
const rolle = String(req.body?.rolle || ""); const rolle = String(req.body?.rolle || "");
const code = String(req.body?.code || ""); const code = String(req.body?.code || "");
if (!ROLLEN.has(rolle) || code.length < 4 || code.length > 200) { const codeBrauchbar = code.length >= 4 && code.length <= 200;
/* =================================================================
DER STILLE ZUGANG (09.09.2026)
Wunsch Filipe: *"dass die keine neue eingangs kachel bekommen wie
spicy dogfather und so sondern einfach einen code. damit die von
der workspace auch nicht mal sehen dass die modis von mir einen
eigenen zugang haben."*
Ein Modi oeffnet dieselbe Seite, tippt auf IRGENDEINE vorhandene
Kachel und gibt seinen Code ein. Welche Kachel, ist gleichgueltig
-- der Code allein entscheidet. Von aussen sieht das aus wie jede
andere Anmeldung: dieselbe Seite, dieselbe Anfrage, dieselben
Felder.
ZWEI NAHELIEGENDE WEGE, DIE ABSICHTLICH NICHT GEWAEHLT WURDEN:
* Ein Merkmal im Code ("M-..."): waere ein sichtbares Kennzeichen
auf dem Zettel des Modis. Wer den Code sieht, sieht die Sorte.
* Eine eigene Adresse (/modi.html): waere eine Seite, die man
finden kann -- und die Seitenpruefung zaehlt Seiten.
WARUM DAS VOR DEM GEWOHNTEN WEG STEHT UND NICHT DAHINTER: Ein
Rueckfall NACH einem Fehlversuch haette den gewohnten Weg
verlaengert -- und zwar nur dann, wenn er scheitert. Genau daran
waere es zu erkennen gewesen: Fehlversuche dauern ploetzlich
laenger als frueher. Der Suchschluessel kostet Mikrosekunden und
trifft in aller Regel nichts; fuer alle anderen bleibt der Ablauf
unveraendert, auch in der Zeit.
DER SUCHSCHLUESSEL ALLEIN LAESST NIEMANDEN HEREIN. Er sagt nur,
WEN man pruefen soll -- danach entscheidet scrypt wie ueberall. */
const still = codeBrauchbar ? stillerZugang(code) : null;
if (still) {
db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip);
db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), still.id);
sitzungSetzen(res, still, req);
protokolliere("anmeldung", {
personId: still.id, rolle: still.rolle, ip, detail: still.name,
});
return res.json({
weiter: "/workspace/start.html", name: still.name, rolle: still.rolle,
});
}
/* Ab hier der gewohnte Weg -- unveraendert. 'modi' ist keine
Kachel-Rolle und faellt deshalb in dieselbe Antwort wie eine
erfundene. */
if (!ROLLEN_KACHEL.has(rolle) || !codeBrauchbar) {
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt()); db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
return res.status(401).json({ fehler: "ungueltig" }); return res.status(401).json({ fehler: "ungueltig" });
} }
@@ -2452,14 +2673,51 @@ function sichtbareCreatorIdsRoh(person) {
===================================================================== */ ===================================================================== */
export function verborgeneIds(person) { export function verborgeneIds(person) {
try { try {
const admins = db().prepare( const d = db();
/* ERSTER TEIL: die verborgenen Admin-Zugaenge (Regel vom 09.09.2026).
Der Admin mit der kleinsten Nummer ist DogFather, jeder weitere
Admin-Zugang ist verborgen. */
const admins = d.prepare(
"SELECT id FROM personen WHERE rolle = 'admin' ORDER BY id").all().map((z) => z.id); "SELECT id FROM personen WHERE rolle = 'admin' ORDER BY id").all().map((z) => z.id);
if (admins.length < 2) return []; const versteckteAdmins = admins.length >= 2 ? admins.slice(1) : [];
const versteckt = admins.slice(1);
if (!person) return versteckt; /* ZWEITER TEIL: das Modi-Team.
if (person.id === admins[0]) return []; /* DogFather sieht alle */
if (versteckt.includes(person.id)) return []; /* sich selbst sieht man */ Hier haengt die Regel an der ROLLE und nicht an einer Nummer --
return versteckt; und das ist der Unterschied, auf den es ankommt. Beim ersten Teil
steht die Schwachstelle im Kommentar oben ausdruecklich da: Wuerde
Zugang 1 geloescht, rueckte der naechste nach und waere ploetzlich
sichtbar. Eine Rolle kann das nicht passieren. Wer einen Modi
verbergen will, muss ihm nichts anhaengen und nichts nachhalten --
er IST verborgen, solange er ein Modi ist.
WER DARF SIE SEHEN, und nur diese zwei:
* die DogFather-Rolle (admin) -- also auch die rechte Hand,
denn sie traegt dieselbe Rolle. So gewollt (Kapitel 3 des
Anforderungsdokuments: gleicher Ueberblick).
* ein Modi selbst -- sie sind untereinander ein Team.
Manager, Scouts, Creator und Spicy Media sehen sie nicht. Nicht
"sehen sie ohne Einzelheiten", sondern gar nicht: kein Name, kein
Eintrag, keine Zahl, die sie mitzaehlt. */
const modis = d.prepare(
"SELECT id FROM personen WHERE rolle = 'modi'").all().map((z) => z.id);
/* Ohne bekannte Person wird BEIDES verborgen. Das ist die strengere
Antwort, und bei einer Sichtbarkeitsregel ist die strengere die
richtige: Wer nicht weiss, wer fragt, zeigt nichts. */
const darfAdminsSehen = !!person
&& (person.id === admins[0] || versteckteAdmins.includes(person.id));
const darfModisSehen = !!person
&& (person.rolle === "admin" || person.rolle === "modi");
const weg = [];
if (!darfAdminsSehen) weg.push(...versteckteAdmins);
if (!darfModisSehen) weg.push(...modis);
/* Doppelte heraus: Ein Modi-Zugang koennte theoretisch auch in der
ersten Liste stehen, wenn jemand die Rollen umhaengt. */
return [...new Set(weg)];
} catch { } catch {
/* Ohne Datenbank lieber nichts verbergen als abstuerzen -- diese /* Ohne Datenbank lieber nichts verbergen als abstuerzen -- diese
Funktion darf keine Seite lahmlegen. Sie laeuft in jedem Funktion darf keine Seite lahmlegen. Sie laeuft in jedem
@@ -2510,7 +2768,27 @@ function sichtbarePersonenIdsRoh(person) {
for (const z of db().prepare( for (const z of db().prepare(
"SELECT id FROM personen WHERE rolle = 'admin' AND aktiv = 1").all()) dogi.push(z.id); "SELECT id FROM personen WHERE rolle = 'admin' AND aktiv = 1").all()) dogi.push(z.id);
} catch { /* ohne Datenbank bleibt die Liste, wie sie war */ } } catch { /* ohne Datenbank bleibt die Liste, wie sie war */ }
return [...new Set([person.id, ...creator, ...scouts, ...dogi])];
/* DAS MODI-TEAM SIEHT SICH UNTEREINANDER (Entscheidung Filipe,
09.09.2026: "ja, sie sind untereinander ein Team").
Ohne diese Zeilen waere jeder Modi allein: kein Team-Gruppenchat,
keine Schicht, die man tauschen kann, keine Eskalation, die man
weiterreicht, ohne dass DogFather sie einzeln durchstellt.
Es ist KEIN Loch in der Verborgenheit, sondern ihre andere Seite.
Was diese Liste zusaetzlich hergibt, wird gleich danach durch
ohneVerborgene() wieder eingeschraenkt -- und dort steht, dass ein
Modi nur fuer die DogFather-Rolle und fuer Modis sichtbar ist. Wer
kein Modi ist, bekommt diese Nummern hier gar nicht erst. */
const modis = [];
if (person.rolle === "modi") {
try {
for (const z of db().prepare(
"SELECT id FROM personen WHERE rolle = 'modi' AND aktiv = 1").all()) modis.push(z.id);
} catch { /* ohne Datenbank bleibt die Liste, wie sie war */ }
}
return [...new Set([person.id, ...creator, ...scouts, ...dogi, ...modis])];
} }
/** WER BETREUT MICH -- die Blickrichtung nach oben. /** WER BETREUT MICH -- die Blickrichtung nach oben.
@@ -2975,8 +3253,9 @@ export function personAnlegen(name, rolle, akteur = null) {
const salt = randomBytes(16).toString("hex"); const salt = randomBytes(16).toString("hex");
const hash = hashe(code, salt, SCRYPT.N); const hash = hashe(code, salt, SCRYPT.N);
const { lastInsertRowid } = db().prepare( const { lastInsertRowid } = db().prepare(
"INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)" "INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
).run(name, rolle, hash, salt, SCRYPT.N, jetzt()); + " VALUES (?,?,?,?,?,?,1,?)"
).run(name, rolle, hash, salt, SCRYPT.N, codeKennung(code), jetzt());
protokolliere("person_angelegt", { protokolliere("person_angelegt", {
personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null,
ip: akteur?.ip ?? null, detail: `${name} (${rolle})`, ip: akteur?.ip ?? null, detail: `${name} (${rolle})`,
@@ -3000,8 +3279,9 @@ export function codeNeu(id, akteur = null, behalteToken = null) {
if (!person) throw new Error(`Keine Person mit Nummer ${id}`); if (!person) throw new Error(`Keine Person mit Nummer ${id}`);
const code = codeErzeugen(); const code = codeErzeugen();
const salt = randomBytes(16).toString("hex"); const salt = randomBytes(16).toString("hex");
db().prepare("UPDATE personen SET code_hash = ?, code_salt = ?, code_n = ? WHERE id = ?") db().prepare(
.run(hashe(code, salt, SCRYPT.N), salt, SCRYPT.N, id); "UPDATE personen SET code_hash = ?, code_salt = ?, code_n = ?, code_kennung = ? WHERE id = ?")
.run(hashe(code, salt, SCRYPT.N), salt, SCRYPT.N, codeKennung(code), id);
/* Offene Sitzungen beenden -- ein neuer Code soll den alten Zugang /* Offene Sitzungen beenden -- ein neuer Code soll den alten Zugang
wirklich beenden, nicht nur die nächste Anmeldung betreffen. wirklich beenden, nicht nur die nächste Anmeldung betreffen.
+4 -4
View File
@@ -13,25 +13,25 @@
"theme_color": "#06090f", "theme_color": "#06090f",
"icons": [ "icons": [
{ {
"src": "/assets/img/app-symbole/workspace-192.png?v=202609092156", "src": "/assets/img/app-symbole/workspace-192.png?v=202609100010",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/assets/img/app-symbole/workspace-512.png?v=202609092156", "src": "/assets/img/app-symbole/workspace-512.png?v=202609100010",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609092156", "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609100010",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609092156", "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609100010",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
+60 -27
View File
@@ -65,34 +65,66 @@
: ['creator']; : ['creator'];
for (const r of ROLLEN.filter((x) => darf.includes(x.wert))) { for (const r of ROLLEN.filter((x) => darf.includes(x.wert))) {
if ((r.wert === 'admin' || r.wert === 'manager') && ich.rolle !== 'admin') continue; if ((r.wert === 'admin' || r.wert === 'manager') && ich.rolle !== 'admin') continue;
const b = el('button', 'rollenwahl__knopf'); ziel.append(rollenKnopf(ziel, r));
b.type = 'button'; }
b.dataset.rolle = r.wert; }
b.setAttribute('role', 'radio');
b.setAttribute('aria-checked', r.wert === 'creator' ? 'true' : 'false');
if (r.wert === 'creator') b.dataset.an = 'ja';
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); /* EIN KNOPF DER ROLLENWAHL.
s.setAttribute('viewBox', '0 0 24 24');
s.setAttribute('class', 'rollenwahl__symbol');
s.setAttribute('aria-hidden', 'true');
const u = document.createElementNS('http://www.w3.org/2000/svg', 'use');
u.setAttribute('href', '#r-' + r.symbol);
s.append(u);
const txt = el('span', 'rollenwahl__text'); Steht als eigene Funktion da, seit die Auswahl aus ZWEI Quellen
txt.append(el('span', 'rollenwahl__name', r.name)); kommt: der Liste oben (fuer alle gleich) und dem, was der Server
txt.append(el('span', 'rollenwahl__erklaerung', r.text)); zusaetzlich schickt (siehe rollenwahlErgaenzen). Zwei Abschriften
b.append(s, txt); desselben Knopfes waeren zwei Gelegenheiten, dass der eine ein
b.addEventListener('click', () => { aria-checked bekommt und der andere nicht. */
for (const x of ziel.querySelectorAll('.rollenwahl__knopf')) { function rollenKnopf(ziel, r) {
delete x.dataset.an; const b = el('button', 'rollenwahl__knopf');
x.setAttribute('aria-checked', 'false'); b.type = 'button';
} b.dataset.rolle = r.wert;
b.dataset.an = 'ja'; b.setAttribute('role', 'radio');
b.setAttribute('aria-checked', 'true'); b.setAttribute('aria-checked', r.wert === 'creator' ? 'true' : 'false');
}); if (r.wert === 'creator') b.dataset.an = 'ja';
ziel.append(b);
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
s.setAttribute('viewBox', '0 0 24 24');
s.setAttribute('class', 'rollenwahl__symbol');
s.setAttribute('aria-hidden', 'true');
const u = document.createElementNS('http://www.w3.org/2000/svg', 'use');
u.setAttribute('href', '#r-' + r.symbol);
s.append(u);
const txt = el('span', 'rollenwahl__text');
txt.append(el('span', 'rollenwahl__name', r.name));
txt.append(el('span', 'rollenwahl__erklaerung', r.text));
b.append(s, txt);
b.addEventListener('click', () => {
for (const x of ziel.querySelectorAll('.rollenwahl__knopf')) {
delete x.dataset.an;
x.setAttribute('aria-checked', 'false');
}
b.dataset.an = 'ja';
b.setAttribute('aria-checked', 'true');
});
return b;
}
/* ROLLEN, DIE ERST DER SERVER NENNT (09.09.2026).
Die Liste ROLLEN oben steht in dieser Datei -- und diese Datei
bekommt jeder ausgeliefert, der die Seite oeffnet. Eine Rolle, von
der ausser der DogFather-Rolle niemand wissen soll, darf deshalb
nicht darin stehen; sie kommt aus /workspace/api/verwaltung/personen
und nur an die, die sie vergeben duerfen.
WARUM NACHTRAEGLICH UND NICHT GLEICH: baueRollenwahl() laeuft, bevor
die Personen geladen sind -- das Formular soll sofort dastehen und
nicht auf das Netz warten. Die Zusatzrolle kommt also eine Runde
spaeter dazu. Wer nichts bekommt, sieht die Auswahl wie bisher. */
function rollenwahlErgaenzen(zusatz) {
const ziel = $('f-rolle');
if (!ziel || !Array.isArray(zusatz) || !zusatz.length) return;
for (const r of zusatz) {
if (!r || !r.wert || ziel.querySelector(`[data-rolle="${r.wert}"]`)) continue;
ziel.append(rollenKnopf(ziel, r));
} }
} }
@@ -754,8 +786,9 @@
try { try {
const a = await hole('/workspace/api/verwaltung/personen'); const a = await hole('/workspace/api/verwaltung/personen');
if (!a.ok) { melde('Personen konnten nicht geladen werden.'); return; } if (!a.ok) { melde('Personen konnten nicht geladen werden.'); return; }
const { personen, betreuer } = await a.json(); const { personen, betreuer, zusatzrollen } = await a.json();
betreuerListe = betreuer || []; betreuerListe = betreuer || [];
rollenwahlErgaenzen(zusatzrollen);
alle = personen; alle = personen;
ziel.textContent = ''; ziel.textContent = '';
ziel.setAttribute('aria-busy', 'false'); ziel.setAttribute('aria-busy', 'false');
+24 -2
View File
@@ -666,9 +666,24 @@
Bei der eigenen Sicht wie bisher die eigene Rolle; bei fremder Bei der eigenen Sicht wie bisher die eigene Rolle; bei fremder
Sicht der Name davor -- sonst liest man "Creator, dein eigener Sicht der Name davor -- sonst liest man "Creator, dein eigener
Bereich" und haelt es fuer die eigene Lage. */ Bereich" und haelt es fuer die eigene Lage. */
/* DER NAME DER EIGENEN ROLLE KOMMT NOTFALLS VOM SERVER (09.09.2026).
ROLLENTEXT steht in einer Datei, die JEDER bekommt, der die Seite
oeffnet -- auch jeder Manager, jeder Scout, jeder Creator. Eine
Rolle, von der ausser DogFather niemand wissen soll, darf deshalb
nicht darin stehen: Ein Blick in den Quelltext wuerde genuegen.
`rolle_name` kommt dagegen aus /workspace/api/ich und beschreibt
immer nur EINEN Menschen -- den, der gerade angemeldet ist. Wer
Manager ist, liest dort "Manager" und sonst nichts.
Die Reihenfolge ist Absicht: erst das ausfuehrliche Wort aus der
Liste (mit dem erklaerenden Zusatz dahinter), dann der Name vom
Server, zuletzt der nackte Schluessel. So aendert sich fuer die
fuenf bekannten Rollen nichts. */
$('rollentext').textContent = ich.sicht $('rollentext').textContent = ich.sicht
? `Arbeitsplatz von ${ich.sicht.name} · ${ROLLENTEXT[zeigt.rolle] || zeigt.rolle}` ? `Arbeitsplatz von ${ich.sicht.name} · ${ROLLENTEXT[zeigt.rolle] || zeigt.rolle}`
: (ROLLENTEXT[ich.rolle] || ich.rolle); : (ROLLENTEXT[ich.rolle] || ich.rolle_name || ich.rolle);
/* Datum und Rolle in der Anrede -- wie im Vorbild, wo unter "Noch /* Datum und Rolle in der Anrede -- wie im Vorbild, wo unter "Noch
wach, Dogi" das Datum steht und daneben "ADMIN & PARTNER". wach, Dogi" das Datum steht und daneben "ADMIN & PARTNER".
@@ -687,7 +702,14 @@
} }
const rolleEl = $('zgruss-rolle'); const rolleEl = $('zgruss-rolle');
if (rolleEl) { if (rolleEl) {
rolleEl.textContent = (ROLLEN_KURZ[zeigt.rolle] || zeigt.rolle || '').toUpperCase(); /* Aus demselben Grund wie oben: Steht die Rolle nicht in der
Liste, fragt die Plakette den Server -- aber nur fuer die
EIGENE Sicht. In einer fremden Sicht gibt es kein rolle_name
zu dieser Person, und etwas anderes hinzuschreiben waere
geraten. */
const eigene = !ich.sicht;
rolleEl.textContent = (ROLLEN_KURZ[zeigt.rolle]
|| (eigene ? ich.rolle_name : '') || zeigt.rolle || '').toUpperCase();
rolleEl.hidden = false; rolleEl.hidden = false;
} }
} }
+11 -11
View File
@@ -12,12 +12,12 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -231,10 +231,10 @@
</form> </form>
</dialog> </dialog>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/aufgaben.js?v=202609092156" defer></script> <script src="assets/js/aufgaben.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/automation.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/automation.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -140,10 +140,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/automation.js?v=202609092156" defer></script> <script src="assets/js/automation.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+16 -16
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/bereich.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -149,14 +149,14 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/checkliste.js?v=202609092156" defer></script> <script src="assets/js/checkliste.js?v=202609100010" defer></script>
<script src="assets/js/ampel.js?v=202609092156" defer></script> <script src="assets/js/ampel.js?v=202609100010" defer></script>
<script src="assets/js/schulung.js?v=202609092156" defer></script> <script src="assets/js/schulung.js?v=202609100010" defer></script>
<script src="assets/js/vorlagen.js?v=202609092156" defer></script> <script src="assets/js/vorlagen.js?v=202609100010" defer></script>
<script src="assets/js/bereich.js?v=202609092156" defer></script> <script src="assets/js/bereich.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+13 -13
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/calls.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/calls.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -82,11 +82,11 @@
</main> </main>
<script src="assets/js/ki.js?v=202609092156" defer></script> <script src="assets/js/ki.js?v=202609100010" defer></script>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/calls.js?v=202609092156" defer></script> <script src="assets/js/calls.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,19 +12,19 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog, <!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog,
.neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem .neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem
Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen
zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html. zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html.
Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten
Dialog zu sehen bekam. --> Dialog zu sehen bekam. -->
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/chat.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/chat.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -247,10 +247,10 @@
</form> </form>
</dialog> </dialog>
<script src="assets/js/bereiche.js?v=202609092156"></script> <script src="assets/js/bereiche.js?v=202609100010"></script>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156"></script> <script src="assets/js/kopf.js?v=202609100010"></script>
<script src="assets/js/glocke.js?v=202609092156"></script> <script src="assets/js/glocke.js?v=202609100010"></script>
<script src="assets/js/chat.js?v=202609092156"></script> <script src="assets/js/chat.js?v=202609100010"></script>
</body> </body>
</html> </html>
+14 -14
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/content.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/content.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -172,12 +172,12 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/checkliste.js?v=202609092156" defer></script> <script src="assets/js/checkliste.js?v=202609100010" defer></script>
<script src="assets/js/vorlagen.js?v=202609092156" defer></script> <script src="assets/js/vorlagen.js?v=202609100010" defer></script>
<script src="assets/js/content.js?v=202609092156" defer></script> <script src="assets/js/content.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/dateien.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/dateien.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -101,10 +101,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/dateien.js?v=202609092156" defer></script> <script src="assets/js/dateien.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+4 -4
View File
@@ -30,9 +30,9 @@
<link rel="preload" as="image" href="assets/img/buehne/gate-hoch-580.webp" <link rel="preload" as="image" href="assets/img/buehne/gate-hoch-580.webp"
type="image/webp" media="(max-width: 899px)" /> type="image/webp" media="(max-width: 899px)" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
</head> </head>
<body class="gate"> <body class="gate">
@@ -401,6 +401,6 @@
</main> </main>
</div> </div>
<script src="assets/js/gate.js?v=202609092156" defer></script> <script src="assets/js/gate.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+13 -13
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/kalender.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/kalender.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -370,11 +370,11 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/kalender.js?v=202609092156" defer></script> <script src="assets/js/kalender.js?v=202609100010" defer></script>
<script src="assets/js/kalender-abo.js?v=202609092156" defer></script> <script src="assets/js/kalender-abo.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/leistung.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/leistung.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -217,10 +217,10 @@
</form> </form>
</dialog> </dialog>
<script src="assets/js/wahl.js?v=202609092156"></script> <script src="assets/js/wahl.js?v=202609100010"></script>
<script src="assets/js/bereiche.js?v=202609092156"></script> <script src="assets/js/bereiche.js?v=202609100010"></script>
<script src="assets/js/kopf.js?v=202609092156"></script> <script src="assets/js/kopf.js?v=202609100010"></script>
<script src="assets/js/glocke.js?v=202609092156"></script> <script src="assets/js/glocke.js?v=202609100010"></script>
<script src="assets/js/leistung.js?v=202609092156"></script> <script src="assets/js/leistung.js?v=202609100010"></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/personen.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/personen.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -335,10 +335,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/personen.js?v=202609092156" defer></script> <script src="assets/js/personen.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+13 -13
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/profil.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -207,11 +207,11 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/steckbrief.js?v=202609092156" defer></script> <script src="assets/js/steckbrief.js?v=202609100010" defer></script>
<script src="assets/js/profil.js?v=202609092156" defer></script> <script src="assets/js/profil.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+13 -13
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/report.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/report.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -99,11 +99,11 @@
</main> </main>
<script src="assets/js/ki.js?v=202609092156" defer></script> <script src="assets/js/ki.js?v=202609100010" defer></script>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/report.js?v=202609092156" defer></script> <script src="assets/js/report.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/scouting.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/scouting.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -132,10 +132,10 @@
</div> </div>
</div> </div>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/scouting.js?v=202609092156" defer></script> <script src="assets/js/scouting.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+11 -11
View File
@@ -12,14 +12,14 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<!-- Nur die Startseite: Begrüßungskachel, Uhr, Glockenmulde. Siehe <!-- Nur die Startseite: Begrüßungskachel, Uhr, Glockenmulde. Siehe
den Hinweis in start.css -- dort stand es bis zum 07.09.2026. --> den Hinweis in start.css -- dort stand es bis zum 07.09.2026. -->
<link rel="stylesheet" href="assets/css/heim.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/heim.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -667,10 +667,10 @@
</p> </p>
</main> </main>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/start.js?v=202609092156" defer></script> <script src="assets/js/start.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+13 -13
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/startcheck.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -78,11 +78,11 @@
</main> </main>
<script src="assets/js/ki.js?v=202609092156" defer></script> <script src="assets/js/ki.js?v=202609100010" defer></script>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/startcheck.js?v=202609092156" defer></script> <script src="assets/js/startcheck.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/profil.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -119,10 +119,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/steckbrief.js?v=202609092156" defer></script> <script src="assets/js/steckbrief.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -7,13 +7,13 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/team.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/team.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -103,10 +103,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/team.js?v=202609092156" defer></script> <script src="assets/js/team.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,15 +12,15 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<!-- Formular- und Dialogstile: seit dem 07.09.2026 steht hier der <!-- Formular- und Dialogstile: seit dem 07.09.2026 steht hier der
Dialog "Creator anlegen". --> Dialog "Creator anlegen". -->
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/uebersicht.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -142,11 +142,11 @@
</main> </main>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/uebersicht.js?v=202609092156" defer></script> <script src="assets/js/uebersicht.js?v=202609100010" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609092156" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100010" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609092156" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100010" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/start.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/start.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/wissen.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/wissen.css?v=202609100010" />
<link rel="stylesheet" href="assets/css/module.css?v=202609092156" /> <link rel="stylesheet" href="assets/css/module.css?v=202609100010" />
</head> </head>
<body class="start"> <body class="start">
@@ -183,10 +183,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609092156" defer></script> <script src="assets/js/wahl.js?v=202609100010" defer></script>
<script src="assets/js/bereiche.js?v=202609092156" defer></script> <script src="assets/js/bereiche.js?v=202609100010" defer></script>
<script src="assets/js/kopf.js?v=202609092156" defer></script> <script src="assets/js/kopf.js?v=202609100010" defer></script>
<script src="assets/js/glocke.js?v=202609092156" defer></script> <script src="assets/js/glocke.js?v=202609100010" defer></script>
<script src="assets/js/wissen.js?v=202609092156" defer></script> <script src="assets/js/wissen.js?v=202609100010" defer></script>
</body> </body>
</html> </html>