feat(rollen): granulares Rechte-System (Roadmap Phase 6, letztes Modul)
CI / backend-tests (push) Failing after 1m55s
CI / frontend-build (push) Successful in 19s

Additiv neben dem bestehenden 4-Rollen-System (mitarbeiter/material-
verantwortlicher/leitungsverantwortlicher/administration bleiben unverändert,
kein Breaking Change): Admin kann jetzt eigene Rollen (z.B. "Materialwart",
"Helfer" aus der Ursprungs-Anforderung) mit frei wählbaren Einzelrechten aus
einem Berechtigungs-Katalog anlegen und Benutzern zuweisen - auch Benutzern
ganz ohne feste RolleTyp-Zuordnung.

Neue Tabellen: berechtigung (Katalog), rolle (custom, admin-anlegbar),
rolle_berechtigung (M:N), benutzer_rolle_zuordnung (M:N, eigene Tabelle statt
Wiederverwendung des ENUM-basierten benutzer_rolle).

require_roles_or_permission() kombiniert beide Systeme: bestehende feste
Rollen ODER eine passende granulare Berechtigung. Auf die vom Nutzer genannten
Beispiel-Endpunkte angewendet: Material anlegen/bearbeiten, Lagerbewegung
durchführen, Mangel melden/lesen/bearbeiten, Prüfung durchführen - weitere
Endpunkte folgen bei Bedarf nach demselben Muster (require_permission()/
require_roles_or_permission() stehen jetzt als Bausteine bereit).

Neue Endpunkte: GET /berechtigungen, CRUD /rollen, PUT/DELETE
/rollen/{id}/berechtigungen/{id}, PUT/DELETE /benutzer/{id}/rollen/{id}.

Frontend: neuer Admin-Tab "Rollen & Rechte" (Rolle anlegen, Rechte togglen,
Benutzer zuweisen/entfernen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
This commit is contained in:
2026-09-05 14:07:03 +02:00
co-authored by Claude Sonnet 5
parent dbbbd0940b
commit c56fc285de
15 changed files with 740 additions and 17 deletions
+13
View File
@@ -238,6 +238,19 @@ export interface Benutzer {
einheit_id: number | null;
}
export interface Berechtigung {
id: number;
schluessel: string;
beschreibung: string;
}
export interface CustomRolle {
id: number;
name: string;
beschreibung: string | null;
berechtigung_ids: number[];
}
export interface Einheit {
id: number;
name: string;
+5 -1
View File
@@ -11,6 +11,7 @@ import { FachSection } from "./admin/FachSection";
import { HistorieSection } from "./admin/HistorieSection";
import { KategorieSection } from "./admin/KategorieSection";
import { LagerbewegungSection } from "./admin/LagerbewegungSection";
import { RolleSection } from "./admin/RolleSection";
import { KontrollverantwortungSection } from "./admin/KontrollverantwortungSection";
import { MaterialSection } from "./admin/MaterialSection";
import { ObjektSection } from "./admin/ObjektSection";
@@ -32,7 +33,8 @@ type Tab =
| "kontrollverantwortung"
| "zustaendigkeit"
| "personal"
| "lagerbewegungen";
| "lagerbewegungen"
| "rollen";
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
// (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt
@@ -63,6 +65,7 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
label: "Lagerbewegungen",
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
];
/**
@@ -222,6 +225,7 @@ export function AdminPage() {
{aktiverTab === "lagerbewegungen" && (
<LagerbewegungSection objekte={objekte} standorte={standorte} onFehler={setFehler} />
)}
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
<>
+205
View File
@@ -0,0 +1,205 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { Benutzer, Berechtigung, CustomRolle } from "../../api/types";
interface Props {
benutzer: Benutzer[];
onFehler: (text: string) => void;
}
// Granulares Rechte-System (Roadmap Phase 6): additiv neben dem bestehenden
// 4-Rollen-System - Admin kann hier eigene Rollen (z.B. "Materialwart",
// "Helfer") mit frei wählbaren Einzelrechten anlegen und Benutzern zuweisen.
export function RolleSection({ benutzer, onFehler }: Props) {
const [rollen, setRollen] = useState<CustomRolle[]>([]);
const [berechtigungen, setBerechtigungen] = useState<Berechtigung[]>([]);
const [name, setName] = useState("");
const [beschreibung, setBeschreibung] = useState("");
const [laedt, setLaedt] = useState(true);
const [ausgeklapptId, setAusgeklapptId] = useState<number | null>(null);
const [zugeordneteBenutzer, setZugeordneteBenutzer] = useState<Record<number, number[]>>({});
const [neuerBenutzerId, setNeuerBenutzerId] = useState<number | "">("");
async function laden() {
setLaedt(true);
try {
const [r, b] = await Promise.all([
apiRequest<CustomRolle[]>("/rollen"),
apiRequest<Berechtigung[]>("/berechtigungen"),
]);
setRollen(r);
setBerechtigungen(b);
} catch {
onFehler("Rollen konnten nicht geladen werden.");
} finally {
setLaedt(false);
}
}
useEffect(() => {
laden();
}, []);
async function ladeZuordnungen(rolleId: number) {
const treffer = benutzer.filter((b) => zugeordneteBenutzer[rolleId]?.includes(b.id));
if (treffer.length > 0 || zugeordneteBenutzer[rolleId]) return;
const ergebnisse = await Promise.all(
benutzer.map(async (b) => {
const ids = await apiRequest<number[]>(`/benutzer/${b.id}/rollen`).catch((): number[] => []);
return ids.includes(rolleId) ? b.id : null;
})
);
setZugeordneteBenutzer((z) => ({ ...z, [rolleId]: ergebnisse.filter((x): x is number => x !== null) }));
}
function ausklappen(rolleId: number) {
if (ausgeklapptId === rolleId) {
setAusgeklapptId(null);
return;
}
setAusgeklapptId(rolleId);
ladeZuordnungen(rolleId);
}
async function anlegen() {
try {
await apiRequest("/rollen", { method: "POST", body: { name, beschreibung: beschreibung || null } });
setName("");
setBeschreibung("");
await laden();
} catch {
onFehler("Rolle konnte nicht angelegt werden (Name evtl. schon vergeben).");
}
}
async function berechtigungUmschalten(rolle: CustomRolle, berechtigungId: number) {
const hat = rolle.berechtigung_ids.includes(berechtigungId);
try {
await apiRequest(`/rollen/${rolle.id}/berechtigungen/${berechtigungId}`, {
method: hat ? "DELETE" : "PUT",
});
await laden();
} catch {
onFehler("Berechtigung konnte nicht geändert werden.");
}
}
async function benutzerHinzufuegen(rolleId: number) {
if (neuerBenutzerId === "") return;
try {
await apiRequest(`/benutzer/${neuerBenutzerId}/rollen/${rolleId}`, { method: "PUT" });
setZugeordneteBenutzer((z) => ({ ...z, [rolleId]: [...(z[rolleId] ?? []), neuerBenutzerId as number] }));
setNeuerBenutzerId("");
} catch {
onFehler("Benutzer konnte der Rolle nicht zugeordnet werden.");
}
}
async function benutzerEntfernen(rolleId: number, benutzerId: number) {
try {
await apiRequest(`/benutzer/${benutzerId}/rollen/${rolleId}`, { method: "DELETE" });
setZugeordneteBenutzer((z) => ({ ...z, [rolleId]: (z[rolleId] ?? []).filter((id) => id !== benutzerId) }));
} catch {
onFehler("Benutzer konnte nicht entfernt werden.");
}
}
return (
<section className="section">
<h2>Rollen &amp; Rechte</h2>
<div className="card">
<div className="row" style={{ marginBottom: "1rem", flexWrap: "wrap" }}>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Rollenname (z.B. Materialwart)" />
<input
className="input"
value={beschreibung}
onChange={(e) => setBeschreibung(e.target.value)}
placeholder="Beschreibung (optional)"
/>
<button className="btn btn-primary" onClick={anlegen} disabled={!name}>
Anlegen
</button>
</div>
{laedt && <p className="text-muted">Lade</p>}
{!laedt && rollen.length === 0 && <p className="text-muted">Noch keine eigenen Rollen angelegt.</p>}
{!laedt && rollen.length > 0 && (
<ul className="card-list">
{rollen.map((r) => (
<li key={r.id} style={{ padding: "0.4rem 0" }}>
<div className="row-between">
<span>
<strong>{r.name}</strong>
{r.beschreibung && <span className="text-muted"> {r.beschreibung}</span>}
</span>
<button className="btn btn-secondary" onClick={() => ausklappen(r.id)}>
{ausgeklapptId === r.id ? "Schließen" : "Bearbeiten"}
</button>
</div>
{ausgeklapptId === r.id && (
<div className="stack" style={{ marginTop: "0.5rem", paddingLeft: "1rem" }}>
<div>
<strong className="text-muted">Berechtigungen</strong>
<div className="row" style={{ flexWrap: "wrap", gap: "0.4rem", marginTop: "0.3rem" }}>
{berechtigungen.map((b) => (
<label key={b.id} className="row" style={{ gap: "0.3rem" }}>
<input
type="checkbox"
checked={r.berechtigung_ids.includes(b.id)}
onChange={() => berechtigungUmschalten(r, b.id)}
/>
{b.beschreibung}
</label>
))}
</div>
</div>
<div>
<strong className="text-muted">Zugewiesene Benutzer</strong>
<ul className="card-list">
{(zugeordneteBenutzer[r.id] ?? []).map((benutzerId) => {
const b = benutzer.find((x) => x.id === benutzerId);
return (
<li key={benutzerId} className="row-between" style={{ padding: "0.2rem 0" }}>
<span>{b ? `${b.name} (${b.login})` : `#${benutzerId}`}</span>
<button
className="btn btn-secondary"
onClick={() => benutzerEntfernen(r.id, benutzerId)}
>
Entfernen
</button>
</li>
);
})}
</ul>
<div className="row" style={{ marginTop: "0.3rem" }}>
<select
className="input"
value={neuerBenutzerId}
onChange={(e) => setNeuerBenutzerId(Number(e.target.value) || "")}
>
<option value="">Benutzer wählen</option>
{benutzer
.filter((b) => !(zugeordneteBenutzer[r.id] ?? []).includes(b.id))
.map((b) => (
<option key={b.id} value={b.id}>
{b.name} ({b.login})
</option>
))}
</select>
<button className="btn btn-primary" onClick={() => benutzerHinzufuegen(r.id)}>
Hinzufügen
</button>
</div>
</div>
</div>
)}
</li>
))}
</ul>
)}
</div>
</section>
);
}