Die Rolle KASSIER war seit Phase 0 angelegt, ein Kassenmodul fehlte in der Planung. Phase-9.md beschreibt es jetzt: Konten, Buchungen mit Beleg, Beitragslauf mit Mahnstufen, Rechnungen, Kassenabschluss und Auswertungen. Geschaetzt 8-11 PT, Version 0.10.0. Zwei Festlegungen aus der Spezifikation, weil sie den Rest bestimmen: eine festgeschriebene Buchung ist unveraenderbar und wird nur ueber eine Gegenbuchung korrigiert; der Kassenabschluss friert seinen Zeitraum ein. Ausserdem als entschieden festgehalten: - Berechtigung bleibt ohne organisationId. Die Tabelle ist der systemweite Katalog dessen, was die Software kann; mandantenbezogen wird es erst ueber RolleBerechtigung. - Die Loeschausnahme im Audit-Log bleibt. UPDATE ist dauerhaft gesperrt, DELETE nur im gekennzeichneten Aufbewahrungslauf des Workers. Roadmap, Aufwandsschaetzung, Versionierung, README und die Seitenleiste sind entsprechend ergaenzt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
/**
|
|
* Zentrale Berechtigungslogik.
|
|
*
|
|
* Regel aus CLAUDE.md: Keine Rollenprüfung in Komponenten. Alles, was mit
|
|
* "darf jemand das?" zu tun hat, steht hier — und nur hier.
|
|
*
|
|
* Aufbau:
|
|
* Recht = ein einzelnes Können, z. B. "geraet.bearbeiten"
|
|
* Rolle = ein Bündel von Rechten, z. B. GERAETEWART
|
|
* Sitzung = wer gerade angemeldet ist, mit welchen Rollen, in welcher Wehr
|
|
*
|
|
* Die Rechte einer Rolle sind hier als Vorgabe hinterlegt. Beim Seed werden sie
|
|
* in die Tabellen berechtigung/rolle_berechtigung gespiegelt, damit eine Wehr
|
|
* sie nachträglich anpassen kann, ohne dass jemand Code ändern muss.
|
|
*/
|
|
|
|
/** Rollen exakt wie im Prisma-Schema (enum RolleSchluessel). */
|
|
export const ROLLEN = [
|
|
"SUPERADMIN",
|
|
"KOMMANDANT",
|
|
"GERAETEWART",
|
|
"ATEMSCHUTZGERAETEWART",
|
|
"KASSIER",
|
|
"SCHRIFTFUEHRER",
|
|
"AUSBILDER",
|
|
"MITGLIED",
|
|
"KIOSK",
|
|
] as const;
|
|
|
|
export type Rolle = (typeof ROLLEN)[number];
|
|
|
|
/** Klartext für die Oberfläche. */
|
|
export const ROLLEN_BEZEICHNUNG: Record<Rolle, string> = {
|
|
SUPERADMIN: "Administrator",
|
|
KOMMANDANT: "Kommandant",
|
|
GERAETEWART: "Gerätewart",
|
|
ATEMSCHUTZGERAETEWART: "Atemschutzgerätewart",
|
|
KASSIER: "Kassier",
|
|
SCHRIFTFUEHRER: "Schriftführer",
|
|
AUSBILDER: "Ausbilder",
|
|
MITGLIED: "Mitglied",
|
|
KIOSK: "Terminal im Gerätehaus",
|
|
};
|
|
|
|
export const ROLLEN_BESCHREIBUNG: Record<Rolle, string> = {
|
|
SUPERADMIN:
|
|
"Verwaltet die Installation selbst: Wehren anlegen, Rollen zuschneiden, Systemeinstellungen.",
|
|
KOMMANDANT:
|
|
"Führt die Wehr. Sieht und bearbeitet alles innerhalb der eigenen Organisation.",
|
|
GERAETEWART:
|
|
"Verantwortet Fahrzeuge, Geräte und deren Prüfungen nach DGUV.",
|
|
ATEMSCHUTZGERAETEWART:
|
|
"Verantwortet Atemschutzgeräte, Masken und die Überwachung nach FwDV 7.",
|
|
KASSIER: "Führt die Kasse: Buchungen, Beiträge, Rechnungen, Auswertungen.",
|
|
SCHRIFTFUEHRER:
|
|
"Führt Protokolle, Berichte und die Dokumentation der Wehr.",
|
|
AUSBILDER: "Plant Übungen und Lehrgänge, führt Teilnahmenachweise.",
|
|
MITGLIED: "Sieht die eigenen Daten, Termine und Qualifikationen.",
|
|
KIOSK:
|
|
"Terminal im Gerätehaus. Darf nur anmelden, quittieren und erfassen — nie verwalten.",
|
|
};
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Rechte
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Alle Einzelrechte. Schema: <bereich>.<taetigkeit>
|
|
* Neue Rechte hier ergänzen, dann `npm run db:seed` — der Seed gleicht den
|
|
* Katalog in der Datenbank ab.
|
|
*/
|
|
export const RECHTE = {
|
|
// Organisation und System
|
|
"organisation.lesen": { bereich: "Organisation", bezeichnung: "Wehrdaten ansehen" },
|
|
"organisation.bearbeiten": { bereich: "Organisation", bezeichnung: "Wehrdaten bearbeiten" },
|
|
"organisation.verwalten": { bereich: "Organisation", bezeichnung: "Wehren anlegen und löschen" },
|
|
"system.einstellungen": { bereich: "System", bezeichnung: "Systemeinstellungen ändern" },
|
|
"system.auditlog": { bereich: "System", bezeichnung: "Audit-Log einsehen" },
|
|
|
|
// Personen
|
|
"person.lesen": { bereich: "Personen", bezeichnung: "Mitgliederliste ansehen" },
|
|
"person.eigene": { bereich: "Personen", bezeichnung: "Eigene Daten ansehen und pflegen" },
|
|
"person.bearbeiten": { bereich: "Personen", bezeichnung: "Mitglieder anlegen und bearbeiten" },
|
|
"person.loeschen": { bereich: "Personen", bezeichnung: "Mitglieder austragen" },
|
|
"person.rollen": { bereich: "Personen", bezeichnung: "Rollen zuweisen" },
|
|
"person.ausweis": { bereich: "Personen", bezeichnung: "PIN und RFID-Ausweise verwalten" },
|
|
"person.export": { bereich: "Personen", bezeichnung: "Personendaten exportieren (DSGVO)" },
|
|
|
|
// Fahrzeuge und Geräte
|
|
"fahrzeug.lesen": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge ansehen" },
|
|
"fahrzeug.bearbeiten": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge anlegen und bearbeiten" },
|
|
"geraet.lesen": { bereich: "Geräte", bezeichnung: "Geräte ansehen" },
|
|
"geraet.bearbeiten": { bereich: "Geräte", bezeichnung: "Geräte anlegen und bearbeiten" },
|
|
"geraet.aussondern": { bereich: "Geräte", bezeichnung: "Geräte aussondern oder sperren" },
|
|
|
|
// Prüfungen und Wartung (Phase 2)
|
|
"pruefung.lesen": { bereich: "Prüfungen", bezeichnung: "Prüfungen und Fristen ansehen" },
|
|
"pruefung.durchfuehren": { bereich: "Prüfungen", bezeichnung: "Prüfungen durchführen und dokumentieren" },
|
|
"pruefung.planen": { bereich: "Prüfungen", bezeichnung: "Prüftermine und Vorlagen planen" },
|
|
|
|
// Atemschutz nach FwDV 7 (Phase 5)
|
|
"atemschutz.lesen": { bereich: "Atemschutz", bezeichnung: "Atemschutzdaten ansehen" },
|
|
"atemschutz.bearbeiten": { bereich: "Atemschutz", bezeichnung: "Atemschutzgeräte und Träger verwalten" },
|
|
"atemschutz.ueberwachung": { bereich: "Atemschutz", bezeichnung: "Atemschutzüberwachung führen" },
|
|
|
|
// Einsätze und Übungen (Phase 3)
|
|
"einsatz.lesen": { bereich: "Einsätze", bezeichnung: "Einsätze ansehen" },
|
|
"einsatz.bearbeiten": { bereich: "Einsätze", bezeichnung: "Einsätze erfassen und bearbeiten" },
|
|
"einsatz.bericht": { bereich: "Einsätze", bezeichnung: "Einsatzberichte erstellen" },
|
|
|
|
// Ausbildung und Qualifikationen (Phase 1 und 3)
|
|
"ausbildung.lesen": { bereich: "Ausbildung", bezeichnung: "Übungen und Lehrgänge ansehen" },
|
|
"ausbildung.bearbeiten": { bereich: "Ausbildung", bezeichnung: "Übungen planen und Teilnahme erfassen" },
|
|
"qualifikation.lesen": { bereich: "Ausbildung", bezeichnung: "Qualifikationen ansehen" },
|
|
"qualifikation.bearbeiten": { bereich: "Ausbildung", bezeichnung: "Qualifikationen pflegen" },
|
|
|
|
// Kasse (Phase 9)
|
|
"kasse.lesen": { bereich: "Kasse", bezeichnung: "Buchungen und Auswertungen ansehen" },
|
|
"kasse.bearbeiten": { bereich: "Kasse", bezeichnung: "Buchungen erfassen" },
|
|
"kasse.abschluss": { bereich: "Kasse", bezeichnung: "Kassenabschluss durchführen" },
|
|
|
|
// Dateien
|
|
"datei.lesen": { bereich: "Dateien", bezeichnung: "Dateien herunterladen" },
|
|
"datei.hochladen": { bereich: "Dateien", bezeichnung: "Dateien hochladen" },
|
|
"datei.loeschen": { bereich: "Dateien", bezeichnung: "Dateien löschen" },
|
|
|
|
// Terminal im Gerätehaus (Phase 4)
|
|
"kiosk.anmelden": { bereich: "Terminal", bezeichnung: "Am Terminal anmelden" },
|
|
"kiosk.erfassen": { bereich: "Terminal", bezeichnung: "Am Terminal Erfassungen vornehmen" },
|
|
} as const;
|
|
|
|
export type Recht = keyof typeof RECHTE;
|
|
|
|
export const ALLE_RECHTE = Object.keys(RECHTE) as Recht[];
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Rolle zu Recht
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/** Rechte, die jede angemeldete Person hat. */
|
|
const GRUNDRECHTE: Recht[] = [
|
|
"person.eigene",
|
|
"organisation.lesen",
|
|
"person.lesen",
|
|
"fahrzeug.lesen",
|
|
"geraet.lesen",
|
|
"pruefung.lesen",
|
|
"einsatz.lesen",
|
|
"ausbildung.lesen",
|
|
"qualifikation.lesen",
|
|
"datei.lesen",
|
|
];
|
|
|
|
/**
|
|
* Vorgabe je Rolle. SUPERADMIN steht bewusst nicht drin — der bekommt in
|
|
* hatRecht() pauschal alles, damit ein neu ergänztes Recht nicht versehentlich
|
|
* am Administrator vorbeigeht.
|
|
*/
|
|
export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
|
|
KOMMANDANT: [
|
|
...GRUNDRECHTE,
|
|
"organisation.bearbeiten",
|
|
"system.auditlog",
|
|
"person.bearbeiten",
|
|
"person.loeschen",
|
|
"person.rollen",
|
|
"person.ausweis",
|
|
"person.export",
|
|
"fahrzeug.bearbeiten",
|
|
"geraet.bearbeiten",
|
|
"geraet.aussondern",
|
|
"pruefung.planen",
|
|
"pruefung.durchfuehren",
|
|
"atemschutz.lesen",
|
|
"atemschutz.bearbeiten",
|
|
"atemschutz.ueberwachung",
|
|
"einsatz.bearbeiten",
|
|
"einsatz.bericht",
|
|
"ausbildung.bearbeiten",
|
|
"qualifikation.bearbeiten",
|
|
"kasse.lesen",
|
|
"datei.hochladen",
|
|
"datei.loeschen",
|
|
],
|
|
|
|
GERAETEWART: [
|
|
...GRUNDRECHTE,
|
|
"fahrzeug.bearbeiten",
|
|
"geraet.bearbeiten",
|
|
"geraet.aussondern",
|
|
"pruefung.planen",
|
|
"pruefung.durchfuehren",
|
|
"datei.hochladen",
|
|
],
|
|
|
|
ATEMSCHUTZGERAETEWART: [
|
|
...GRUNDRECHTE,
|
|
"geraet.bearbeiten",
|
|
"pruefung.planen",
|
|
"pruefung.durchfuehren",
|
|
"atemschutz.lesen",
|
|
"atemschutz.bearbeiten",
|
|
"atemschutz.ueberwachung",
|
|
"datei.hochladen",
|
|
],
|
|
|
|
KASSIER: [...GRUNDRECHTE, "kasse.lesen", "kasse.bearbeiten", "kasse.abschluss", "datei.hochladen"],
|
|
|
|
SCHRIFTFUEHRER: [
|
|
...GRUNDRECHTE,
|
|
"person.bearbeiten",
|
|
"einsatz.bearbeiten",
|
|
"einsatz.bericht",
|
|
"ausbildung.bearbeiten",
|
|
"datei.hochladen",
|
|
],
|
|
|
|
AUSBILDER: [
|
|
...GRUNDRECHTE,
|
|
"ausbildung.bearbeiten",
|
|
"qualifikation.bearbeiten",
|
|
"datei.hochladen",
|
|
],
|
|
|
|
MITGLIED: [...GRUNDRECHTE],
|
|
|
|
// Das Terminal darf bewusst fast nichts. Wer daran arbeitet, meldet sich
|
|
// zusätzlich mit PIN oder Ausweis an und handelt dann unter seiner Person.
|
|
KIOSK: ["kiosk.anmelden", "kiosk.erfassen", "organisation.lesen"],
|
|
};
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Sitzung und Prüffunktionen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/** Was die Anwendung über den gerade Angemeldeten weiß. */
|
|
export interface Sitzung {
|
|
personId: string;
|
|
organisationId: string;
|
|
name: string;
|
|
rollen: Rolle[];
|
|
/** Wie die Anmeldung zustande kam — relevant fürs Audit und für den Kiosk. */
|
|
anmeldeArt: "LOKAL" | "OIDC" | "KIOSK_PIN" | "KIOSK_RFID" | "SYSTEM";
|
|
}
|
|
|
|
/** Fehler, den die tRPC-Schicht in ein FORBIDDEN übersetzt. */
|
|
export class KeinZugriffFehler extends Error {
|
|
constructor(
|
|
public readonly benoetigtesRecht: Recht | Recht[],
|
|
nachricht?: string,
|
|
) {
|
|
const rechte = Array.isArray(benoetigtesRecht)
|
|
? benoetigtesRecht.join(", ")
|
|
: benoetigtesRecht;
|
|
super(nachricht ?? `Fehlende Berechtigung: ${rechte}`);
|
|
this.name = "KeinZugriffFehler";
|
|
}
|
|
}
|
|
|
|
/** Alle Rechte, die sich aus den Rollen einer Sitzung ergeben. */
|
|
export function rechteVon(rollen: readonly Rolle[]): Set<Recht> {
|
|
if (rollen.includes("SUPERADMIN")) return new Set(ALLE_RECHTE);
|
|
|
|
const gesammelt = new Set<Recht>();
|
|
for (const rolle of rollen) {
|
|
if (rolle === "SUPERADMIN") continue;
|
|
for (const recht of ROLLEN_RECHTE[rolle] ?? []) gesammelt.add(recht);
|
|
}
|
|
return gesammelt;
|
|
}
|
|
|
|
/** Hat die Sitzung das Recht? Der Administrator hat immer alles. */
|
|
export function hatRecht(sitzung: Sitzung | null, recht: Recht): boolean {
|
|
if (!sitzung) return false;
|
|
if (sitzung.rollen.includes("SUPERADMIN")) return true;
|
|
return rechteVon(sitzung.rollen).has(recht);
|
|
}
|
|
|
|
/** Mindestens eines der Rechte. */
|
|
export function hatEinesDerRechte(sitzung: Sitzung | null, rechte: Recht[]): boolean {
|
|
return rechte.some((recht) => hatRecht(sitzung, recht));
|
|
}
|
|
|
|
/** Alle genannten Rechte. */
|
|
export function hatAlleRechte(sitzung: Sitzung | null, rechte: Recht[]): boolean {
|
|
return rechte.every((recht) => hatRecht(sitzung, recht));
|
|
}
|
|
|
|
/** Wirft, wenn das Recht fehlt. Für Server-Actions und tRPC-Prozeduren. */
|
|
export function verlangeRecht(sitzung: Sitzung | null, recht: Recht): asserts sitzung is Sitzung {
|
|
if (!hatRecht(sitzung, recht)) throw new KeinZugriffFehler(recht);
|
|
}
|
|
|
|
/** Wirft, wenn keines der Rechte vorliegt. */
|
|
export function verlangeEinesDerRechte(
|
|
sitzung: Sitzung | null,
|
|
rechte: Recht[],
|
|
): asserts sitzung is Sitzung {
|
|
if (!hatEinesDerRechte(sitzung, rechte)) throw new KeinZugriffFehler(rechte);
|
|
}
|
|
|
|
/** Hat die Sitzung eine dieser Rollen? Nur für Anzeigezwecke gedacht. */
|
|
export function hatRolle(sitzung: Sitzung | null, ...rollen: Rolle[]): boolean {
|
|
if (!sitzung) return false;
|
|
return rollen.some((rolle) => sitzung.rollen.includes(rolle));
|
|
}
|
|
|
|
/**
|
|
* Darf die Sitzung auf diesen Datensatz zugreifen? Ergänzt die Mandanten-
|
|
* trennung der Datenbank um eine Prüfung im Code — doppelt hält besser, und
|
|
* die Fehlermeldung ist verständlicher als ein leeres Ergebnis.
|
|
*/
|
|
export function gehoertZurOrganisation(
|
|
sitzung: Sitzung | null,
|
|
datensatz: { organisationId: string } | null | undefined,
|
|
): boolean {
|
|
if (!sitzung || !datensatz) return false;
|
|
return sitzung.organisationId === datensatz.organisationId;
|
|
}
|
|
|
|
/**
|
|
* Die eigenen Daten darf jeder sehen, auch ohne person.lesen. Andere nur mit
|
|
* dem entsprechenden Recht.
|
|
*/
|
|
export function darfPersonSehen(sitzung: Sitzung | null, personId: string): boolean {
|
|
if (!sitzung) return false;
|
|
if (sitzung.personId === personId) return true;
|
|
return hatRecht(sitzung, "person.lesen");
|
|
}
|
|
|
|
/** Wandelt einen beliebigen Text in eine gültige Rolle um, sonst null. */
|
|
export function alsRolle(wert: string | null | undefined): Rolle | null {
|
|
if (!wert) return null;
|
|
const gross = wert.trim().toUpperCase();
|
|
return (ROLLEN as readonly string[]).includes(gross) ? (gross as Rolle) : null;
|
|
}
|