diff --git a/server/workspace-aufgaben.js b/server/workspace-aufgaben.js index 8a0e4f86..5c03a198 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -10,7 +10,7 @@ import express from "express"; import { - db, protokolliere, echteIp, sitzungLesen, betreutWo, + db, protokolliere, echteIp, sitzungLesen, betreutWo, istLeitung, ROLLEN_SORTIERUNG, } from "./workspace.js"; export const aufgabenRouter = express.Router(); @@ -71,7 +71,7 @@ export function sichtbar(person) { } function darfAendern(person, aufgabe) { - if (person.rolle === "admin") return true; + if (istLeitung(person)) return true; return aufgabe.creator_id === person.id || aufgabe.verantwortlich_id === person.id; } @@ -132,10 +132,10 @@ aufgabenRouter.get("/workspace/api/uebersicht", (req, res) => { die Liste bewusst nicht -- sie sollen keine fremden Namen sehen. */ aufgabenRouter.get("/workspace/api/personen", (req, res) => { try { - if (req.person.rolle === "admin") { + if (istLeitung(req.person)) { return res.json({ personen: db().prepare( - "SELECT id, name, rolle FROM personen WHERE aktiv = 1 ORDER BY rolle, name").all(), + "SELECT id, name, rolle FROM personen WHERE aktiv = 1 ORDER BY " + ROLLEN_SORTIERUNG + ", name").all(), }); } res.json({ personen: [{ id: req.person.id, name: req.person.name, rolle: req.person.rolle }] }); @@ -193,7 +193,7 @@ aufgabenRouter.post("/workspace/api/aufgaben", gleicheHerkunft, (req, res) => { /* Wer nicht Management ist, darf ausschließlich für sich selbst anlegen -- egal, was im Aufruf steht. */ - if (req.person.rolle !== "admin") { + if (!istLeitung(req.person)) { aus.creator_id = req.person.rolle === "creator" ? req.person.id : null; aus.verantwortlich_id = req.person.id; } @@ -242,7 +242,7 @@ aufgabenRouter.patch("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res) if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); /* Zuordnungen darf nur das Management verschieben. */ - if (req.person.rolle !== "admin") { delete aus.creator_id; delete aus.verantwortlich_id; } + if (!istLeitung(req.person)) { delete aus.creator_id; delete aus.verantwortlich_id; } const felder = Object.keys(aus); if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); @@ -278,7 +278,7 @@ aufgabenRouter.patch("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res) aufgabenRouter.delete("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res) => { try { - if (req.person.rolle !== "admin") return res.status(403).json({ fehler: "nicht_erlaubt" }); + if (!istLeitung(req.person)) 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" }); diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index ce94a2bc..ca22b942 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -16,7 +16,7 @@ import express from "express"; import { - db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds, + db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds, istLeitung, } from "./workspace.js"; export const bereicheRouter = express.Router(); @@ -84,7 +84,7 @@ bereicheRouter.use("/workspace/api/bereich", angemeldet); /* Scouts haben mit der Creator-Betreuung nichts zu tun -- sie sehen hier nichts. Creator sehen ihren eigenen Bereich, das Management alles. */ export function sichtbar(person) { - if (person.rolle === "admin") return { wo: "1=1", werte: [] }; + if (istLeitung(person)) return { wo: "1=1", werte: [] }; if (person.rolle === "creator") return { wo: "e.creator_id = ?", werte: [person.id] }; /* Ein Scout sieht die Bereiche der Creator, die er betreut -- und ohne Zuteilung gar nichts (null heisst: Bereich existiert fuer dich nicht). */ @@ -258,7 +258,7 @@ bereicheRouter.patch("/workspace/api/bereich/:bereich/:id", gleicheHerkunft, (re const { aus, fehler } = pruefe(bereich, req.body || {}, { neu: false }); if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); - if (req.person.rolle !== "admin") delete aus.creator_id; + if (!istLeitung(req.person)) delete aus.creator_id; const felder = Object.keys(aus); if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); @@ -290,7 +290,7 @@ bereicheRouter.delete("/workspace/api/bereich/:bereich/:id", gleicheHerkunft, (r /* Löschen darf das Management und wer den Eintrag selbst geschrieben hat -- sonst könnte ein Creator eine Notiz des Managements über seinen eigenen Bereich verschwinden lassen. */ - if (req.person.rolle !== "admin" && eintrag.erstellt_von !== req.person.id) { + if (!istLeitung(req.person) && eintrag.erstellt_von !== req.person.id) { return res.status(403).json({ fehler: "nicht_erlaubt" }); } diff --git a/server/workspace-dateien.js b/server/workspace-dateien.js index 3c9d253f..42f4679a 100644 --- a/server/workspace-dateien.js +++ b/server/workspace-dateien.js @@ -29,7 +29,7 @@ import { randomBytes } from "node:crypto"; import { join, extname, basename } from "node:path"; import { mkdirSync, createReadStream, writeFileSync, unlinkSync, statSync } from "node:fs"; import { - db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, betreutWo, darfCreator, betreuteIds, + db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, betreutWo, darfCreator, betreuteIds, istLeitung, ROLLEN_SORTIERUNG, } from "./workspace.js"; export const dateienRouter = express.Router(); @@ -68,7 +68,7 @@ dateienRouter.use("/workspace/api/dateien", angemeldet); /* Sichtbarkeit wie überall: an einer Stelle, damit keine Abfrage sie vergisst. */ export function sichtbar(person) { - if (person.rolle === "admin") return { wo: "1=1", werte: [] }; + if (istLeitung(person)) return { wo: "1=1", werte: [] }; /* Ausdrueckliche Freigabe -- gilt fuer jede Rolle gleich. Wer in der Liste steht, sieht die Datei, egal ob Creator oder Scout. */ @@ -96,7 +96,7 @@ export function freigabenZu(ids) { SELECT dp.datei_id, p.id, p.name, p.rolle FROM datei_personen dp JOIN personen p ON p.id = dp.person_id WHERE dp.datei_id IN (${platz}) - ORDER BY p.rolle, p.name`).all(...ids)) { + ORDER BY ${ROLLEN_SORTIERUNG.replace('rolle', 'p.rolle')}, p.name`).all(...ids)) { if (!karte.has(z.datei_id)) karte.set(z.datei_id, []); karte.get(z.datei_id).push({ id: z.id, name: z.name, rolle: z.rolle }); } @@ -108,9 +108,9 @@ export function freigabenZu(ids) { betreut -- sonst koennte er sich ueber eine Freigabe Zugang zu fremden Bereichen verschaffen. */ export function moeglicheEmpfaenger(person) { - if (person.rolle === "admin") { + if (istLeitung(person)) { return db().prepare( - "SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND id <> ? ORDER BY rolle, name") + "SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND id <> ? ORDER BY " + ROLLEN_SORTIERUNG + ", name") .all(person.id); } if (person.rolle === "scout") { @@ -224,7 +224,7 @@ dateienRouter.post("/workspace/api/dateien", } creatorId = w; } - } else if (req.person.rolle === "admin") { + } else if (istLeitung(req.person)) { const w = req.get("x-creator"); if (w) { const z = Number(w); @@ -353,10 +353,10 @@ dateienRouter.patch("/workspace/api/dateien/:id", gleicheHerkunft, (req, res) => if (!STATUS.includes(req.body.status)) return res.status(400).json({ fehler: "Unbekannter Status." }); /* "freigegeben" ist eine Abnahme -- die trifft laut Konzept das Management, nicht der, der die Datei hochgeladen hat. */ - if (req.body.status === "freigegeben" && req.person.rolle !== "admin") { + if (req.body.status === "freigegeben" && !istLeitung(req.person)) { return res.status(403).json({ fehler: "Freigeben darf nur DogFather." }); } - if (datei.status === "freigegeben" && req.person.rolle !== "admin") { + if (datei.status === "freigegeben" && !istLeitung(req.person)) { return res.status(403).json({ fehler: "Freigegebene Dateien ändert nur DogFather." }); } setz.push("status = ?"); daten.push(req.body.status); @@ -395,7 +395,7 @@ dateienRouter.delete("/workspace/api/dateien/:id", gleicheHerkunft, (req, res) = WHERE ${wo} AND d.id = ?`).get(...werte, id); if (!datei) return res.status(404).json({ fehler: "nicht_gefunden" }); - if (req.person.rolle !== "admin") { + if (!istLeitung(req.person)) { if (datei.hochgeladen_von !== req.person.id) { return res.status(403).json({ fehler: "nicht_erlaubt" }); } diff --git a/server/workspace-hinweise.js b/server/workspace-hinweise.js index 70ed7f36..73a66546 100644 --- a/server/workspace-hinweise.js +++ b/server/workspace-hinweise.js @@ -23,7 +23,9 @@ ===================================================================== */ import express from "express"; -import { db, sitzungLesen, betreuteIds } from "./workspace.js"; +import { + db, sitzungLesen, betreuteIds, istLeitung, +} from "./workspace.js"; import { sichtbar as sichtbarAufgaben } from "./workspace-aufgaben.js"; import { sichtbar as sichtbarTermine } from "./workspace-kalender.js"; import { sichtbar as sichtbarDateien } from "./workspace-dateien.js"; @@ -124,7 +126,7 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => { /* ---------- Dateien ---------- */ const d = sichtbarDateien(person); if (d) { - dazu("dateien_review", person.rolle === "admin" ? "warnung" : "offen", + dazu("dateien_review", istLeitung(person) ? "warnung" : "offen", "Datei im Review", "dateien.html", zaehle(`SELECT COUNT(*) n FROM dateien d WHERE ${d.wo} AND d.status = 'review'`, d.werte)); @@ -146,7 +148,7 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => { { let woS = null; let werteS = []; - if (person.rolle === "admin") woS = "1=1"; + if (istLeitung(person)) woS = "1=1"; else if (person.rolle === "creator") { woS = "s.creator_id = ?"; werteS = [person.id]; } else { const ids = betreuteIds(person); @@ -164,9 +166,9 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => { } /* ---------- Scout-Pipeline ---------- */ - if (person.rolle === "admin" || person.rolle === "scout") { - const nur = person.rolle === "admin" ? "1=1" : "l.scout_id = ?"; - const werte = person.rolle === "admin" ? [] : [person.id]; + if (istLeitung(person) || person.rolle === "scout") { + const nur = istLeitung(person) ? "1=1" : "l.scout_id = ?"; + const werte = istLeitung(person) ? [] : [person.id]; dazu("followup_faellig", "warnung", "Follow-up fällig", "scouting.html", zaehle(`SELECT COUNT(*) n FROM leads l @@ -187,7 +189,7 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => { Review-Termine und fehlende Zustaendigkeit sind Steuerungswissen. Sie erscheinen deshalb weder bei Creator noch bei Scouts -- so wie die zugehoerigen Felder im Profil auch. */ - if (person.rolle === "admin") { + if (istLeitung(person)) { dazu("review_faellig", "warnung", "Review-Termin überfällig", "profil.html", zaehle(`SELECT COUNT(*) n FROM profile f diff --git a/server/workspace-kalender.js b/server/workspace-kalender.js index 0afa3950..6c2d9116 100644 --- a/server/workspace-kalender.js +++ b/server/workspace-kalender.js @@ -13,7 +13,7 @@ import express from "express"; import { - db, protokolliere, echteIp, sitzungLesen, betreutWo, + db, protokolliere, echteIp, sitzungLesen, betreutWo, istLeitung, } from "./workspace.js"; export const kalenderRouter = express.Router(); @@ -46,7 +46,7 @@ kalenderRouter.use("/workspace/api/termine", angemeldet); /* Wer sieht welchen Termin? Gleiche Logik wie bei den Aufgaben: Management alles, sonst nur, woran man selbst beteiligt ist. */ export function sichtbar(person) { - if (person.rolle === "admin") return { wo: "1=1", werte: [] }; + if (istLeitung(person)) return { wo: "1=1", werte: [] }; const eigen = "(t.creator_id = ? OR t.teilnehmer_id = ? OR t.erstellt_von = ?)"; const werte = [person.id, person.id, person.id]; const b = betreutWo(person, "t.creator_id"); @@ -85,7 +85,7 @@ kalenderRouter.get("/workspace/api/termine", (req, res) => { /* Fristen aus den Aufgaben -- nur zum Anzeigen, nicht bearbeitbar. Die Sichtbarkeitsregel der Aufgaben gilt dabei unveraendert. */ - const aufgabenWo = req.person.rolle === "admin" + const aufgabenWo = istLeitung(req.person) ? { wo: "1=1", werte: [] } : req.person.rolle === "creator" ? { wo: "(a.creator_id = ? OR a.verantwortlich_id = ?)", werte: [req.person.id, req.person.id] } @@ -164,7 +164,7 @@ kalenderRouter.post("/workspace/api/termine", gleicheHerkunft, (req, res) => { if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); /* Wer nicht Management ist, legt nur für sich selbst an. */ - if (req.person.rolle !== "admin") { + if (!istLeitung(req.person)) { aus.creator_id = req.person.rolle === "creator" ? req.person.id : null; aus.teilnehmer_id = req.person.id; } @@ -207,7 +207,7 @@ kalenderRouter.patch("/workspace/api/termine/:id", gleicheHerkunft, (req, res) = const { aus, fehler } = pruefe(req.body || {}, { neu: false }); if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); - if (req.person.rolle !== "admin") { delete aus.creator_id; delete aus.teilnehmer_id; } + if (!istLeitung(req.person)) { delete aus.creator_id; delete aus.teilnehmer_id; } const felder = Object.keys(aus); if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); @@ -239,7 +239,7 @@ kalenderRouter.delete("/workspace/api/termine/:id", gleicheHerkunft, (req, res) /* Löschen darf das Management -- und wer den Termin selbst angelegt hat. Sonst könnte ein Creator einen Call absagen, den das Management angesetzt hat. */ - if (req.person.rolle !== "admin" && termin.erstellt_von !== req.person.id) { + if (!istLeitung(req.person) && termin.erstellt_von !== req.person.id) { return res.status(403).json({ fehler: "nicht_erlaubt" }); } diff --git a/server/workspace-personen.js b/server/workspace-personen.js index 5c2c62c5..9c1520d4 100644 --- a/server/workspace-personen.js +++ b/server/workspace-personen.js @@ -15,12 +15,14 @@ import express from "express"; import { - db, protokolliere, echteIp, sitzungLesen, personAnlegen, codeNeu, personSperren, betreuungSetzen, + db, protokolliere, echteIp, sitzungLesen, personAnlegen, codeNeu, personSperren, betreuungSetzen, istLeitung, istDogFather, ROLLEN_SORTIERUNG, ROLLEN_REIHE, } from "./workspace.js"; export const personenRouter = express.Router(); -const ROLLEN = ["admin", "creator", "scout"]; +/* Reihenfolge und Umfang kommen aus workspace.js -- eine eigene + Liste hier waere die naechste Stelle, die beim Aendern vergessen wird. */ +const ROLLEN = ROLLEN_REIHE; const NAME_MAX = 60; /* Nur Management. Alles andere bekommt 404 statt 403 -- wer nicht @@ -28,7 +30,7 @@ const NAME_MAX = 60; 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" }); + if (!istLeitung(person)) return res.status(404).json({ fehler: "nicht_gefunden" }); req.person = person; next(); } @@ -44,6 +46,31 @@ function gleicheHerkunft(req, res, next) { personenRouter.use("/workspace/api/verwaltung", nurAdmin); +/* --------------------------------------------------------------------- + Schranke: An DogFather und Managern aendert nur DogFather etwas. + + Sie haengt an JEDEM Weg mit einer :id, nicht an einzelnen Routen. Der + erste Versuch sicherte nur Anlegen und Sperren ab -- und prompt blieb + "neuer Zugangscode" offen. Ein Manager konnte DogFather einen neuen + Code ausstellen, bekam ihn angezeigt und haette ihn damit aus seinem + eigenen Konto ausgesperrt. Genau das soll "nur DogFather hat alle + endgueltigen Rechte" verhindern. + + Als Schranke statt als Einzelpruefung, damit der naechste Weg, der + hier dazukommt, automatisch mitgeschuetzt ist. */ +function nurDogFatherBeiLeitung(req, res, next) { + if (istDogFather(req.person)) return next(); + const id = Number(req.params.id); + if (!Number.isInteger(id)) return next(); + const ziel = db().prepare("SELECT rolle FROM personen WHERE id = ?").get(id); + if (ziel && (ziel.rolle === "admin" || ziel.rolle === "manager")) { + return res.status(403).json({ + fehler: "An DogFather und Managern ändert nur DogFather etwas.", + }); + } + next(); +} + /* ---------- Übersicht --------------------------------------------------- */ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => { @@ -60,7 +87,7 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => { (SELECT COUNT(*) FROM betreuung y WHERE y.betreuer_id = p.id) AS betreut_anzahl FROM personen p LEFT JOIN betreuung b ON b.creator_id = p.id - ORDER BY p.aktiv DESC, p.rolle, p.name`).all(), + ORDER BY p.aktiv DESC, ${ROLLEN_SORTIERUNG.replace("rolle", "p.rolle")}, p.name`).all(), /* Wer ueberhaupt als zustaendig eingetragen werden kann. */ betreuer: db().prepare( "SELECT id, name FROM personen WHERE rolle = 'scout' AND aktiv = 1 ORDER BY name").all(), @@ -120,6 +147,16 @@ personenRouter.post("/workspace/api/verwaltung/personen", gleicheHerkunft, (req, 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." }); + /* ERSTER VORBEHALT: Eine Leitung anlegen darf nur DogFather. + Duerfte ein Manager das, koennte er sich einen zweiten Zugang mit + vollen Rechten schaffen -- und waere damit nicht mehr begrenzbar. + "Nur DogFather hat alle endgueltigen Rechte" faengt hier an. */ + if (!istDogFather(req.person) && (rolle === "admin" || rolle === "manager")) { + return res.status(403).json({ + fehler: "DogFather und Manager legt nur DogFather selbst an.", + }); + } + 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." }); @@ -135,7 +172,7 @@ personenRouter.post("/workspace/api/verwaltung/personen", gleicheHerkunft, (req, /* ---------- Neuer Code -------------------------------------------------- */ -personenRouter.post("/workspace/api/verwaltung/personen/:id/code", gleicheHerkunft, (req, res) => { +personenRouter.post("/workspace/api/verwaltung/personen/:id/code", gleicheHerkunft, nurDogFatherBeiLeitung, (req, res) => { try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); @@ -160,7 +197,7 @@ personenRouter.post("/workspace/api/verwaltung/personen/:id/code", gleicheHerkun /* ---------- Sperren / Entsperren ---------------------------------------- */ -personenRouter.patch("/workspace/api/verwaltung/personen/:id", gleicheHerkunft, (req, res) => { +personenRouter.patch("/workspace/api/verwaltung/personen/:id", gleicheHerkunft, nurDogFatherBeiLeitung, (req, res) => { try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); @@ -175,9 +212,14 @@ personenRouter.patch("/workspace/api/verwaltung/personen/:id", gleicheHerkunft, 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") { + /* Und niemals den letzten aktiven DogFather sperren -- sonst kann + niemand mehr eine Leitung anlegen, auch kein Manager. + Bewusst istDogFather und NICHT istLeitung: Manager duerfen + gesperrt werden, es gibt ja noch DogFather. Die Massenumstellung + auf istLeitung hatte das hier faelschlich mitgezogen -- gezaehlt + werden aber nur DogFather-Zugaenge, also blockierte die Regel + plotzlich auch das Sperren eines Managers. */ + if (!aktiv && istDogFather(person)) { 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 der letzte aktive DogFather-Zugang." }); diff --git a/server/workspace-profil.js b/server/workspace-profil.js index a4aad071..6f5647d4 100644 --- a/server/workspace-profil.js +++ b/server/workspace-profil.js @@ -12,7 +12,7 @@ import express from "express"; import { - db, protokolliere, echteIp, sitzungLesen, darfCreator, betreuteIds, + db, protokolliere, echteIp, sitzungLesen, darfCreator, betreuteIds, istLeitung, } from "./workspace.js"; export const profilRouter = express.Router(); @@ -61,7 +61,7 @@ profilRouter.use("/workspace/api/profil", angemeldet); "eigen" = der Creator selbst. null = 404. */ function darf(person, zielId) { - if (person.rolle === "admin") return "voll"; + if (istLeitung(person)) return "voll"; if (person.rolle === "creator") return person.id === zielId ? "eigen" : null; if (person.rolle === "scout") return darfCreator(person, zielId) ? "betreuend" : null; return null; @@ -116,7 +116,7 @@ profilRouter.get("/workspace/api/profil", (req, res) => { if (!ids.length) return res.json({ creator: [], eigen: false, betreuend: true }); nur = ` AND p.id IN (${ids.map(() => "?").join(",")})`; werte = ids; - } else if (req.person.rolle !== "admin") { + } else if (!istLeitung(req.person)) { return res.status(404).json({ fehler: "nicht_gefunden" }); } res.json({ diff --git a/server/workspace-reports.js b/server/workspace-reports.js index eb30bf29..72419d96 100644 --- a/server/workspace-reports.js +++ b/server/workspace-reports.js @@ -20,7 +20,7 @@ import express from "express"; import { - db, protokolliere, echteIp, sitzungLesen, betreuteIds, darfCreator, + db, protokolliere, echteIp, sitzungLesen, betreuteIds, darfCreator, istLeitung, } from "./workspace.js"; export const reportRouter = express.Router(); @@ -52,7 +52,7 @@ function zielCreator(req) { const gewuenscht = w && w !== "alle" && Number.isInteger(Number(w)) && Number(w) > 0 ? Number(w) : null; - if (req.person.rolle === "admin") return gewuenscht; + if (istLeitung(req.person)) return gewuenscht; /* Scout: darf nur ueber die eigenen Creator einen Report ziehen. Ein fremder Wunsch wird nicht abgelehnt, sondern still auf den ersten @@ -168,7 +168,7 @@ reportRouter.get("/workspace/api/report", (req, res) => { FROM profile WHERE person_id = ?`).get(creator) || null; /* Der Review-Termin ist Steuerungswissen -- ein Creator sieht ihn in seinem Profil auch nicht. */ - if (profil && req.person.rolle !== "admin") { + if (profil && !istLeitung(req.person)) { delete profil.naechster_review; delete profil.plan_start; } @@ -236,7 +236,7 @@ reportRouter.post("/workspace/api/report/entscheidung", (req, res) => { } let creator = null; - if (req.person.rolle === "admin") { + if (istLeitung(req.person)) { const w = req.body?.creator_id; if (w) { const z = Number(w); diff --git a/server/workspace-scouts.js b/server/workspace-scouts.js index 6656c6d4..dd72fcae 100644 --- a/server/workspace-scouts.js +++ b/server/workspace-scouts.js @@ -17,7 +17,7 @@ import express from "express"; import { - db, protokolliere, echteIp, sitzungLesen, personAnlegen, betreuungSetzen, + db, protokolliere, echteIp, sitzungLesen, personAnlegen, betreuungSetzen, istLeitung, } from "./workspace.js"; export const scoutRouter = express.Router(); @@ -58,7 +58,7 @@ function gleicheHerkunft(req, res, next) { scoutRouter.use("/workspace/api/leads", angemeldet); function sichtbar(person) { - if (person.rolle === "admin") return { wo: "1=1", werte: [] }; + if (istLeitung(person)) return { wo: "1=1", werte: [] }; return { wo: "l.scout_id = ?", werte: [person.id] }; } @@ -146,7 +146,7 @@ scoutRouter.post("/workspace/api/leads", gleicheHerkunft, (req, res) => { /* Ein Scout legt immer in die eigene Pipeline. Das Management darf einen Lead einem Scout zuordnen. */ let scoutId = req.person.id; - if (req.person.rolle === "admin") { + if (istLeitung(req.person)) { const w = req.body?.scout_id; if (w) { const z = Number(w); @@ -226,7 +226,7 @@ scoutRouter.delete("/workspace/api/leads/:id", gleicheHerkunft, (req, res) => { if (!lead) return res.status(404).json({ fehler: "nicht_gefunden" }); /* Ein übergebener Lead ist Teil der Betreuungsgeschichte -- den räumt nur das Management weg. */ - if (req.person.rolle !== "admin" && lead.status === "uebergeben") { + if (!istLeitung(req.person) && lead.status === "uebergeben") { return res.status(403).json({ fehler: "Übergebene Leads löscht nur DogFather." }); } db().prepare("DELETE FROM leads WHERE id = ?").run(id); @@ -249,7 +249,7 @@ scoutRouter.delete("/workspace/api/leads/:id", gleicheHerkunft, (req, res) => { scoutRouter.post("/workspace/api/leads/:id/onboarding", gleicheHerkunft, (req, res) => { try { - if (req.person.rolle !== "admin") return res.status(403).json({ fehler: "nicht_erlaubt" }); + if (!istLeitung(req.person)) 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" }); @@ -302,7 +302,7 @@ scoutRouter.post("/workspace/api/leads/:id/onboarding", gleicheHerkunft, (req, r /* Scout-Liste für die Zuordnung durch das Management. */ scoutRouter.get("/workspace/api/leads-scouts", angemeldet, (req, res) => { try { - if (req.person.rolle !== "admin") { + if (!istLeitung(req.person)) { return res.json({ scouts: [{ id: req.person.id, name: req.person.name }] }); } res.json({ diff --git a/server/workspace-suche.js b/server/workspace-suche.js index bb858177..a80be26e 100644 --- a/server/workspace-suche.js +++ b/server/workspace-suche.js @@ -23,7 +23,9 @@ ===================================================================== */ import express from "express"; -import { db, sitzungLesen, betreuteIds } from "./workspace.js"; +import { + db, sitzungLesen, betreuteIds, istLeitung, +} from "./workspace.js"; import { sichtbar as sichtbarAufgaben } from "./workspace-aufgaben.js"; import { sichtbar as sichtbarTermine } from "./workspace-kalender.js"; import { sichtbar as sichtbarDateien } from "./workspace-dateien.js"; @@ -182,9 +184,9 @@ sucheRouter.get("/workspace/api/suche", (req, res) => { } /* ---------- Scout-Pipeline ---------- */ - if (person.rolle === "admin" || person.rolle === "scout") { - const nur = person.rolle === "admin" ? "1=1" : "l.scout_id = ?"; - const werte = person.rolle === "admin" ? [] : [person.id]; + if (istLeitung(person) || person.rolle === "scout") { + const nur = istLeitung(person) ? "1=1" : "l.scout_id = ?"; + const werte = istLeitung(person) ? [] : [person.id]; quelle("Scout-Pipeline", ` SELECT l.id, l.name, l.plattform, l.handle, l.status, l.notizen, l.potenzial, l.aktivitaet FROM leads l @@ -207,7 +209,7 @@ sucheRouter.get("/workspace/api/suche", (req, res) => { Management, denn ein Treffer daraus taucht sonst spaeter in einer Ansicht auf, die diese Felder gar nicht zeigen darf. Wer die Notiz lesen will, oeffnet das Profil. */ - if (person.rolle === "admin" || person.rolle === "scout") { + if (istLeitung(person) || person.rolle === "scout") { let nur = ""; let werte = []; if (person.rolle === "scout") { diff --git a/server/workspace-wissen.js b/server/workspace-wissen.js index c92ad958..c04fc5c2 100644 --- a/server/workspace-wissen.js +++ b/server/workspace-wissen.js @@ -22,7 +22,9 @@ import express from "express"; import { randomBytes } from "node:crypto"; import { join } from "node:path"; import { mkdirSync, createReadStream, writeFileSync, unlinkSync, statSync } from "node:fs"; -import { db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER } from "./workspace.js"; +import { + db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, istLeitung, +} from "./workspace.js"; export const wissenRouter = express.Router(); @@ -139,7 +141,7 @@ function angemeldet(req, res, next) { next(); } function nurDogFather(req, res, next) { - if (req.person.rolle !== "admin") { + if (!istLeitung(req.person)) { return res.status(403).json({ fehler: "Die Bibliothek pflegt DogFather." }); } next(); @@ -177,7 +179,7 @@ wissenRouter.get("/workspace/api/wissen/aufbau", (req, res) => { geraete: GERAETE, tags: [...benutzt].sort(), tag_vorschlaege: TAG_VORSCHLAEGE, - darf_pflegen: req.person.rolle === "admin", + darf_pflegen: istLeitung(req.person), gesamt: [...zaehler.values()].reduce((s, n) => s + n, 0), }); } catch (fehler) { diff --git a/server/workspace.js b/server/workspace.js index 742318c1..4cd0abc3 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -52,7 +52,27 @@ const COOKIE = "dfw_sitzung"; const SITZUNG_STUNDEN = 12; const VERSUCHE_MAX = 8; // pro IP const VERSUCHE_FENSTER_MIN = 10; -const ROLLEN = new Set(["admin", "creator", "scout"]); +const ROLLEN = new Set(["admin", "manager", "scout", "creator"]); + +/* Die Reihenfolge, in der Rollen ueberall erscheinen: DogFather zuerst, + dann Manager, dann Scout, dann Creator. Steht hier einmal, damit keine + Liste eine eigene Reihenfolge erfindet. */ +export const ROLLEN_REIHE = ["admin", "manager", "scout", "creator"]; + +/* Als SQL-Ausdruck fuer ORDER BY. "ORDER BY rolle" waere alphabetisch + (admin, creator, manager, scout) -- also fast genau falsch herum. */ +export const ROLLEN_SORTIERUNG = + "CASE rolle WHEN 'admin' THEN 0 WHEN 'manager' THEN 1 WHEN 'scout' THEN 2 ELSE 3 END"; + +/* LEITUNG = DogFather und Manager. Ein Manager darf alles, was + DogFather darf -- mit genau zwei Ausnahmen, die in + workspace-personen.js stehen: Er kann keine Leitung anlegen und keine + Leitung veraendern. Sonst koennte er sich selbst zum DogFather machen + oder den echten aussperren. "Nur DogFather hat alle endgueltigen + Rechte" heisst genau das. */ +const LEITUNG = new Set(["admin", "manager"]); +export const istLeitung = (person) => !!person && LEITUNG.has(person.rolle); +export const istDogFather = (person) => !!person && person.rolle === "admin"; /* Der Rollenschluessel bleibt "admin" -- er steckt in der CHECK-Regel der Datenbank, in jeder Sitzung und in jeder Rechteabfrage. Umbenannt wird @@ -65,8 +85,9 @@ const ROLLEN = new Set(["admin", "creator", "scout"]); die in der Oberflaeche "DogFather" heisst. */ export const ROLLEN_NAME = { admin: "DogFather", - creator: "Creator", + manager: "Manager", scout: "Scout", + creator: "Creator", }; /* scrypt-Parameter. N=2^15 braucht auf diesem Server rund 150 ms — spürbar @@ -78,6 +99,85 @@ const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64 }; let _db = null; let _dbFehler = null; +/* ===================================================================== + Umstellungen an bestehenden Datenbanken. + + CREATE TABLE IF NOT EXISTS legt eine Tabelle nur an, wenn sie fehlt -- + eine vorhandene wird NICHT angefasst. Die CHECK-Regel fuer die Rolle + stand also weiterhin auf den alten drei Werten, und ein Manager waere + an der Datenbank gescheitert, obwohl im Code alles stimmt. + + SQLite kann eine CHECK-Regel nicht aendern. Der einzige saubere Weg + ist: neue Tabelle, Daten hinueber, alte weg, neue umbenennen. Das ist + der Moment, in dem eine Datenbank kaputtgehen kann -- deshalb wird + vorher eine vollstaendige Sicherung geschrieben (VACUUM INTO, von + SQLite selbst und in sich konsistent, anders als ein Dateikopie + waehrend laufender Schreibvorgaenge). + ===================================================================== */ +function umstellungen(d) { + const jetztStempel = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, ""); + + /* ---- Rolle "manager" erlauben ---- */ + const bauplan = d.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || ""; + if (!bauplan.includes("'manager'")) { + const sicherung = `${DB_PFAD}.vor-manager-${jetztStempel}`; + try { + d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`); + console.log("[workspace] Sicherung vor der Umstellung:", sicherung); + } catch (fehler) { + /* Ohne Sicherung wird NICHT umgestellt. Lieber laeuft der Manager + noch nicht, als dass Daten ohne Netz angefasst werden. */ + console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message); + return; + } + + /* Fremdschluessel muessen aus sein, weil andere Tabellen auf + personen(id) zeigen -- und das laesst sich nicht innerhalb einer + Transaktion umschalten. */ + d.exec("PRAGMA foreign_keys = OFF"); + try { + d.exec("BEGIN"); + d.exec(` + CREATE TABLE personen_neu ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + rolle TEXT NOT NULL CHECK (rolle IN ('admin','manager','scout','creator')), + code_hash TEXT NOT NULL, + code_salt TEXT NOT NULL, + code_n INTEGER NOT NULL, + aktiv INTEGER NOT NULL DEFAULT 1, + erstellt TEXT NOT NULL, + letzter_login TEXT + ); + INSERT INTO personen_neu + (id, name, rolle, code_hash, code_salt, code_n, aktiv, erstellt, letzter_login) + SELECT id, name, rolle, code_hash, code_salt, code_n, aktiv, erstellt, letzter_login + FROM personen; + DROP TABLE personen; + ALTER TABLE personen_neu RENAME TO personen; + `); + d.exec("COMMIT"); + + /* Nach dem Tausch pruefen, ob die Verweise noch stimmen. Findet + sich etwas, wird das laut gemeldet -- stillschweigend kaputte + Verweise waeren das Schlimmste an dieser Stelle. */ + const kaputt = d.prepare("PRAGMA foreign_key_check").all(); + if (kaputt.length) { + console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length, + "verwaiste Verweise. Sicherung liegt unter", sicherung); + } else { + console.log("[workspace] Rolle 'manager' freigeschaltet, Verweise geprueft."); + } + } catch (fehler) { + try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ } + console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message); + } finally { + d.exec("PRAGMA foreign_keys = ON"); + } + } +} + export function db() { if (_db) return _db; if (_dbFehler) throw _dbFehler; @@ -92,7 +192,7 @@ export function db() { CREATE TABLE IF NOT EXISTS personen ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, - rolle TEXT NOT NULL CHECK (rolle IN ('admin','creator','scout')), + rolle TEXT NOT NULL CHECK (rolle IN ('admin','manager','scout','creator')), code_hash TEXT NOT NULL, code_salt TEXT NOT NULL, code_n INTEGER NOT NULL, @@ -369,6 +469,7 @@ export function db() { CREATE INDEX IF NOT EXISTS idx_versuche_ip ON versuche (ip, zeitpunkt); CREATE INDEX IF NOT EXISTS idx_sitzungen_gueltig ON sitzungen (gueltig_bis); `); + umstellungen(d); _db = d; return d; } catch (fehler) { @@ -493,13 +594,13 @@ export const workspaceRouter = express.Router(); const GESCHUETZT = { "/workspace/start.html": null, "/workspace/aufgaben.html": null, - "/workspace/personen.html": ["admin"], - "/workspace/profil.html": ["admin", "creator", "scout"], + "/workspace/personen.html": ["admin", "manager"], + "/workspace/profil.html": ["admin", "manager", "scout", "creator"], "/workspace/kalender.html": null, "/workspace/dateien.html": null, - "/workspace/bereich.html": ["admin", "creator", "scout"], - "/workspace/report.html": ["admin", "creator", "scout"], - "/workspace/scouting.html": ["admin", "scout"], + "/workspace/bereich.html": ["admin", "manager", "scout", "creator"], + "/workspace/report.html": ["admin", "manager", "scout", "creator"], + "/workspace/scouting.html": ["admin", "manager", "scout"], "/workspace/calls.html": null, "/workspace/startcheck.html": null, "/workspace/wissen.html": null, @@ -649,7 +750,7 @@ export function betreutWo(person, spalte) { /* Darf diese Person den Bereich dieses Creators sehen und bearbeiten? */ export function darfCreator(person, creatorId) { if (!person || !creatorId) return false; - if (person.rolle === "admin") return true; + if (istLeitung(person)) return true; if (person.rolle === "creator") return person.id === Number(creatorId); return betreuteIds(person).includes(Number(creatorId)); } @@ -721,7 +822,7 @@ export function personenListe() { return 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 - FROM personen p ORDER BY p.rolle, p.name`).all(); + FROM personen p ORDER BY ${ROLLEN_SORTIERUNG.replace("rolle", "p.rolle")}, p.name`).all(); } export function protokollLesen(anzahl = 20) { diff --git a/workspace/assets/css/personen.css b/workspace/assets/css/personen.css index cfca322a..d639fc35 100644 --- a/workspace/assets/css/personen.css +++ b/workspace/assets/css/personen.css @@ -91,3 +91,71 @@ outline: none; border-color: rgba(63, 189, 245, .65); box-shadow: 0 0 0 3px rgba(63, 189, 245, .14); } + +/* =================================================================== + Rollenwahl (28.08.2026). + + Ersetzt ein -
- - +
+ Rolle + +
@@ -87,7 +93,7 @@ - - + + diff --git a/workspace/profil.html b/workspace/profil.html index 8226476e..7954e238 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -127,7 +127,7 @@ - - + + diff --git a/workspace/report.html b/workspace/report.html index 78741aea..13b1c322 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -86,8 +86,8 @@ - - - + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 35336731..3089af1b 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,7 +124,7 @@ - - + + diff --git a/workspace/start.html b/workspace/start.html index 3f43fd22..87d42d58 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -56,7 +56,7 @@

- - + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index a0950cb4..ab5d3143 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -65,8 +65,8 @@ - - - + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 8f4fc93c..9dc9ec2b 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -165,7 +165,7 @@ - - + +