feat(rollen): granulares Rechte-System (Roadmap Phase 6, letztes Modul)
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:
@@ -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;
|
||||
|
||||
@@ -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" && (
|
||||
<>
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user