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:
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user