diff --git a/server/index.js b/server/index.js index 806372e3..c1767af4 100644 --- a/server/index.js +++ b/server/index.js @@ -15,6 +15,7 @@ import { webdesignGate } from "./webdesign-gate.js"; import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js"; import { workspaceRouter } from "./workspace.js"; import { aufgabenRouter } from "./workspace-aufgaben.js"; +import { personenRouter } from "./workspace-personen.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -70,6 +71,7 @@ app.use(webdesignGate); selbst ab (Begründung im Kopf von workspace.js). */ app.use(workspaceRouter); app.use(aufgabenRouter); +app.use(personenRouter); /* 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-aufgaben.js b/server/workspace-aufgaben.js index feb34cd7..ab3bb879 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -261,3 +261,30 @@ aufgabenRouter.patch("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res) res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); + +/* ---------- Löschen ------------------------------------------------------ + Nur Management. Das Konzept will ausdrücklich, dass Erledigtes sichtbar + bleibt ("Erledigte Aufgaben verschwinden nicht") -- Löschen ist deshalb + der Ausnahmefall für Fehleinträge, nicht der normale Abschluss. Der + Titel wandert ins Protokoll, damit nachvollziehbar bleibt, was weg ist. */ + +aufgabenRouter.delete("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res) => { + try { + if (req.person.rolle !== "admin") return res.status(403).json({ fehler: "nicht_erlaubt" }); + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const aufgabe = db().prepare("SELECT id, titel FROM aufgaben WHERE id = ?").get(id); + if (!aufgabe) return res.status(404).json({ fehler: "nicht_gefunden" }); + + db().prepare("DELETE FROM aufgaben WHERE id = ?").run(id); + protokolliere("aufgabe_geloescht", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} ${aufgabe.titel}`.slice(0, 120), + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Aufgabe löschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace-personen.js b/server/workspace-personen.js new file mode 100644 index 00000000..3d7cc6fd --- /dev/null +++ b/server/workspace-personen.js @@ -0,0 +1,175 @@ +/* ===================================================================== + workspace-personen.js — Personen und Zugangscodes über den Browser + verwalten. Ausschließlich für die Rolle "admin". + + Bisher ging das nur per SSH über workspace-code.js. Das bleibt als + Notweg bestehen (etwa wenn niemand mehr hineinkommt), für den Alltag + ist es aber zu umständlich. + + Zum Anzeigen des Codes im Browser: Der Code wird genau einmal in der + Antwort auf das Anlegen zurückgegeben und danach nirgends gespeichert + -- in der Datenbank steht nur sein scrypt-Hash. Das ist dasselbe + Verfahren, das etwa GitHub für Zugriffstoken verwendet. Er läuft über + HTTPS und landet weder im Protokoll noch im Serverlog. + ===================================================================== */ + +import express from "express"; +import { + db, protokolliere, echteIp, sitzungLesen, personAnlegen, codeNeu, personSperren, +} from "./workspace.js"; + +export const personenRouter = express.Router(); + +const ROLLEN = ["admin", "creator", "scout"]; +const NAME_MAX = 60; + +/* Nur Management. Alles andere bekommt 404 statt 403 -- wer nicht + berechtigt ist, muss nicht erfahren, dass es diesen Bereich gibt. */ +function nurAdmin(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + if (person.rolle !== "admin") return res.status(404).json({ fehler: "nicht_gefunden" }); + 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(); +} + +personenRouter.use("/workspace/api/verwaltung", nurAdmin); + +/* ---------- Übersicht --------------------------------------------------- */ + +personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => { + try { + res.json({ + personen: db().prepare(` + SELECT p.id, p.name, p.rolle, p.aktiv, p.erstellt, p.letzter_login, + (SELECT COUNT(*) FROM sitzungen s WHERE s.person_id = p.id) AS sitzungen, + (SELECT COUNT(*) FROM aufgaben a + WHERE (a.creator_id = p.id OR a.verantwortlich_id = p.id) + AND a.status <> 'erledigt') AS offene_aufgaben + FROM personen p + ORDER BY p.aktiv DESC, p.rolle, p.name`).all(), + }); + } catch (fehler) { + console.error("[workspace] Personen lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Anlegen ----------------------------------------------------- */ + +personenRouter.post("/workspace/api/verwaltung/personen", gleicheHerkunft, (req, res) => { + try { + const name = String(req.body?.name ?? "").trim(); + const rolle = String(req.body?.rolle ?? ""); + 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 (!ROLLEN.includes(rolle)) return res.status(400).json({ fehler: "Unbekannte Rolle." }); + + 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." }); + + const neu = personAnlegen(name, rolle, { ...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 }); + } catch (fehler) { + console.error("[workspace] Person anlegen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Neuer Code -------------------------------------------------- */ + +personenRouter.post("/workspace/api/verwaltung/personen/:id/code", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + if (id === req.person.id) { + /* Sich selbst den Code zu tauschen wirft einen sofort hinaus (alle + Sitzungen werden beendet). Erlaubt, aber nur bewusst. */ + if (!req.body?.auch_mich) { + return res.status(400).json({ fehler: "eigener_code", hinweis: + "Das beendet deine eigene Sitzung sofort." }); + } + } + const neu = codeNeu(id, { ...req.person, ip: echteIp(req) }); + res.json({ id: neu.id, name: neu.name, rolle: neu.rolle, code: neu.code }); + } catch (fehler) { + if (/Keine Person/.test(fehler?.message || "")) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + console.error("[workspace] Code erneuern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Sperren / Entsperren ---------------------------------------- */ + +personenRouter.patch("/workspace/api/verwaltung/personen/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const person = db().prepare("SELECT id, name, rolle, aktiv FROM personen WHERE id = ?").get(id); + if (!person) return res.status(404).json({ fehler: "nicht_gefunden" }); + + if (req.body?.aktiv !== undefined) { + const aktiv = req.body.aktiv ? 1 : 0; + /* Sich selbst zu sperren würde den letzten Zugang zusperren können. + Deshalb gar nicht erst erlauben. */ + if (id === req.person.id && !aktiv) { + return res.status(400).json({ fehler: "Du kannst dich nicht selbst sperren." }); + } + /* Und niemals das letzte aktive Management sperren -- sonst kommt + niemand mehr in die Verwaltung hinein. */ + if (!aktiv && person.rolle === "admin") { + const { n } = db().prepare( + "SELECT COUNT(*) AS n FROM personen WHERE rolle = 'admin' AND aktiv = 1").get(); + if (n <= 1) return res.status(400).json({ fehler: "Das ist das letzte aktive Management." }); + } + personSperren(id, aktiv, { ...req.person, ip: echteIp(req) }); + } + + if (req.body?.name !== undefined) { + const name = String(req.body.name).trim(); + if (name.length < 2 || name.length > NAME_MAX) { + return res.status(400).json({ fehler: "Name ist ungültig." }); + } + db().prepare("UPDATE personen SET name = ? WHERE id = ?").run(name, id); + protokolliere("person_umbenannt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${person.name} -> ${name}`, + }); + } + + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Person ändern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Protokoll ---------------------------------------------------- */ + +personenRouter.get("/workspace/api/verwaltung/protokoll", (req, res) => { + try { + const anzahl = Math.min(Math.max(Number(req.query.anzahl) || 40, 1), 200); + res.json({ + eintraege: db().prepare(` + SELECT k.zeitpunkt, k.aktion, k.rolle, k.detail, k.ip, p.name AS wer + FROM protokoll k LEFT JOIN personen p ON p.id = k.person_id + ORDER BY k.id DESC LIMIT ?`).all(anzahl), + }); + } catch { + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index bbddc0f7..7063db02 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -247,13 +247,27 @@ export const workspaceRouter = express.Router(); /* Schutz der angemeldeten Seiten. Serverseitig, nicht nur im Browser -- sonst könnte man die Seite einfach direkt aufrufen. */ -const GESCHUETZT = ["/workspace/start.html", "/workspace/aufgaben.html"]; +/* Pfad -> erlaubte Rollen. `null` heisst: jede angemeldete Rolle. + + Die Rolle wird hier mitgeprueft und nicht nur in der Schnittstelle. + Sonst bekommt z. B. ein Creator die Verwaltungsseite zwar ausgeliefert + (HTTP 200) und sieht ihr Geruest, auch wenn sie danach leer bleibt und + das Skript ihn wegschickt. Sichtbar sein soll sie gar nicht. */ +const GESCHUETZT = { + "/workspace/start.html": null, + "/workspace/aufgaben.html": null, + "/workspace/personen.html": ["admin"], +}; workspaceRouter.use((req, res, next) => { - if (!GESCHUETZT.includes(req.path)) return next(); + if (!Object.hasOwn(GESCHUETZT, req.path)) return next(); const person = sitzungLesen(req); - if (person) return next(); - return res.redirect(302, "/workspace/"); + if (!person) return res.redirect(302, "/workspace/"); + const erlaubt = GESCHUETZT[req.path]; + if (erlaubt && !erlaubt.includes(person.rolle)) { + return res.redirect(302, "/workspace/start.html"); + } + return next(); }); workspaceRouter.post("/workspace/api/anmelden", (req, res) => { @@ -343,7 +357,12 @@ export function codeErzeugen(gruppen = 4, laenge = 4) { return aus; } -export function personAnlegen(name, rolle) { +/* `akteur` ist WER die Aktion ausloest -- nicht, wen sie betrifft. Das + muss getrennt bleiben: Stand im Protokoll die neu angelegte Person als + person_id, las sich der Eintrag so, als haette sie sich selbst angelegt. + Wer betroffen ist, steht im Text. Ohne Akteur (Kommandozeile) bleibt + das Feld leer. */ +export function personAnlegen(name, rolle, akteur = null) { if (!ROLLEN.has(rolle)) throw new Error(`Unbekannte Rolle: ${rolle}`); const code = codeErzeugen(); const salt = randomBytes(16).toString("hex"); @@ -351,11 +370,14 @@ export function personAnlegen(name, rolle) { const { lastInsertRowid } = db().prepare( "INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)" ).run(name, rolle, hash, salt, SCRYPT.N, jetzt()); - protokolliere("person_angelegt", { personId: Number(lastInsertRowid), rolle, detail: name }); + protokolliere("person_angelegt", { + personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, + ip: akteur?.ip ?? null, detail: `${name} (${rolle})`, + }); return { id: Number(lastInsertRowid), name, rolle, code }; } -export function codeNeu(id) { +export function codeNeu(id, akteur = null) { const person = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(id); if (!person) throw new Error(`Keine Person mit Nummer ${id}`); const code = codeErzeugen(); @@ -365,14 +387,21 @@ export function codeNeu(id) { /* Alle offenen Sitzungen beenden -- ein neuer Code soll den alten Zugang wirklich beenden, nicht nur die nächste Anmeldung betreffen. */ db().prepare("DELETE FROM sitzungen WHERE person_id = ?").run(id); - protokolliere("code_erneuert", { personId: id, rolle: person.rolle, detail: person.name }); + protokolliere("code_erneuert", { + personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, + ip: akteur?.ip ?? null, detail: `für ${person.name}`, + }); return { ...person, code }; } -export function personSperren(id, aktiv = 0) { +export function personSperren(id, aktiv = 0, akteur = null) { + const person = db().prepare("SELECT name FROM personen WHERE id = ?").get(id); db().prepare("UPDATE personen SET aktiv = ? WHERE id = ?").run(aktiv ? 1 : 0, id); if (!aktiv) db().prepare("DELETE FROM sitzungen WHERE person_id = ?").run(id); - protokolliere(aktiv ? "person_entsperrt" : "person_gesperrt", { personId: id }); + protokolliere(aktiv ? "person_entsperrt" : "person_gesperrt", { + personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, + ip: akteur?.ip ?? null, detail: person?.name ?? `#${id}`, + }); } export function personenListe() { diff --git a/workspace/assets/css/aufgaben.css b/workspace/assets/css/aufgaben.css index 8d94994c..d9054df1 100644 --- a/workspace/assets/css/aufgaben.css +++ b/workspace/assets/css/aufgaben.css @@ -129,3 +129,34 @@ @media (max-width: 1080px) { .brett { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 620px) { .brett { grid-template-columns: 1fr; } } + +/* ---------- Bearbeiten-Dialog ------------------------------------------ */ + +.dialog { + width: min(560px, calc(100vw - 32px)); + padding: 0; border: 1px solid var(--rand-hell); + border-radius: var(--radius); + background: #0b1018; + color: var(--text); + box-shadow: var(--schatten); +} +.dialog::backdrop { background: rgba(2, 4, 8, .68); backdrop-filter: blur(3px); } + +.neu--blank { margin: 0; border: 0; background: none; padding: 22px; } + +.dialog__titel { margin: 0 0 18px; font-size: 1.12rem; font-weight: 700; } + +.dialog__knoepfe { display: flex; gap: 10px; align-items: center; margin-top: 16px; flex-wrap: wrap; } +.dialog__knoepfe #b-loeschen { margin-left: auto; } + +.abmelden--warnung { color: var(--warn); border-color: rgba(255, 154, 162, .35); } +.abmelden--warnung:hover { background: rgba(255, 154, 162, .1); border-color: rgba(255, 154, 162, .6); } + +.karte__stift { + margin-left: auto; padding: 3px 8px; + background: none; border: 0; border-radius: 7px; + color: var(--text-still); font: inherit; font-size: .75rem; cursor: pointer; +} +.karte__stift:hover { background: rgba(255, 255, 255, .07); color: var(--text-leise); } +.karte__kopf { display: flex; align-items: flex-start; gap: 8px; } +.karte__kopf .karte__titel { flex: 1 1 auto; } diff --git a/workspace/assets/css/personen.css b/workspace/assets/css/personen.css new file mode 100644 index 00000000..4b249baa --- /dev/null +++ b/workspace/assets/css/personen.css @@ -0,0 +1,73 @@ +/* =================================================================== + Personenverwaltung. + =================================================================== */ + +.person { + display: grid; gap: 4px 16px; align-items: center; + grid-template-columns: 1fr auto; + padding: 14px 16px; margin-bottom: 9px; + background: rgba(255, 255, 255, .03); + border: 1px solid var(--rand); + border-radius: var(--radius-klein); +} +.person[data-aktiv="nein"] { opacity: .58; } + +.person__name { font-weight: 600; font-size: .98rem; } + +.person__zeile { + display: flex; flex-wrap: wrap; gap: 8px; + margin-top: 2px; font-size: .79rem; color: var(--text-still); +} + +.marke-rolle { + padding: 1px 9px; border-radius: 999px; + font-size: .7rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; +} +.marke-rolle[data-rolle="admin"] { background: rgba(63, 189, 245, .16); color: #7fd2fb; } +.marke-rolle[data-rolle="creator"] { background: rgba(138, 118, 255, .16); color: #b3a6ff; } +.marke-rolle[data-rolle="scout"] { background: rgba(77, 171, 119, .16); color: #79d1a2; } + +.person__knoepfe { display: flex; gap: 7px; flex-wrap: wrap; justify-content: flex-end; } + +/* ---------- Code-Anzeige ---------------------------------------------- */ + +.code-kasten { + margin: 0 0 26px; padding: 20px 22px; + background: linear-gradient(100deg, rgba(63, 189, 245, .12), rgba(138, 118, 255, .09)); + border: 1px solid rgba(63, 189, 245, .5); + border-radius: var(--radius); +} +.code-kasten__schild { + margin: 0 0 8px; font-size: .78rem; font-weight: 600; + letter-spacing: .06em; text-transform: uppercase; color: var(--text-leise); +} +.code-kasten__code { + margin: 0 0 12px; + font-family: "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace; + font-size: clamp(1.3rem, 3.4vw, 1.9rem); font-weight: 700; + letter-spacing: .12em; color: #eaf4fd; + user-select: all; /* ein Klick markiert den ganzen Code */ + word-break: break-all; +} +.code-kasten__hinweis { margin: 0 0 14px; font-size: .85rem; color: var(--text-leise); } + +/* ---------- Protokoll --------------------------------------------------- */ + +.protokoll-block { margin-top: 44px; } + +.protokoll-zeile { + display: grid; gap: 10px; + grid-template-columns: 128px 190px 1fr auto; + padding: 7px 10px; + font-size: .8rem; color: var(--text-leise); + border-bottom: 1px solid rgba(150, 186, 220, .08); +} +.protokoll-zeile:first-child { border-top: 1px solid rgba(150, 186, 220, .08); } +.protokoll-zeit { color: var(--text-still); font-variant-numeric: tabular-nums; } +.protokoll-ip { color: var(--text-still); font-variant-numeric: tabular-nums; } + +@media (max-width: 760px) { + .person { grid-template-columns: 1fr; } + .person__knoepfe { justify-content: flex-start; margin-top: 10px; } + .protokoll-zeile { grid-template-columns: 1fr; gap: 2px; padding: 10px; } +} diff --git a/workspace/assets/js/aufgaben.js b/workspace/assets/js/aufgaben.js index 86718d91..428931cc 100644 --- a/workspace/assets/js/aufgaben.js +++ b/workspace/assets/js/aufgaben.js @@ -48,7 +48,15 @@ function karte(a) { const k = el('article', 'karte'); k.dataset.prio = a.prioritaet; - k.append(el('h3', 'karte__titel', a.titel)); + + const kopf = el('div', 'karte__kopf'); + kopf.append(el('h3', 'karte__titel', a.titel)); + const stift = el('button', 'karte__stift', 'bearbeiten'); + stift.type = 'button'; + stift.setAttribute('aria-label', `${a.titel} bearbeiten`); + stift.addEventListener('click', () => dialogOeffnen(a)); + kopf.append(stift); + k.append(kopf); if (a.beschreibung) k.append(el('p', 'karte__text', a.beschreibung)); const zeile = el('div', 'karte__zeile'); @@ -178,6 +186,70 @@ } catch { /* bereits umgeleitet */ } finally { knopfEl.disabled = false; } }); + /* ---------- Bearbeiten ------------------------------------------------ */ + + const dialog = $('bearbeiten-dialog'); + let bearbeitet = null; + + function dialogOeffnen(a) { + bearbeitet = a; + $('b-titel').value = a.titel; + $('b-text').value = a.beschreibung || ''; + $('b-prio').value = a.prioritaet; + $('b-frist').value = a.frist || ''; + $('b-fehler').textContent = ''; + if (ich.rolle === 'admin') { + $('b-creator').value = a.creator_id ? String(a.creator_id) : ''; + $('b-verant').value = a.verantwortlich_id ? String(a.verantwortlich_id) : ''; + $('b-loeschen').hidden = false; + } + dialog.showModal(); + $('b-titel').focus(); + } + + $('b-abbrechen').addEventListener('click', () => dialog.close()); + + $('b-speichern').addEventListener('click', async () => { + const daten = { + titel: $('b-titel').value, + beschreibung: $('b-text').value, + prioritaet: $('b-prio').value, + frist: $('b-frist').value, + }; + if (ich.rolle === 'admin') { + daten.creator_id = $('b-creator').value || null; + daten.verantwortlich_id = $('b-verant').value || null; + } + $('b-speichern').disabled = true; + try { + const a = await hole(`/workspace/api/aufgaben/${bearbeitet.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(daten), + }); + if (!a.ok) { + $('b-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Speichern ging nicht.'; + return; + } + dialog.close(); + await laden(); + } catch { /* umgeleitet */ } finally { $('b-speichern').disabled = false; } + }); + + $('b-loeschen').addEventListener('click', async () => { + if (!confirm(`Aufgabe wirklich loeschen? + +"${bearbeitet.titel}" + +Das laesst sich nicht rueckgaengig machen. Erledigte Aufgaben sollten normalerweise stehen bleiben.`)) return; + try { + const a = await hole(`/workspace/api/aufgaben/${bearbeitet.id}`, { method: 'DELETE' }); + if (!a.ok) { $('b-fehler').textContent = 'Loeschen ging nicht.'; return; } + dialog.close(); + await laden(); + } catch { /* umgeleitet */ } + }); + /* ---------- Start ----------------------------------------------------- */ $('abmelden').addEventListener('click', async () => { @@ -198,10 +270,12 @@ if (ich.rolle === 'admin') { $('feld-creator').hidden = false; $('feld-verant').hidden = false; + $('b-feld-creator').hidden = false; + $('b-feld-verant').hidden = false; try { const { personen } = await (await hole('/workspace/api/personen')).json(); for (const p of personen) { - for (const ziel of ['f-creator', 'f-verant']) { + for (const ziel of ['f-creator', 'f-verant', 'b-creator', 'b-verant']) { const o = el('option', null, `${p.name} (${p.rolle})`); o.value = String(p.id); $(ziel).append(o); diff --git a/workspace/assets/js/personen.js b/workspace/assets/js/personen.js new file mode 100644 index 00000000..196be31c --- /dev/null +++ b/workspace/assets/js/personen.js @@ -0,0 +1,202 @@ +/* =================================================================== + Personen und Zugangscodes verwalten. Nur für Management -- der Server + liefert allen anderen ohnehin 404 auf diese Schnittstellen. + =================================================================== */ +(() => { + 'use strict'; + + const $ = (id) => document.getElementById(id); + const ROLLENNAME = { admin: 'Management', creator: 'Creator', scout: 'Scout' }; + let ich = null; + + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + + 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'); } + if (a.status === 404 && pfad.includes('/verwaltung/')) { + location.assign('/workspace/start.html'); throw new Error('nicht erlaubt'); + } + return a; + } + + const datum = (iso) => (iso ? iso.slice(0, 16).replace('T', ' ') : '—'); + + /* ---------- Code anzeigen -------------------------------------------- */ + + function zeigeCode(p) { + $('code-name').textContent = `${p.name} · ${ROLLENNAME[p.rolle] || p.rolle}`; + $('code-wert').textContent = p.code; + $('code-kasten').hidden = false; + $('code-kasten').scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + $('code-schliessen').addEventListener('click', () => { + $('code-kasten').hidden = true; + /* Den Code auch aus dem Dokument entfernen, damit er nicht in einer + Bildschirmaufnahme oder im Verlauf hängen bleibt. */ + $('code-wert').textContent = ''; + }); + + $('code-kopieren').addEventListener('click', async () => { + try { + await navigator.clipboard.writeText($('code-wert').textContent); + $('code-kopieren').textContent = 'Kopiert ✓'; + setTimeout(() => { $('code-kopieren').textContent = 'Kopieren'; }, 1800); + } catch { + melde('Kopieren ging nicht — bitte von Hand markieren.'); + } + }); + + /* ---------- Liste ----------------------------------------------------- */ + + function zeile(p) { + const k = el('div', 'person'); + k.dataset.aktiv = p.aktiv ? 'ja' : 'nein'; + + const links = el('div'); + links.append(el('div', 'person__name', p.name + (p.id === ich.id ? ' (du)' : ''))); + + const z = el('div', 'person__zeile'); + const rolle = el('span', 'marke-rolle', ROLLENNAME[p.rolle] || p.rolle); + rolle.dataset.rolle = p.rolle; + z.append(rolle); + if (!p.aktiv) z.append(el('span', null, 'gesperrt')); + z.append(el('span', null, `letzte Anmeldung: ${datum(p.letzter_login)}`)); + z.append(el('span', null, `offene Aufgaben: ${p.offene_aufgaben}`)); + if (p.sitzungen) z.append(el('span', null, `${p.sitzungen} offene Sitzung(en)`)); + links.append(z); + + const knoepfe = el('div', 'person__knoepfe'); + knoepfe.append(tuKnopf('Neuer Code', async () => { + const eigen = p.id === ich.id; + const frage = eigen + ? `Neuer Code für dich selbst?\n\nDeine aktuelle Sitzung wird sofort beendet und du musst dich mit dem neuen Code neu anmelden.` + : `Neuer Code für ${p.name}?\n\nDer alte Code gilt dann nicht mehr, offene Sitzungen werden beendet.`; + if (!confirm(frage)) return; + const a = await hole(`/workspace/api/verwaltung/personen/${p.id}/code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(eigen ? { auch_mich: true } : {}), + }); + if (!a.ok) { melde('Neuer Code hat nicht geklappt.'); return; } + zeigeCode(await a.json()); + await laden(); + })); + + if (p.id !== ich.id) { + knoepfe.append(tuKnopf(p.aktiv ? 'Sperren' : 'Entsperren', async () => { + if (p.aktiv && !confirm(`${p.name} sperren?\n\nOffene Sitzungen werden sofort beendet.`)) return; + const a = await hole(`/workspace/api/verwaltung/personen/${p.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ aktiv: !p.aktiv }), + }); + if (!a.ok) { melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); return; } + await laden(); + })); + } + + k.append(links, knoepfe); + return k; + } + + function tuKnopf(text, tu) { + const b = el('button', 'schritt', text); + b.type = 'button'; + b.addEventListener('click', async () => { + b.disabled = true; melde(''); + try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; } + }); + return b; + } + + async function laden() { + const ziel = $('liste'); + try { + const a = await hole('/workspace/api/verwaltung/personen'); + if (!a.ok) { melde('Personen konnten nicht geladen werden.'); return; } + const { personen } = await a.json(); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + for (const p of personen) ziel.append(zeile(p)); + await protokollLaden(); + } catch { /* umgeleitet */ } + } + + async function protokollLaden() { + try { + const a = await hole('/workspace/api/verwaltung/protokoll?anzahl=25'); + if (!a.ok) return; + const { eintraege } = await a.json(); + const ziel = $('protokoll'); + ziel.textContent = ''; + for (const e of eintraege) { + const z = el('div', 'protokoll-zeile'); + z.append(el('span', 'protokoll-zeit', datum(e.zeitpunkt))); + z.append(el('span', null, e.aktion)); + z.append(el('span', null, [e.wer, e.detail].filter(Boolean).join(' · '))); + z.append(el('span', 'protokoll-ip', e.ip || '')); + ziel.append(z); + } + } catch { /* umgeleitet */ } + } + + /* ---------- Anlegen ---------------------------------------------------- */ + + const umschalten = (auf) => { + $('neu').hidden = !auf; $('neu-oeffnen').hidden = auf; + if (auf) $('f-name').focus(); + }; + $('neu-oeffnen').addEventListener('click', () => umschalten(true)); + $('neu-abbrechen').addEventListener('click', () => { + $('neu').reset(); $('neu-fehler').textContent = ''; umschalten(false); + }); + + $('neu').addEventListener('submit', async (e) => { + e.preventDefault(); + $('neu-fehler').textContent = ''; + const knopf = $('neu-speichern'); + knopf.disabled = true; + try { + const a = await hole('/workspace/api/verwaltung/personen', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: $('f-name').value, rolle: $('f-rolle').value }), + }); + if (!a.ok) { + $('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; + return; + } + $('neu').reset(); umschalten(false); + zeigeCode(await a.json()); + await laden(); + } catch { /* umgeleitet */ } finally { knopf.disabled = false; } + }); + + /* ---------- Start ------------------------------------------------------- */ + + $('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 !== 'admin') { location.assign('/workspace/start.html'); return; } + $('wer').textContent = `${ich.name} · ${ich.rolle}`; + await laden(); + })(); +})(); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index f1701218..58d3cc4a 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -17,6 +17,7 @@ jetzt, damit die Trennung von Anfang an sichtbar ist. */ 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: 'Aufgaben', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'aufgaben.html' }, diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 70894e55..937004b6 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -81,6 +81,53 @@ + + +
+

Aufgabe bearbeiten

+ +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ + +
+ + + +
+ + + +
+
+
+ diff --git a/workspace/personen.html b/workspace/personen.html new file mode 100644 index 00000000..6bb4e7af --- /dev/null +++ b/workspace/personen.html @@ -0,0 +1,86 @@ + + + + + +Personen & Zugänge · Creator Workspace + + + + + + + + + + + +
+

Creator Workspace · Personen

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

Zugänge

+

Personen & Zugangscodes

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

Personen werden geladen …

+ +
+

Letzte Ereignisse

+

…

+
+ +
+ + + +