From 8bfd2ae1669f2803bf6e1c45f3c6d21bd53d1ed7 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Fri, 28 Aug 2026 00:17:53 +0200 Subject: [PATCH] Workspace: Creator-Profile (Onboarding aus dem Konzept) /workspace/profil.html -- Stammdaten, Ziele und 90-Tage-Plan, genau nach Seite 5 des Konzepts. Management waehlt oben den Creator aus, ein Creator sieht nur sein eigenes Profil. Sicherheitskern ist das Feld admin_notiz. Das Konzept fordert "private Admin-Notizen separat". Die Notiz wird deshalb nicht im Browser ausgeblendet, sondern gar nicht erst gesendet: Die Spaltenliste der Abfrage haengt an der Rolle (FELDER_OFFEN / FELDER_ADMIN). Dasselbe gilt fuer Plan-Start und Review-Termin. Geprueft: - In der kompletten Rohantwort an den Creator kommt der Inhalt der internen Notiz 0-mal vor - Creator auf fremdes Profil: 404 (lesend wie schreibend) - Scout auf ein Profil: 404, profil.html leitet ihn weg - Creator setzt admin_notiz selbst: wird stillschweigend ignoriert, der Inhalt bleibt unveraendert - Profil einer Nicht-Creator-Person: 404 Dabei ist ein aelterer Fehler aufgefallen: /api/ich lieferte nur Name und Rolle, nicht die eigene Nummer. Dadurch rief die Profilseite eines Creators /api/profil/undefined auf und blieb leer. Derselbe Fehler machte in der Personenverwaltung den Selbstvergleich unwirksam -- beim eigenen Eintrag erschien ein "Sperren"-Knopf, den der Server dann ablehnte. /api/ich liefert jetzt zusaetzlich die id. Im Protokoll landen nur die Feldnamen, nie die Inhalte: Im Profil stehen persoenliche Angaben, die nicht zusaetzlich im Audit-Log auftauchen sollen. --- server/index.js | 2 + server/workspace-profil.js | 177 ++++++++++++++++++++++++++++++++ server/workspace.js | 31 +++++- workspace/assets/css/profil.css | 67 ++++++++++++ workspace/assets/js/profil.js | 130 +++++++++++++++++++++++ workspace/assets/js/start.js | 4 +- workspace/profil.html | 126 +++++++++++++++++++++++ 7 files changed, 534 insertions(+), 3 deletions(-) create mode 100644 server/workspace-profil.js create mode 100644 workspace/assets/css/profil.css create mode 100644 workspace/assets/js/profil.js create mode 100644 workspace/profil.html diff --git a/server/index.js b/server/index.js index c1767af4..898ba903 100644 --- a/server/index.js +++ b/server/index.js @@ -16,6 +16,7 @@ import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js"; import { workspaceRouter } from "./workspace.js"; import { aufgabenRouter } from "./workspace-aufgaben.js"; import { personenRouter } from "./workspace-personen.js"; +import { profilRouter } from "./workspace-profil.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -72,6 +73,7 @@ app.use(webdesignGate); app.use(workspaceRouter); app.use(aufgabenRouter); app.use(personenRouter); +app.use(profilRouter); /* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen). SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert diff --git a/server/workspace-profil.js b/server/workspace-profil.js new file mode 100644 index 00000000..b86990a4 --- /dev/null +++ b/server/workspace-profil.js @@ -0,0 +1,177 @@ +/* ===================================================================== + workspace-profil.js — Creator-Profile (Onboarding-Teil des Konzepts): + Stammdaten, Ziele, 90-Tage-Plan. + + Sicherheitskern dieses Moduls ist das Feld `admin_notiz`. Das Konzept + fordert "private Admin-Notizen separat" -- diese Notiz darf ein Creator + unter keinen Umständen sehen, auch nicht als Nebeneffekt einer + Sammelabfrage. Deshalb wird sie nicht nur im Browser ausgeblendet, + sondern gar nicht erst mitgeschickt: Die Spaltenliste der Abfrage + haengt an der Rolle (siehe FELDER_OFFEN / FELDER_ADMIN). + ===================================================================== */ + +import express from "express"; +import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; + +export const profilRouter = express.Router(); + +/* Felder, die auch der Creator selbst sehen und ändern darf. */ +const FELDER_OFFEN = [ + "handles", "nische", "live_zeiten", "technik", + "ziel_live", "ziel_content", "ziel_community", "ziel_technik", + "plan_prio1", "plan_prio2", "plan_prio3", +]; +/* Nur fürs Management. `admin_notiz` ist der eigentliche Grund für die + Trennung, die Termine stehen hier, weil sie zur Steuerung gehören. */ +const FELDER_ADMIN = ["plan_start", "naechster_review", "admin_notiz"]; + +const DATUMSFELDER = new Set(["plan_start", "naechster_review"]); +const TEXT_MAX = 2000; + +const jetzt = () => new Date().toISOString(); + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +profilRouter.use("/workspace/api/profil", angemeldet); + +/* Wer darf welches Profil? Scouts gar keins -- im Konzept haben sie mit + der Creator-Betreuung nichts zu tun. */ +function darf(person, zielId) { + if (person.rolle === "admin") return "voll"; + if (person.rolle === "creator" && person.id === zielId) return "eigen"; + return null; +} + +/* ---------- Lesen ------------------------------------------------------- */ + +profilRouter.get("/workspace/api/profil/:id", (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const recht = darf(req.person, id); + /* 404 statt 403: Wer kein Recht hat, soll nicht erfahren, ob es die + Person überhaupt gibt. */ + if (!recht) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const person = db().prepare( + "SELECT id, name, rolle, aktiv, erstellt, letzter_login FROM personen WHERE id = ?").get(id); + if (!person || person.rolle !== "creator") { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + + const felder = recht === "voll" ? [...FELDER_OFFEN, ...FELDER_ADMIN] : FELDER_OFFEN; + const reihe = db().prepare( + `SELECT ${felder.join(", ")}, geaendert FROM profile WHERE person_id = ?`).get(id); + + res.json({ + person, + /* Kein Profil angelegt = leeres Objekt, kein Fehler. */ + profil: reihe || Object.fromEntries(felder.map((f) => [f, null])), + darf_alles: recht === "voll", + }); + } catch (fehler) { + console.error("[workspace] Profil lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* Liste aller Creator -- fürs Management, zur Auswahl. */ +profilRouter.get("/workspace/api/profil", (req, res) => { + try { + if (req.person.rolle === "creator") { + return res.json({ creator: [{ id: req.person.id, name: req.person.name }], eigen: true }); + } + if (req.person.rolle !== "admin") return res.status(404).json({ fehler: "nicht_gefunden" }); + res.json({ + creator: db().prepare(` + SELECT p.id, p.name, p.aktiv, p.letzter_login, + (SELECT COUNT(*) FROM aufgaben a + WHERE a.creator_id = p.id AND a.status <> 'erledigt') AS offene_aufgaben, + (SELECT naechster_review FROM profile f WHERE f.person_id = p.id) AS naechster_review, + (SELECT geaendert FROM profile f WHERE f.person_id = p.id) AS profil_geaendert + FROM personen p WHERE p.rolle = 'creator' + ORDER BY p.aktiv DESC, p.name`).all(), + eigen: false, + }); + } catch (fehler) { + console.error("[workspace] Creator-Liste:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Speichern ---------------------------------------------------- */ + +profilRouter.put("/workspace/api/profil/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const recht = darf(req.person, id); + if (!recht) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const person = db().prepare("SELECT id, rolle FROM personen WHERE id = ?").get(id); + if (!person || person.rolle !== "creator") return res.status(404).json({ fehler: "nicht_gefunden" }); + + /* Ein Creator kann die Verwaltungsfelder nicht setzen -- sie stehen + gar nicht erst in der Liste der zulässigen Felder. Was er trotzdem + mitschickt, wird stillschweigend ignoriert. */ + const erlaubt = recht === "voll" ? [...FELDER_OFFEN, ...FELDER_ADMIN] : FELDER_OFFEN; + + const werte = {}; + for (const feld of erlaubt) { + if (req.body?.[feld] === undefined) continue; + let wert = req.body[feld]; + if (wert === null) { werte[feld] = null; continue; } + wert = String(wert).trim(); + if (!wert) { werte[feld] = null; continue; } + if (DATUMSFELDER.has(feld)) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(wert) || Number.isNaN(Date.parse(wert))) { + return res.status(400).json({ fehler: `Kein gültiges Datum: ${feld}` }); + } + } else if (wert.length > TEXT_MAX) { + return res.status(400).json({ fehler: "Ein Feld ist zu lang." }); + } + werte[feld] = wert; + } + if (!Object.keys(werte).length) return res.status(400).json({ fehler: "nichts_zu_speichern" }); + + const spalten = Object.keys(werte); + /* UPSERT: Beim ersten Speichern entsteht die Zeile, danach wird sie + aktualisiert -- ohne vorher prüfen zu müssen, ob es sie gibt. */ + db().prepare(` + INSERT INTO profile (person_id, ${spalten.join(", ")}, geaendert, geaendert_von) + VALUES (?, ${spalten.map(() => "?").join(", ")}, ?, ?) + ON CONFLICT(person_id) DO UPDATE SET + ${spalten.map((s) => `${s} = excluded.${s}`).join(", ")}, + geaendert = excluded.geaendert, + geaendert_von = excluded.geaendert_von + `).run(id, ...spalten.map((s) => werte[s]), jetzt(), req.person.id); + + protokolliere("profil_gespeichert", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + /* Nur die Feldnamen, nie die Inhalte -- im Profil stehen persönliche + Angaben, die nicht zusätzlich im Protokoll landen sollen. */ + detail: `Creator #${id}: ${spalten.join(",")}`.slice(0, 120), + }); + + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Profil speichern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index 7063db02..7f6ea80a 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -125,6 +125,30 @@ export function db() { CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status); CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id); + /* Creator-Profil (Onboarding aus dem Konzept). Eine Zeile je + Creator, entsteht erst beim ersten Speichern. + admin_notiz ist bewusst Teil dieser Tabelle, wird aber nur an + das Management ausgeliefert -- siehe workspace-profil.js. */ + CREATE TABLE IF NOT EXISTS profile ( + person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE, + handles TEXT, + nische TEXT, + live_zeiten TEXT, + technik TEXT, + ziel_live TEXT, + ziel_content TEXT, + ziel_community TEXT, + ziel_technik TEXT, + plan_start TEXT, + plan_prio1 TEXT, + plan_prio2 TEXT, + plan_prio3 TEXT, + naechster_review TEXT, + admin_notiz TEXT, + geaendert TEXT, + geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL + ); + CREATE TABLE IF NOT EXISTS versuche ( ip TEXT NOT NULL, zeitpunkt TEXT NOT NULL @@ -257,6 +281,7 @@ const GESCHUETZT = { "/workspace/start.html": null, "/workspace/aufgaben.html": null, "/workspace/personen.html": ["admin"], + "/workspace/profil.html": ["admin", "creator"], }; workspaceRouter.use((req, res, next) => { @@ -335,7 +360,11 @@ workspaceRouter.post("/workspace/api/abmelden", (req, res) => { workspaceRouter.get("/workspace/api/ich", (req, res) => { const person = sitzungLesen(req); if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); - res.json({ name: person.name, rolle: person.rolle }); + /* Die eigene Nummer gehört mit dazu: Ohne sie kann die Oberfläche nicht + erkennen, welcher Eintrag der eigene ist (etwa "das bin ich" in der + Personenliste), und das eigene Profil liesse sich gar nicht aufrufen. + Ein Geheimnis ist sie nicht -- sie beschreibt nur den Angemeldeten. */ + res.json({ id: person.id, name: person.name, rolle: person.rolle }); }); /* ---------- Verwaltung (nur über die Kommandozeile) -------------------- diff --git a/workspace/assets/css/profil.css b/workspace/assets/css/profil.css new file mode 100644 index 00000000..16a2490d --- /dev/null +++ b/workspace/assets/css/profil.css @@ -0,0 +1,67 @@ +/* =================================================================== + Creator-Profil (Onboarding). + =================================================================== */ + +.gruppe { + margin: 0 0 22px; + padding: 20px 22px 22px; + background: rgba(255, 255, 255, .03); + border: 1px solid var(--rand); + border-radius: var(--radius); +} + +.gruppe__titel { + padding: 0 6px; + font-size: .96rem; font-weight: 700; + color: var(--text); +} +.gruppe__text { margin: 2px 0 18px; font-size: .86rem; color: var(--text-still); } + +.gruppe .feld-schild { margin-top: 14px; } +.gruppe .feld-schild:first-of-type { margin-top: 0; } + +.gruppe textarea, .gruppe input { + width: 100%; padding: 10px 12px; + background: rgba(255, 255, 255, .045); + border: 1px solid var(--rand); + border-radius: var(--radius-klein); + color: var(--text); font: inherit; + transition: border-color var(--tempo), box-shadow var(--tempo); +} +.gruppe textarea { resize: vertical; min-height: 46px; } +.gruppe textarea:focus, .gruppe input:focus { + outline: none; border-color: rgba(63, 189, 245, .7); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .16); +} +.gruppe input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(.75); cursor: pointer; } + +.gruppe__raster { + display: grid; gap: 14px; margin-top: 16px; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} + +/* Die interne Notiz hebt sich deutlich ab, damit im Gespräch mit dem + Creator niemand versehentlich hineinschreibt, was er lesen soll. */ +.gruppe--intern { + border-color: rgba(224, 163, 60, .38); + background: rgba(224, 163, 60, .05); +} +.gruppe--intern .gruppe__titel { color: #e6b45c; } + +#auswahl-block select { + min-width: 210px; padding: 10px 12px; + background: rgba(255, 255, 255, .045); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; +} + +.speicher-leiste { + position: sticky; bottom: 0; + display: flex; align-items: center; gap: 14px; + margin-top: 26px; padding: 14px 0; + background: linear-gradient(180deg, transparent, var(--tinte) 34%); +} + +@media (max-width: 560px) { + .gruppe { padding: 16px 15px 18px; } +} diff --git a/workspace/assets/js/profil.js b/workspace/assets/js/profil.js new file mode 100644 index 00000000..cf612ad8 --- /dev/null +++ b/workspace/assets/js/profil.js @@ -0,0 +1,130 @@ +/* =================================================================== + Creator-Profil. + + Welche Felder überhaupt ankommen, entscheidet der Server anhand der + Rolle -- `admin_notiz` fehlt in der Antwort an einen Creator komplett. + Diese Datei blendet deshalb nur aus, was ohnehin nicht da ist. + =================================================================== */ +(() => { + 'use strict'; + + const OFFEN = ['handles', 'nische', 'live_zeiten', 'technik', + 'ziel_live', 'ziel_content', 'ziel_community', 'ziel_technik', + 'plan_prio1', 'plan_prio2', 'plan_prio3']; + const NUR_ADMIN = ['plan_start', 'naechster_review', 'admin_notiz']; + + const $ = (id) => document.getElementById(id); + let ich = null; + let aktuell = null; + let darfAlles = false; + + const melde = (t) => { $('fehler').textContent = t || ''; }; + + async function hole(pfad, optionen = {}) { + const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + return a; + } + + function fuelle(profil) { + for (const f of [...OFFEN, ...NUR_ADMIN]) { + const feld = $(f); + if (!feld) continue; + /* undefined heißt: Der Server hat das Feld nicht geschickt (fehlende + Berechtigung). Dann bleibt es leer und wird auch nicht gesendet. */ + feld.value = profil[f] ?? ''; + } + } + + function datumHuebsch(iso) { + return iso.slice(0, 10).split('-').reverse().join('.'); + } + + async function profilLaden(id) { + melde(''); + try { + const a = await hole('/workspace/api/profil/' + id); + if (!a.ok) { melde('Profil konnte nicht geladen werden.'); return; } + const daten = await a.json(); + aktuell = daten.person.id; + darfAlles = daten.darf_alles; + + $('titel').textContent = ich.rolle === 'admin' + ? 'Profil: ' + daten.person.name + : 'Mein Profil'; + $('unterzeile').textContent = daten.profil.geaendert + ? 'Zuletzt geändert am ' + datumHuebsch(daten.profil.geaendert) + : 'Noch nichts eingetragen.'; + + $('gruppe-intern').hidden = !darfAlles; + $('termine').hidden = !darfAlles; + fuelle(daten.profil); + $('formular').hidden = false; + } catch { /* umgeleitet */ } + } + + $('formular').addEventListener('submit', async (e) => { + e.preventDefault(); + melde(''); + const knopf = $('speichern'); + const daten = {}; + for (const f of darfAlles ? [...OFFEN, ...NUR_ADMIN] : OFFEN) { + const feld = $(f); + if (feld) daten[f] = feld.value; + } + knopf.disabled = true; + $('stand').textContent = 'Speichere …'; + try { + const a = await hole('/workspace/api/profil/' + aktuell, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(daten), + }); + if (!a.ok) { + melde((await a.json().catch(() => ({}))).fehler || 'Speichern ging nicht.'); + $('stand').textContent = ''; + return; + } + $('stand').textContent = 'Gespeichert ✓'; + setTimeout(() => { $('stand').textContent = ''; }, 2600); + } catch { /* umgeleitet */ } finally { knopf.disabled = false; } + }); + + $('abmelden').addEventListener('click', async () => { + try { await fetch('/workspace/api/abmelden', { method: 'POST', credentials: 'same-origin' }); } + catch { /* egal */ } + location.assign('/workspace/'); + }); + + (async () => { + try { + const a = await hole('/workspace/api/ich'); + if (!a.ok) { location.assign('/workspace/'); return; } + ich = await a.json(); + } catch { return; } + if (ich.rolle === 'scout') { location.assign('/workspace/start.html'); return; } + $('wer').textContent = ich.name + ' · ' + ich.rolle; + + let liste; + try { liste = await (await hole('/workspace/api/profil')).json(); } catch { return; } + + if (ich.rolle === 'admin') { + if (!liste.creator.length) { + $('unterzeile').textContent = + 'Es gibt noch keinen Creator. Lege zuerst unter Personen & Zugänge einen an.'; + return; + } + $('auswahl-block').hidden = false; + for (const c of liste.creator) { + const o = document.createElement('option'); + o.value = String(c.id); + o.textContent = c.name + (c.aktiv ? '' : ' (gesperrt)'); + $('auswahl').append(o); + } + $('auswahl').addEventListener('change', () => profilLaden(Number($('auswahl').value))); + await profilLaden(liste.creator[0].id); + } else { + await profilLaden(ich.id); + } + })(); +})(); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index 58d3cc4a..705943d8 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -18,8 +18,8 @@ const BEREICHE = [ { name: 'Dashboard', phase: 1, rollen: ['admin', 'creator', 'scout'] }, { name: 'Personen & Zugänge', phase: 1, rollen: ['admin'], ziel: 'personen.html' }, - { name: 'Creator-Profile', phase: 1, rollen: ['admin'] }, - { name: 'Mein Profil', phase: 1, rollen: ['creator'] }, + { name: 'Creator-Profile', phase: 1, rollen: ['admin'], ziel: 'profil.html' }, + { name: 'Mein Profil', phase: 1, rollen: ['creator'], ziel: 'profil.html' }, { name: 'Aufgaben', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'aufgaben.html' }, { name: 'Kalender', phase: 1, rollen: ['admin', 'creator', 'scout'] }, { name: 'Dateien', phase: 1, rollen: ['admin', 'creator'] }, diff --git a/workspace/profil.html b/workspace/profil.html new file mode 100644 index 00000000..d3242dd9 --- /dev/null +++ b/workspace/profil.html @@ -0,0 +1,126 @@ + + + + + +Creator-Profil · Creator Workspace + + + + + + + + + + + +
+

Creator Workspace · Profil

+
+ … + +
+
+ +
+ +
+
+

Onboarding

+

Creator-Profil

+

…

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