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