Workspace: Aufgaben bearbeiten, Personenverwaltung im Browser

Aufgaben:
- Bearbeiten-Dialog (Titel, Beschreibung, Prioritaet, Frist, Zuordnung).
  Als natives <dialog>: Fokusfang, Esc zum Schliessen und Abdunklung
  ohne eigenen Code.
- Loeschen nur fuer Management, mit Rueckfrage und Protokolleintrag.
  Das Konzept will, dass Erledigtes stehen bleibt -- Loeschen ist der
  Ausnahmefall fuer Fehleintraege, nicht der normale Abschluss.

Personen (/workspace/personen.html, nur Management):
- Anlegen, Code erneuern, sperren/entsperren, Protokollansicht
- Der Code wird genau einmal in der Antwort zurueckgegeben, nie
  gespeichert; beim Schliessen auch aus dem Dokument entfernt
- Selbstschutz: niemand kann sich selbst sperren, und das letzte aktive
  Management laesst sich nicht sperren -- sonst kaeme niemand mehr hinein
- Code fuer sich selbst tauschen nur mit ausdruecklicher Bestaetigung,
  weil es die eigene Sitzung sofort beendet

Zwei Fehler, die beim Testen aufgefallen sind:

1. Rollenpruefung fehlte beim Ausliefern der Seiten. Ein Creator bekam
   personen.html mit HTTP 200 -- die Schnittstellen wiesen ihn zwar ab,
   das Geruest der Seite war aber sichtbar. GESCHUETZT ist jetzt eine
   Zuordnung Pfad -> erlaubte Rollen statt einer blossen Liste.

2. Das Protokoll nannte den falschen Verursacher. personAnlegen trug die
   NEU ANGELEGTE Person als person_id ein, der Eintrag las sich also so,
   als haette sie sich selbst angelegt. Akteur und Betroffener sind jetzt
   getrennt: Akteur in person_id, Betroffener im Text. Ueber die
   Kommandozeile angelegte Personen zeigen korrekt keinen Akteur.
This commit is contained in:
2026-08-27 23:12:28 +02:00
parent 2eecb40537
commit 232a2003dd
11 changed files with 759 additions and 12 deletions
+2
View File
@@ -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
+27
View File
@@ -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" });
}
});
+175
View File
@@ -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" });
}
});
+39 -10
View File
@@ -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() {
+31
View File
@@ -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; }
+73
View File
@@ -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; }
}
+76 -2
View File
@@ -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);
+202
View File
@@ -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();
})();
})();
+1
View File
@@ -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' },
+47
View File
@@ -81,6 +81,53 @@
</main>
<!-- Bearbeiten. Als natives <dialog>: bringt Fokusfang, Schließen mit Esc
und die Abdunklung des Hintergrunds ohne eigenen Code mit. -->
<dialog class="dialog" id="bearbeiten-dialog">
<form method="dialog" id="bearbeiten" class="neu neu--blank">
<h2 class="dialog__titel">Aufgabe bearbeiten</h2>
<div class="neu__zeile">
<label class="feld-schild" for="b-titel">Titel</label>
<input id="b-titel" maxlength="160" required />
</div>
<div class="neu__zeile">
<label class="feld-schild" for="b-text">Beschreibung</label>
<textarea id="b-text" rows="3" maxlength="4000"></textarea>
</div>
<div class="neu__raster">
<div>
<label class="feld-schild" for="b-prio">Priorität</label>
<select id="b-prio">
<option value="hoch">hoch</option>
<option value="mittel">mittel</option>
<option value="niedrig">niedrig</option>
</select>
</div>
<div>
<label class="feld-schild" for="b-frist">Frist</label>
<input id="b-frist" type="date" />
</div>
<div id="b-feld-creator" hidden>
<label class="feld-schild" for="b-creator">Bereich von</label>
<select id="b-creator"><option value="">—</option></select>
</div>
<div id="b-feld-verant" hidden>
<label class="feld-schild" for="b-verant">Verantwortlich</label>
<select id="b-verant"><option value="">—</option></select>
</div>
</div>
<p class="fehler" id="b-fehler" role="alert" aria-live="polite"></p>
<div class="dialog__knoepfe">
<button type="button" class="knopf knopf--klein" id="b-speichern">Speichern</button>
<button type="button" class="abmelden" id="b-abbrechen">Abbrechen</button>
<button type="button" class="abmelden abmelden--warnung" id="b-loeschen" hidden>Löschen</button>
</div>
</form>
</dialog>
<script src="assets/js/aufgaben.js" defer></script>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Personen &amp; Zugänge · Creator Workspace</title>
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css" />
<link rel="stylesheet" href="assets/css/start.css" />
<link rel="stylesheet" href="assets/css/aufgaben.css" />
<link rel="stylesheet" href="assets/css/personen.css" />
</head>
<body class="start">
<header class="kopfleiste">
<p class="marke"><a class="zurueck" href="start.html">Creator&nbsp;Workspace</a> · Personen</p>
<div class="kopfleiste__rechts">
<span class="wer" id="wer">…</span>
<button type="button" class="abmelden" id="abmelden">Abmelden</button>
</div>
</header>
<main class="inhalt inhalt--breit">
<section class="kopf-zeile">
<div>
<p class="marke">Zugänge</p>
<h1 class="titel">Personen &amp; Zugangscodes</h1>
</div>
<button type="button" class="knopf knopf--klein" id="neu-oeffnen">Person anlegen</button>
</section>
<form class="neu" id="neu" hidden>
<div class="neu__raster">
<div>
<label class="feld-schild" for="f-name">Name</label>
<input id="f-name" name="name" maxlength="60" placeholder="z. B. Luna" required />
</div>
<div>
<label class="feld-schild" for="f-rolle">Rolle</label>
<select id="f-rolle" name="rolle">
<option value="creator" selected>Creator</option>
<option value="scout">Scout</option>
<option value="admin">Management</option>
</select>
</div>
</div>
<p class="fehler" id="neu-fehler" role="alert" aria-live="polite"></p>
<div class="neu__knoepfe">
<button type="submit" class="knopf knopf--klein" id="neu-speichern">Anlegen</button>
<button type="button" class="abmelden" id="neu-abbrechen">Abbrechen</button>
</div>
</form>
<!-- Anzeige des frisch erzeugten Codes. Bewusst auffällig und mit klarem
Hinweis, dass er nur jetzt zu sehen ist. -->
<div class="code-kasten" id="code-kasten" hidden role="status" aria-live="polite">
<p class="code-kasten__schild">Zugangscode für <span id="code-name"></span></p>
<p class="code-kasten__code" id="code-wert"></p>
<p class="code-kasten__hinweis">
Dieser Code wird <strong>nur jetzt</strong> angezeigt und ist danach nicht mehr
abrufbar. Sicher weitergeben und in Bitwarden ablegen.
</p>
<div class="neu__knoepfe">
<button type="button" class="knopf knopf--klein" id="code-kopieren">Kopieren</button>
<button type="button" class="abmelden" id="code-schliessen">Verstanden, schließen</button>
</div>
</div>
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
<div id="liste" aria-busy="true"><p class="leise">Personen werden geladen …</p></div>
<section class="protokoll-block">
<p class="feldschild">Letzte Ereignisse</p>
<div id="protokoll"><p class="leise">…</p></div>
</section>
</main>
<script src="assets/js/personen.js" defer></script>
</body>
</html>