diff --git a/server/pruef-creator-anlegen.mjs b/server/pruef-creator-anlegen.mjs new file mode 100644 index 00000000..b846559a --- /dev/null +++ b/server/pruef-creator-anlegen.mjs @@ -0,0 +1,310 @@ +/* ===================================================================== + EIN MANAGER LEGT EINEN CREATOR AN — und sonst nichts. + + Wunsch Filipe (07.09.2026): "ich will auch dass manager ab jetzt + creator und auch wirklich nur creator hinzufuegen koennen." + + --------------------------------------------------------------------- + WARUM DIESE DATEI MEHR PRUEFT, ALS DER WUNSCH VERLANGT + + Der neue Weg ist der erste, der einem Manager erlaubt, ein KONTO + anzulegen. Ein Konto ist ein Zugang; ein Fehler hier ist kein + Schoenheitsfehler, sondern ein zweiter Schluessel zum Haus. + + Deshalb wird nicht nur geprueft, dass es geht, sondern vor allem, was + NICHT geht. Zu jeder erlaubten Handlung steht die verbotene daneben: + + - Ein Manager legt einen Creator an -> 201 + - ... und der Creator gehoert danach IHM -> Betreuung + - Ein Manager kann die Rolle NICHT bestimmen -> immer creator + - Ein Manager kommt NICHT in die Personenverwaltung -> 404 + - Ein Scout kann diesen Weg nicht benutzen -> 404 + - Ein Creator kann diesen Weg nicht benutzen -> 404 + - Ein Manager teilt keinen FREMDEN Scout zu -> 403 + - Ein Manager teilt seinen EIGENEN Scout zu -> 201 + + Der vorletzte Punkt ist der, auf den es ankommt. Er ist der einzige, + bei dem eine Zahl im Aufruf ueber eine Zuordnung entscheidet -- und + genau so ist am 02.09.2026 schon einmal eine Schranke umgangen + worden ("es reichte, eine Zahl in die Adresse zu schreiben"). + + Aufruf: node server/pruef-creator-anlegen.mjs + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { DatabaseSync } from "node:sqlite"; +import { scryptSync, randomBytes } from "node:crypto"; +import { notbremse } from "./helfer-notbremse.mjs"; + +notbremse(240_000, "die Creator-Anlegen-Pruefung"); + +const HIER = dirname(fileURLToPath(import.meta.url)); +const { portMussFreiSein } = await import("./helfer-port.mjs"); +const PORT = 4347; +await portMussFreiSein(PORT, "die Creator-Anlegen-Pruefung"); +const BASIS = `http://127.0.0.1:${PORT}`; + +const ordner = mkdtempSync(join(tmpdir(), "ws-canlegen-")); +const DB = join(ordner, "workspace.db"); + +let fehler = 0, gemacht = 0; +const ok = (b, t) => { gemacht++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +let kind = null; +async function starten() { + kind = spawn(process.execPath, [join(HIER, "index.js")], { + env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT), + SITE_ACCESS_SECRET: "lokaler-test", + SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" }, + stdio: ["ignore", "pipe", "pipe"] }); + let aus = ""; + kind.stdout.on("data", (d) => { aus += d; }); + kind.stderr.on("data", (d) => { aus += d; }); + for (let i = 0; i < 80; i++) { + await new Promise((r) => setTimeout(r, 250)); + if (kind.exitCode !== null) break; + try { const a = await fetch(BASIS + "/workspace/", { redirect: "manual" }); + if (a.status < 500) return; } catch { /* noch nicht oben */ } + } + throw new Error("Server startete nicht:\n" + aus.slice(-1500)); +} + +await starten(); +kind.kill(); +await new Promise((r) => setTimeout(r, 600)); + +const jetzt = new Date().toISOString(); +function anlegen(d, name, rolle, code) { + const salz = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salz, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salz, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +/* Zwei Manager mit je einem eigenen Scout -- der zweite ist der ganze + Grund dieser Pruefung: An IHM zeigt sich, ob "nur die eigenen" wirklich + gilt. Mit nur einem Manager waere jede Zuteilung zufaellig richtig. */ +let idDogi, idMax, idMia, idScoutMax, idScoutMia, idCreator; +{ + const d = new DatabaseSync(DB); + idDogi = anlegen(d, "Filipe", "admin", "CODE-DOGI-0001"); + idMax = anlegen(d, "Max", "manager", "CODE-MAX-00001"); + idMia = anlegen(d, "Mia", "manager", "CODE-MIA-00001"); + idScoutMax = anlegen(d, "SamVonMax", "scout", "CODE-SAM-00001"); + idScoutMia = anlegen(d, "TimVonMia", "scout", "CODE-TIM-00001"); + idCreator = anlegen(d, "Luna", "creator", "CODE-LUNA-0001"); + d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)") + .run(idScoutMax, idMax, jetzt); + d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)") + .run(idScoutMia, idMia, jetzt); + d.close(); +} +await starten(); + +const anmelden = async (rolle, code) => { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }) }); + const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); + if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`); + return keks; +}; +const keksDogi = await anmelden("admin", "CODE-DOGI-0001"); +const keksMax = await anmelden("manager", "CODE-MAX-00001"); +const keksMia = await anmelden("manager", "CODE-MIA-00001"); +const keksScout = await anmelden("scout", "CODE-SAM-00001"); +const keksLuna = await anmelden("creator", "CODE-LUNA-0001"); + +const ruf = async (art, weg, keks, koerper) => { + const a = await fetch(BASIS + weg, { method: art, + headers: { Cookie: keks, "Content-Type": "application/json", Origin: BASIS }, + body: koerper ? JSON.stringify(koerper) : undefined }); + let daten = null; try { daten = await a.json(); } catch { /* leer ist erlaubt */ } + return { status: a.status, daten }; +}; +const WEG = "/workspace/api/creator-anlegen"; + +/* ======================================================================= + 1. Der Alltagsfall + ======================================================================= */ +console.log("=== Ein Manager legt einen Creator an ==="); +let neuerId = null; +{ + const { status, daten } = await ruf("POST", WEG, keksMax, { name: "Nova" }); + ok(status === 201, `Max legt "Nova" an (HTTP ${status})`); + ok(daten?.rolle === "creator", `sie ist ein Creator (${daten?.rolle})`); + ok(typeof daten?.code === "string" && daten.code.length >= 8, + `der Zugangscode kommt genau einmal mit (${daten?.code ? daten.code.length + " Zeichen" : "fehlt"})`); + neuerId = daten?.id ?? null; + + const d = new DatabaseSync(DB, { readOnly: true }); + const b = d.prepare("SELECT betreuer_id FROM betreuung WHERE creator_id = ?").get(neuerId); + d.close(); + ok(b?.betreuer_id === idMax, + `und gehoert sofort Max (Betreuung: ${b?.betreuer_id ?? "keine"})`); +} +{ + /* DER PUNKT DAHINTER: Ein Creator ohne Betreuung ist fuer alle ausser + DogFather unsichtbar. Ohne die Zuteilung haette Max ihn angelegt und + danach nicht mehr gesehen -- ein Fehler, den man erst beim Suchen + bemerkt. */ + const { daten } = await ruf("GET", "/workspace/api/personen", keksMax); + const namen = (daten?.personen || []).map((p) => p.name); + ok(namen.includes("Nova"), `Max sieht sie danach auch (${namen.join(", ")})`); +} +{ + const { daten } = await ruf("GET", "/workspace/api/personen", keksMia); + const namen = (daten?.personen || []).map((p) => p.name); + ok(!namen.includes("Nova"), + `die ANDERE Managerin sieht sie NICHT (${namen.join(", ")})`); +} + +/* ======================================================================= + 2. Was nicht geht + ======================================================================= */ +console.log("\n=== Was ein Manager NICHT kann ==="); +{ + /* Die Rolle steht nicht im Aufruf -- also kann sie auch niemand + setzen. Geprueft wird trotzdem, was passiert, wenn es jemand + versucht: Das Feld muss WIRKUNGSLOS sein, nicht bloss abgelehnt. */ + const { status, daten } = await ruf("POST", WEG, keksMax, { name: "Versuch1", rolle: "admin" }); + ok(status === 201, `ein mitgeschicktes Feld "rolle" bricht nichts ab (HTTP ${status})`); + ok(daten?.rolle === "creator", + `es bleibt wirkungslos -- angelegt wurde ein ${daten?.rolle}`); +} +{ + const { status } = await ruf("GET", "/workspace/api/verwaltung/personen", keksMax); + ok(status === 404, `die Personenverwaltung bleibt fuer Max zu (HTTP ${status})`); +} +{ + const { status } = await ruf("POST", "/workspace/api/verwaltung/personen", keksMax, + { name: "Schleichweg", rolle: "creator" }); + ok(status === 404, `auch schreibend (HTTP ${status})`); +} +{ + const { status } = await ruf("POST", `/workspace/api/verwaltung/personen/${idCreator}/code`, keksMax); + ok(status === 404, `einen fremden Code neu setzen geht nicht (HTTP ${status})`); +} +{ + const { status } = await ruf("POST", WEG, keksScout, { name: "VonScout" }); + ok(status === 404, `ein Scout kann diesen Weg nicht benutzen (HTTP ${status})`); +} +{ + const { status } = await ruf("POST", WEG, keksLuna, { name: "VonCreator" }); + ok(status === 404, `ein Creator auch nicht (HTTP ${status})`); +} +{ + const { status } = await ruf("POST", WEG, keksMax, { name: "A" }); + ok(status === 400, `ein zu kurzer Name wird abgelehnt (HTTP ${status})`); +} +{ + const { status } = await ruf("POST", WEG, keksMax, { name: "Nova" }); + ok(status === 409, `ein doppelter Name wird abgelehnt (HTTP ${status})`); +} + +/* ======================================================================= + 3. Die Zuteilung -- der eigentliche Prüfstein + ======================================================================= */ +console.log("\n=== Zugeteilt wird nur der EIGENE Scout ==="); +{ + const { status, daten } = await ruf("POST", WEG, keksMax, + { name: "MitEigenemScout", betreuer_id: idScoutMax }); + ok(status === 201, `Max gibt seinen eigenen Scout mit (HTTP ${status})`); + const d = new DatabaseSync(DB, { readOnly: true }); + const b = d.prepare("SELECT betreuer_id FROM betreuung WHERE creator_id = ?").get(daten?.id); + d.close(); + ok(b?.betreuer_id === idScoutMax, `und der steht auch da (${b?.betreuer_id})`); +} +{ + /* DER FALL, AUF DEN ES ANKOMMT. Eine Zahl im Aufruf darf keine + Zuordnung erzeugen, die es nicht geben soll. */ + const { status } = await ruf("POST", WEG, keksMax, + { name: "MitFremdemScout", betreuer_id: idScoutMia }); + ok(status === 403, `Mias Scout kann Max NICHT zuteilen (HTTP ${status})`); + const d = new DatabaseSync(DB, { readOnly: true }); + const z = d.prepare("SELECT 1 FROM personen WHERE name = 'MitFremdemScout'").get(); + d.close(); + ok(!z, "und der Creator wurde dabei GAR NICHT ERST angelegt"); +} +{ + /* Stillschweigend auf sich selbst zurueckzufallen waere schlimmer als + die Absage: Der Manager glaubte dann, er haette zugeteilt. */ + const { status } = await ruf("POST", WEG, keksMax, { name: "MitUnfug", betreuer_id: 99999 }); + ok(status === 403, `eine erfundene Nummer wird abgelehnt statt ignoriert (HTTP ${status})`); +} +{ + const { daten } = await ruf("GET", WEG + "/scouts", keksMax); + const namen = (daten?.scouts || []).map((s) => s.name); + ok(namen.length === 1 && namen[0] === "SamVonMax", + `die angebotene Liste enthaelt genau seinen Scout (${namen.join(", ") || "leer"})`); +} +{ + /* GEGENPROBE zur Liste: Sie muss fuer die andere Managerin ANDERS + aussehen. Waere sie fuer alle gleich, pruefte die Zeile darueber + nur, dass ueberhaupt etwas zurueckkommt. */ + const { daten } = await ruf("GET", WEG + "/scouts", keksMia); + const namen = (daten?.scouts || []).map((s) => s.name); + ok(namen.length === 1 && namen[0] === "TimVonMia", + `und fuer Mia eine andere (${namen.join(", ") || "leer"})`); +} +{ + const { status } = await ruf("GET", WEG + "/scouts", keksScout); + ok(status === 404, `ein Scout bekommt die Liste nicht (HTTP ${status})`); +} + +/* ======================================================================= + 4. DogFather kann weiterhin alles + ======================================================================= */ +console.log("\n=== DogFather bleibt unangetastet ==="); +{ + const { status, daten } = await ruf("POST", WEG, keksDogi, + { name: "VonDogi", betreuer_id: idScoutMia }); + ok(status === 201, `er darf jeden Scout zuteilen, auch Mias (HTTP ${status})`); + ok(daten?.betreuer_id === idScoutMia, `und es wirkt (${daten?.betreuer_id})`); +} +{ + const { status } = await ruf("GET", "/workspace/api/verwaltung/personen", keksDogi); + ok(status === 200, `die Personenverwaltung steht ihm weiter offen (HTTP ${status})`); +} + +/* ======================================================================= + 5. Der Knopf auf der Seite + ======================================================================= */ +console.log("\n=== Der Knopf auf dem Dashboard ==="); +{ + const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await pw.chromium.launch(); + const sichtbarFuer = async (rolle, code) => { + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const seite = await ctx.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + await seite.goto(BASIS + "/workspace/uebersicht.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1400); + const da = await seite.$eval("#creator-neu", (e) => !e.hidden).catch(() => null); + await ctx.close(); + return da; + }; + ok(await sichtbarFuer("manager", "CODE-MAX-00001") === true, "ein Manager sieht ihn"); + ok(await sichtbarFuer("admin", "CODE-DOGI-0001") === true, "DogFather auch"); + /* Ein Scout kommt auf dieselbe Seite -- der Knopf muss dort fehlen. + Das ist keine Sicherung (die steht im Server), sondern die Zusage, + dass niemand einen Knopf sieht, der nur eine Absage bringt. */ + ok(await sichtbarFuer("scout", "CODE-SAM-00001") === false, "ein Scout nicht"); + await browser.close(); +} + +kind.kill(); +await new Promise((r) => setTimeout(r, 400)); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Zugabe */ } + +console.log(`\n${gemacht} Pruefungen`); +console.log(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-personen.js b/server/workspace-personen.js index 470cf7a0..dd32a3d7 100644 --- a/server/workspace-personen.js +++ b/server/workspace-personen.js @@ -275,6 +275,117 @@ personenRouter.put("/workspace/api/verwaltung/scout-zuteilung/:id", gleicheHerku } }); +/* ======================================================================= + EIN MANAGER LEGT EINEN CREATOR AN (07.09.2026) + + Wunsch Filipe: "ich will auch dass manager ab jetzt creator und auch + wirklich nur creator hinzufuegen koennen auf die seite. die creator + sollen ... auch nur der person hinzugefuegt werden wo dan diesen + creator hinzufuegt oder den scouts von diesem manager." + + ----------------------------------------------------------------------- + WARUM DAS EIN EIGENER WEG IST UND NICHT DIE ALTE TUER + + Alles unter `/workspace/api/verwaltung` haengt an EINER Schranke: + `nurAdmin`. Dahinter liegen acht Wege -- Rollen aendern, Codes neu + setzen, sperren, loeschen, das Protokoll lesen. Einen Manager dort + hineinzulassen und danach in jedem der acht Wege einzeln zu pruefen, + was er darf, ist genau die Bauweise, durch die am 31.08.2026 schon + einmal ein Loch entstanden ist: Zwei von drei Stellen waren + abgesichert, die dritte vergessen. + + Deshalb bleibt die Tuer zu, und daneben steht eine neue mit genau + EINEM Zweck. Sie kann nichts anderes, als einen Creator anzulegen -- + nicht weil eine Abfrage es verbietet, sondern weil es hier gar keinen + anderen Weg gibt. Das ist der Unterschied zwischen "darf nicht" und + "kann nicht". + + DIE ROLLE STEHT NICHT IM AUFRUF. Sie wird hier gesetzt. Ein Feld + `rolle` im Koerper waere die naheliegende Loesung und die falsche: + Dann muesste eine Abfrage sie pruefen, und eine vergessene Abfrage + ist ein zweiter Zugang mit vollen Rechten. + + DIE ZUTEILUNG PASSIERT SOFORT UND HIER. Ein Creator ohne Betreuung + ist fuer alle ausser DogFather unsichtbar -- der Manager haette ihn + angelegt und danach nicht mehr gesehen. Wer anlegt, betreut; ein Scout + des Managers kann gleich mitgegeben werden ("oder den scouts von + diesem manager"). + ======================================================================= */ +function nurLeitung(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + if (!istLeitung(person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + req.person = person; + next(); +} + +personenRouter.post("/workspace/api/creator-anlegen", gleicheHerkunft, nurLeitung, (req, res) => { + try { + const name = String(req.body?.name ?? "").trim(); + 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." }); + + const vorhanden = db().prepare( + "SELECT 1 FROM personen WHERE lower(name) = lower(?) AND aktiv = 1").get(name); + if (vorhanden) return res.status(409).json({ fehler: "Diesen Namen gibt es schon." }); + + /* Wem der neue Creator gehoert. Vorgabe: dem, der ihn anlegt. + Ein Manager darf stattdessen einen SEINER Scouts angeben -- und + nur einen seiner eigenen. Wer eine fremde Nummer hineinschreibt, + bekommt sie nicht, sondern eine Absage: Stillschweigend auf die + Vorgabe zurueckzufallen waere schlimmer, weil der Manager dann + glaubt, er haette zugeteilt. */ + let betreuerId = req.person.id; + const gewuenscht = req.body?.betreuer_id; + if (gewuenscht !== undefined && gewuenscht !== null && gewuenscht !== "") { + const z = Number(gewuenscht); + if (!Number.isInteger(z) || z < 1) return res.status(400).json({ fehler: "Ungültige Zuordnung." }); + if (z !== req.person.id) { + const erlaubt = istDogFather(req.person) + ? db().prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1 AND rolle IN ('admin','manager','scout')").get(z) + : db().prepare(`SELECT 1 FROM personen p + JOIN scout_zuteilung s ON s.scout_id = p.id + WHERE p.id = ? AND p.aktiv = 1 AND p.rolle = 'scout' + AND s.manager_id = ?`).get(z, req.person.id); + if (!erlaubt) { + return res.status(403).json({ + fehler: "Diesen Scout betreust du nicht – zuteilen kannst du nur deine eigenen.", + }); + } + } + betreuerId = z; + } + + const neu = personAnlegen(name, "creator", { ...req.person, ip: echteIp(req) }); + betreuungSetzen(neu.id, betreuerId, { ...req.person, ip: echteIp(req) }); + + /* Der Code steht NUR hier in der Antwort. Danach ist er weg. */ + res.status(201).json({ id: neu.id, name: neu.name, rolle: neu.rolle, code: neu.code, + betreuer_id: betreuerId }); + } catch (fehler) { + console.error("[workspace] Creator anlegen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* Welche Scouts kann ich einem neuen Creator gleich mitgeben? + Dieselbe Regel wie oben, nur lesend -- damit die Oberflaeche genau + die Liste anbietet, die der Server auch annimmt. Zwei getrennte + Listen waeren die naechste Stelle, an der beide auseinanderlaufen. */ +personenRouter.get("/workspace/api/creator-anlegen/scouts", nurLeitung, (req, res) => { + try { + const zeilen = istDogFather(req.person) + ? db().prepare("SELECT id, name FROM personen WHERE aktiv = 1 AND rolle = 'scout' ORDER BY name").all() + : db().prepare(`SELECT p.id, p.name FROM personen p + JOIN scout_zuteilung s ON s.scout_id = p.id + WHERE p.aktiv = 1 AND p.rolle = 'scout' AND s.manager_id = ? + ORDER BY p.name`).all(req.person.id); + res.json({ scouts: zeilen }); + } catch { + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /* ---------- Anlegen ----------------------------------------------------- */ personenRouter.post("/workspace/api/verwaltung/personen", gleicheHerkunft, (req, res) => { diff --git a/workspace/assets/css/aufgaben.css b/workspace/assets/css/aufgaben.css index b26bbadf..414d12c7 100644 --- a/workspace/assets/css/aufgaben.css +++ b/workspace/assets/css/aufgaben.css @@ -813,3 +813,11 @@ .abgebrochen, .abbruch-karte { border: 1px solid CanvasText; } .abgebrochen__zahl, .abbruch-karte__vorher { border: 1px solid CanvasText; } } + +/* Hinweis unter einem Formularfeld -- erklaert das Feld, statt es + kommentarlos wegzunehmen. */ +.feld-hinweis { + margin: 5px 0 0; + font-size: .76rem; line-height: 1.45; + color: var(--text-still); +} diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index 9fb3fe82..e1ca6f61 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -382,13 +382,11 @@ overflow-wrap: anywhere; } -/* Hinweis unter einem Formularfeld -- erklaert das Feld, statt es - kommentarlos wegzunehmen. */ -.feld-hinweis { - margin: 5px 0 0; - font-size: .76rem; line-height: 1.45; - color: var(--text-still); -} +/* .feld-hinweis steht seit dem 07.09.2026 in aufgaben.css -- bei den + uebrigen Formularstilen. Grund: Die Dashboard-Seite braucht ihn jetzt + auch (Creator anlegen), laedt aber diese Datei hier nicht. Ein + Formularbaustein, der in der Bereichsdatei liegt, ist nur so lange + richtig, wie ihn keine zweite Seite braucht. */ @media (max-width: 560px) { /* Auf dem Handy untereinander: Marke und Spanne nebeneinander diff --git a/workspace/assets/css/uebersicht.css b/workspace/assets/css/uebersicht.css index 163893d5..ec5d2d81 100644 --- a/workspace/assets/css/uebersicht.css +++ b/workspace/assets/css/uebersicht.css @@ -344,3 +344,23 @@ a.wert:hover { border-color: var(--rand-hell); background: rgba(255, 255, 255, . @media (prefers-reduced-motion: reduce) { .wert, .fortschritt, .fortschritt__fuellung { transition: none; } } + +/* Der einmalige Zugangscode. Gross, gesperrt, in gleicher Zeichenbreite + und zum Markieren freigegeben -- er wird abgeschrieben oder kopiert, + und beides muss auf Anhieb gelingen. `user-select: all` markiert ihn + mit einem Klick vollstaendig; ein halb markierter Code ist ein Anruf + am naechsten Tag. */ +.code-anzeige { + margin: 6px 0 0; + padding: 12px 14px; + border-radius: var(--radius-klein); + border: 1px solid color-mix(in srgb, var(--k-gold) 34%, transparent); + background: rgba(0, 0, 0, .34); + box-shadow: inset 0 2px 5px -2px rgba(0, 0, 0, .7); + color: #f0d9a8; + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 1.16rem; font-weight: 700; letter-spacing: .1em; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; + user-select: all; -webkit-user-select: all; +} diff --git a/workspace/assets/js/uebersicht.js b/workspace/assets/js/uebersicht.js index 6e2a7cb2..8a7154a2 100644 --- a/workspace/assets/js/uebersicht.js +++ b/workspace/assets/js/uebersicht.js @@ -284,6 +284,107 @@ $('fruehwarnung').hidden = false; } + /* =================================================================== + CREATOR ANLEGEN (07.09.2026) + + Wunsch Filipe: "manager sollen ab jetzt creator und auch wirklich + nur creator hinzufuegen koennen". + + Der Weg dahin ist ein EIGENER (POST /workspace/api/creator-anlegen) + und nicht die Personenverwaltung -- die bleibt DogFather + vorbehalten, dort liegen Rollen, Codes, Sperren und das Protokoll. + Dieser Weg kann nichts anderes, als einen Creator anzulegen; die + Rolle steht nicht einmal im Aufruf. + + DER KNOPF WIRD VERSTECKT, NICHT DIE ABSAGE ABGEFANGEN. Beides + zusammen: Der Server laesst Scouts und Creator ohnehin nicht durch + (404). Das Verstecken erspart ihnen nur einen Knopf, der eine + Absage bringt -- es IST nicht die Sicherung. + + DER CODE WIRD EINMAL GEZEIGT UND NIE NACHGELADEN. Er steht nur in + der Antwort auf das Anlegen; danach gibt es ihn nirgends mehr. + Deshalb bleibt der Dialog nach dem Anlegen OFFEN und schliesst sich + nicht von selbst -- ein Fenster, das sich mit dem einzigen + Zugangscode schliesst, bevor jemand ihn kopiert hat, ist ein + Datenverlust mit Ansage. */ + function creatorAnlegenBereit(ich) { + const knopf = document.getElementById('creator-neu'); + const dialog = document.getElementById('creator-dialog'); + if (!knopf || !dialog || !ich) return; + if (!['admin', 'manager'].includes(ich.rolle)) return; + knopf.hidden = false; + + const f = (id) => document.getElementById(id); + const zuruecksetzen = () => { + f('creator-form').reset(); + f('cn-code-zeile').hidden = true; + f('cn-fehler').textContent = ''; + f('cn-speichern').hidden = false; + f('cn-zu').textContent = 'Abbrechen'; + }; + + knopf.addEventListener('click', async () => { + zuruecksetzen(); + /* Die Scoutliste kommt vom Server -- und zwar dieselbe, die er + beim Anlegen auch annimmt. Eine im Browser zusammengestellte + Liste waere die zweite Wahrheit, und die beiden laufen + auseinander. */ + try { + const a = await hole('/workspace/api/creator-anlegen/scouts'); + const scouts = a.ok ? (await a.json()).scouts || [] : []; + const w = f('cn-scout'); + w.textContent = ''; + const eigen = document.createElement('option'); + eigen.value = ''; eigen.textContent = `${ich.name} (du)`; + w.append(eigen); + for (const sc of scouts) { + const o = document.createElement('option'); + o.value = String(sc.id); o.textContent = sc.name; + w.append(o); + } + /* Gibt es keinen Scout, gibt es auch nichts zu waehlen -- ein + Auswahlfeld mit genau einem Eintrag ist eine Frage ohne + Antwortmoeglichkeit. */ + f('cn-scout-zeile').hidden = scouts.length === 0; + } catch { f('cn-scout-zeile').hidden = true; } + dialog.showModal(); + f('cn-name').focus(); + }); + + f('cn-zu').addEventListener('click', () => dialog.close()); + + f('creator-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const name = f('cn-name').value.trim(); + if (name.length < 2) { f('cn-fehler').textContent = 'Der Name fehlt.'; return; } + const knopfS = f('cn-speichern'); + knopfS.disabled = true; + f('cn-fehler').textContent = ''; + try { + const a = await hole('/workspace/api/creator-anlegen', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, betreuer_id: f('cn-scout').value || null }), + }); + const antwort = await a.json().catch(() => ({})); + if (!a.ok) { + f('cn-fehler').textContent = antwort.fehler || 'Ging nicht.'; + return; + } + f('cn-code-name').textContent = antwort.name; + f('cn-code').textContent = antwort.code; + f('cn-code-zeile').hidden = false; + knopfS.hidden = true; + f('cn-zu').textContent = 'Fertig'; + /* Die Liste dahinter wird neu geladen, sobald der Dialog zugeht + -- der neue Creator soll da stehen, wo man ihn erwartet. */ + dialog.addEventListener('close', () => location.reload(), { once: true }); + } catch { + f('cn-fehler').textContent = 'Ging nicht.'; + } finally { knopfS.disabled = false; } + }); + } + (async () => { let ich; try { @@ -292,6 +393,7 @@ ich = await a.json(); } catch { return; } window.werZeigen?.(ich); + creatorAnlegenBereit(ich); let d; try { diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 8ecccd07..dd68ac89 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -9,9 +9,9 @@ - - - + + + @@ -211,10 +211,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 026c7bfc..d651c604 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -134,10 +134,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 5c7bafad..e8d2063d 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -143,14 +143,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index ee8939cc..aaa0df6d 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -76,11 +76,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index addf5087..f1f87bd1 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -9,16 +9,16 @@ - - + + - - + + @@ -154,9 +154,9 @@ - - - - + + + + diff --git a/workspace/content.html b/workspace/content.html index 1359f6f6..db06f495 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -166,12 +166,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index d4cee6bc..d67f4acc 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -95,10 +95,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 4581094d..53c8283a 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -27,7 +27,7 @@ - + @@ -186,6 +186,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 57f1fc41..c0b734a9 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -257,11 +257,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 882d2422..83bc7786 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -211,10 +211,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 82164dc0..786f1aef 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -123,10 +123,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index c2aa4e0b..a63850be 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -201,11 +201,11 @@ - - - - - - + + + + + + diff --git a/workspace/report.html b/workspace/report.html index d66c20d8..58318070 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -93,11 +93,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 3406671c..0ca6e698 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -126,10 +126,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 27860a44..d2e1c8ca 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -9,11 +9,11 @@ - - + + - + @@ -221,10 +221,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 45719e07..62a23a95 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -72,11 +72,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 2e493fc5..853a8947 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -113,10 +113,10 @@ - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index a2b1d857..37c56112 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -9,9 +9,12 @@ - - - + + + + + @@ -45,10 +48,61 @@ Eine Karte je Creator – mit einem Blick sehen, wo es hakt und wo es läuft.

+ + + + +
+

Creator anlegen

+

+ Der Zugangscode erscheint genau einmal – danach ist er weg und + kann nur neu gesetzt werden. +

+ +
+ + +
+ + + + + + + + +
+ + +
+
+
+