Rollen-differenzierte Verwaltungs-UI (Material-/Leitungsverantwortliche)
CI / backend-tests (push) Failing after 1m19s
CI / frontend-build (push) Successful in 23s

Bug: Frontend prüfte nur istAdmin, Materialverantwortliche und Leitungs-
verantwortliche sahen exakt dieselbe UI wie Mitarbeiter - obwohl das Backend
längst granular unterscheidet (require_roles je Endpunkt).

- AuthContext gibt volle Rollenliste + istVerantwortlich/istMaterial-
  verantwortlich/istLeitungsverantwortlich weiter.
- /admin-Route für alle drei Rollen offen, AdminPage filtert Tabs nach
  Rolle (Material/Leitung: Objekte-Pflege, Kontrollverantwortung,
  Änderungslog; nur Administration: Stammdaten/Benutzer/Eskalation/
  Zuständigkeit).
- ObjektSection blendet Anlegen/Duplizieren/Kopplung für Nicht-Admins aus
  (waren admin-only im Backend, hätten 403 geworfen).
- Promise.allSettled statt Promise.all beim Laden, damit ein admin-only
  Endpunkt (z.B. /benutzer) nicht die ganze Seite für niedrigere Rollen
  blockiert.
- Backend: GET /benutzer jetzt auch für Material-/Leitungsverantwortliche
  lesbar (für Kontrollverantwortung-Auswahl im Frontend nötig), Schreiben
  bleibt admin-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-04 22:25:32 +02:00
co-authored by Claude Sonnet 5
parent cf97b2e13d
commit 7f5bbb65d5
8 changed files with 180 additions and 51 deletions
+24
View File
@@ -3433,3 +3433,27 @@ Keine Commits in dieser Session.
- ergebnisse/material_bereinigung_log.md | 17 ++++++++++-------
---
## 2026-09-04 22:07 22:08 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- cf97b2e Materialstamm: Nachkontrolle bestätigt sauber, 6 Namen getrimmt
### Geänderte Dateien
- DEVLOG.md | 13 +++++++++++++
- ergebnisse/material_bereinigung_log.md | 8 ++++++++
---
## 2026-09-04 22:20 22:25 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 13 +++++++++++++
- ergebnisse/material_bereinigung_log.md | 8 ++++++++
---
+5 -1
View File
@@ -11,10 +11,14 @@ from app.schemas.benutzer import BenutzerCreate, BenutzerRead, BenutzerUpdate
router = APIRouter()
_admin_only = require_roles(RolleTyp.administration)
# Lesend auch für Material-/Leitungsverantwortliche (Rollen-Vorgabe): brauchen
# die Liste u.a. für Kontrollverantwortung/Zuständigkeit-Auswahl im Frontend -
# Anlegen/Ändern bleibt admin-only.
_lesend = require_roles(RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher)
@router.get("/benutzer", response_model=list[BenutzerRead])
async def liste_benutzer(db: AsyncSession = Depends(get_db), _=Depends(_admin_only)) -> list[BenutzerRead]:
async def liste_benutzer(db: AsyncSession = Depends(get_db), _=Depends(_lesend)) -> list[BenutzerRead]:
result = await db.execute(select(Benutzer))
return [BenutzerRead.from_orm_benutzer(b) for b in result.scalars().all()]
+37
View File
@@ -54,3 +54,40 @@ async def test_admin_can_update_rollen(client, admin_user, mitarbeiter_user):
)
assert response.status_code == 200
assert response.json()["rollen"] == ["leitungsverantwortlicher"]
@pytest.mark.asyncio
async def test_materialverantwortlicher_darf_benutzer_lesen_nicht_anlegen(
client, materialverantwortlicher_user
):
"""Rollen-Vorgabe: Material-/Leitungsverantwortliche brauchen die Liste
lesend (z.B. Kontrollverantwortung-Auswahl im Frontend), aber kein
Schreibrecht - anders als Administration."""
token = await login(client, "materialverantwortlicher1")
gelesen = await client.get("/api/v1/benutzer", headers=auth_header(token))
assert gelesen.status_code == 200
angelegt = await client.post(
"/api/v1/benutzer",
json={"name": "X", "login": "x1", "passwort": "sicheres-passwort", "rollen": ["mitarbeiter"]},
headers=auth_header(token),
)
assert angelegt.status_code == 403
@pytest.mark.asyncio
async def test_leitungsverantwortlicher_darf_benutzer_lesen(client, db_session):
from app.core.security import hash_password
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp
benutzer = Benutzer(
name="Test Leitung", login="leitung1", passwort_hash=hash_password("test-passwort-123"), aktiv=True
)
db_session.add(benutzer)
await db_session.flush()
db_session.add(BenutzerRolle(benutzer_id=benutzer.id, rolle=RolleTyp.leitungsverantwortlicher))
await db_session.flush()
token = await login(client, "leitung1")
response = await client.get("/api/v1/benutzer", headers=auth_header(token))
assert response.status_code == 200
+5 -2
View File
@@ -17,11 +17,14 @@ function GeschuetzteRoute({ children }: { children: ReactNode }) {
}
function AdminRoute({ children }: { children: ReactNode }) {
const { eingeloggt, istAdmin } = useAuth();
const { eingeloggt, istVerantwortlich } = useAuth();
if (!eingeloggt) {
return <Navigate to="/login" replace />;
}
if (!istAdmin) {
// Nicht mehr admin-exklusiv: Materialverantwortliche/Leitungsverantwortliche
// dürfen rein, sehen dort aber nur die für sie freigeschalteten Tabs
// (AdminPage filtert selbst nach Rolle, Backend erzwingt es ohnehin je Endpunkt).
if (!istVerantwortlich) {
return <Navigate to="/objekte" replace />;
}
return <AppShell>{children}</AppShell>;
+15
View File
@@ -5,7 +5,15 @@ import type { Me } from "../api/types";
interface AuthContextValue {
eingeloggt: boolean;
rollen: string[];
istAdmin: boolean;
// Rollen-Vorgabe: Materialverantwortliche/Leitungsverantwortliche sahen bisher
// exakt dieselbe UI wie Mitarbeiter (nur istAdmin wurde geprüft), obwohl das
// Backend längst granular unterscheidet (require_roles je Endpunkt). Diese
// beiden Flags spiegeln die Backend-Rollenprüfung ins Frontend.
istMaterialverantwortlich: boolean;
istLeitungsverantwortlich: boolean;
istVerantwortlich: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
@@ -29,7 +37,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const value = useMemo<AuthContextValue>(
() => ({
eingeloggt,
rollen,
istAdmin: rollen.includes("administration"),
istMaterialverantwortlich: rollen.includes("materialverantwortlicher"),
istLeitungsverantwortlich: rollen.includes("leitungsverantwortlicher"),
istVerantwortlich:
rollen.includes("administration") ||
rollen.includes("materialverantwortlicher") ||
rollen.includes("leitungsverantwortlicher"),
login: async (username: string, password: string) => {
const token = await apiLogin(username, password);
setAuthToken(token);
+3 -3
View File
@@ -5,7 +5,7 @@ import { useAuth } from "../auth/AuthContext";
/** Gemeinsamer Rahmen (Kopfzeile + Navigation) für alle eingeloggten Screens. */
export function AppShell({ children }: { children: ReactNode }) {
const { istAdmin, logout } = useAuth();
const { istAdmin, istVerantwortlich, logout } = useAuth();
const navigate = useNavigate();
function handleLogout() {
@@ -23,9 +23,9 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavLink to="/objekte" className={({ isActive }) => (isActive ? "active" : "")}>
Objekte
</NavLink>
{istAdmin && (
{istVerantwortlich && (
<NavLink to="/admin" className={({ isActive }) => (isActive ? "active" : "")}>
Administration
{istAdmin ? "Administration" : "Verwaltung"}
</NavLink>
)}
<button className="btn btn-secondary" onClick={handleLogout}>
+59 -35
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { Benutzer, Bereich, Beladungsvorlage, Fach, Kategorie, Material, Objekt, Objekttyp, Standort } from "../api/types";
import { BenutzerSection } from "./admin/BenutzerSection";
import { BereichSection } from "./admin/BereichSection";
@@ -28,17 +29,29 @@ type Tab =
| "kontrollverantwortung"
| "zustaendigkeit";
const TABS: { key: Tab; label: string }[] = [
{ key: "objekte", label: "Objekte" },
{ key: "vorlagen", label: "Vorlagen" },
{ key: "material", label: "Material" },
{ key: "struktur", label: "Struktur" },
{ key: "standorte", label: "Standorte" },
{ key: "benutzer", label: "Benutzer" },
{ key: "zustaendigkeit", label: "Zuständigkeit" },
{ key: "kontrollverantwortung", label: "Kontrollverantwortung" },
{ key: "historie", label: "Änderungslog" },
{ key: "eskalation", label: "Eskalation" },
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
// (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt
// dasselbe wie Mitarbeiter - hier je Tab, wer laut Backend tatsächlich
// etwas damit anfangen kann).
const TABS: { key: Tab; label: string; rollen: ("administration" | "materialverantwortlicher" | "leitungsverantwortlicher")[] }[] = [
{ key: "objekte", label: "Objekte", rollen: ["administration", "materialverantwortlicher"] },
{ key: "vorlagen", label: "Vorlagen", rollen: ["administration", "materialverantwortlicher"] },
{ key: "material", label: "Material", rollen: ["administration"] },
{ key: "struktur", label: "Struktur", rollen: ["administration"] },
{ key: "standorte", label: "Standorte", rollen: ["administration"] },
{ key: "benutzer", label: "Benutzer", rollen: ["administration"] },
{ key: "zustaendigkeit", label: "Zuständigkeit", rollen: ["administration"] },
{
key: "kontrollverantwortung",
label: "Kontrollverantwortung",
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{
key: "historie",
label: "Änderungslog",
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{ key: "eskalation", label: "Eskalation", rollen: ["administration"] },
];
/**
@@ -51,6 +64,8 @@ const TABS: { key: Tab; label: string }[] = [
* Monolith-Dateien).
*/
export function AdminPage() {
const { rollen, istAdmin } = useAuth();
const sichtbareTabs = TABS.filter((t) => t.rollen.some((r) => rollen.includes(r)));
const [bereiche, setBereiche] = useState<Bereich[]>([]);
const [kategorien, setKategorien] = useState<Kategorie[]>([]);
const [objekttypen, setObjekttypen] = useState<Objekttyp[]>([]);
@@ -62,10 +77,16 @@ export function AdminPage() {
const [faecher, setFaecher] = useState<Fach[]>([]);
const [fehler, setFehler] = useState<string | null>(null);
const [laedt, setLaedt] = useState(true);
const [tab, setTab] = useState<Tab>("objekte");
const [tab, setTab] = useState<Tab | null>(null);
const aktiverTab = tab && sichtbareTabs.some((t) => t.key === tab) ? tab : sichtbareTabs[0]?.key ?? null;
async function ladeAlles() {
const [be, ka, ot, s, v, o, m, b, f] = await Promise.all([
// /benutzer ist admin-only (Backend require_roles) - Promise.all würde bei
// Material-/Leitungsverantwortlichen mit 403 durchfallen und die komplette
// Seite als "nicht geladen" zeigen, obwohl deren Tabs (z.B. Kontroll-
// verantwortung) trotzdem funktionieren sollen. allSettled + leerer
// Fallback statt hartem Fehler.
const [be, ka, ot, s, v, o, m, b, f] = await Promise.allSettled([
apiRequest<Bereich[]>("/bereiche"),
apiRequest<Kategorie[]>("/kategorien"),
apiRequest<Objekttyp[]>("/objekttypen"),
@@ -76,15 +97,16 @@ export function AdminPage() {
apiRequest<Benutzer[]>("/benutzer"),
apiRequest<Fach[]>("/faecher"),
]);
setBereiche(be);
setKategorien(ka);
setObjekttypen(ot);
setStandorte(s);
setVorlagen(v);
setObjekte(o);
setMaterialien(m);
setBenutzer(b);
setFaecher(f);
const wert = <T,>(r: PromiseSettledResult<T[]>): T[] => (r.status === "fulfilled" ? r.value : []);
setBereiche(wert(be));
setKategorien(wert(ka));
setObjekttypen(wert(ot));
setStandorte(wert(s));
setVorlagen(wert(v));
setObjekte(wert(o));
setMaterialien(wert(m));
setBenutzer(wert(b));
setFaecher(wert(f));
}
useEffect(() => {
@@ -103,14 +125,14 @@ export function AdminPage() {
return (
<main className="page">
<h1>Administration</h1>
<h1>{istAdmin ? "Administration" : "Verwaltung"}</h1>
{fehler && <div className="alert alert-danger">{fehler}</div>}
<nav className="row" style={{ marginBottom: "1.25rem", flexWrap: "wrap", gap: "0.5rem" }}>
{TABS.map((t) => (
{sichtbareTabs.map((t) => (
<button
key={t.key}
className={tab === t.key ? "btn btn-primary" : "btn btn-secondary"}
className={aktiverTab === t.key ? "btn btn-primary" : "btn btn-secondary"}
onClick={() => setTab(t.key)}
>
{t.label}
@@ -118,17 +140,18 @@ export function AdminPage() {
))}
</nav>
{tab === "objekte" && (
{aktiverTab === "objekte" && (
<ObjektSection
objekte={objekte}
vorlagen={vorlagen.filter((v) => v.status === "aktiv")}
standorte={standorte}
materialien={materialien}
nurPflegen={!istAdmin}
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
)}
{tab === "vorlagen" && (
{aktiverTab === "vorlagen" && (
<VorlageSection
vorlagen={vorlagen}
objekttypen={objekttypen}
@@ -138,10 +161,10 @@ export function AdminPage() {
onFehler={setFehler}
/>
)}
{tab === "material" && (
{aktiverTab === "material" && (
<MaterialSection materialien={materialien} onGeaendert={ladeAlles} onFehler={setFehler} />
)}
{tab === "struktur" && (
{aktiverTab === "struktur" && (
<>
<BereichSection bereiche={bereiche} onGeaendert={ladeAlles} onFehler={setFehler} />
<KategorieSection
@@ -165,20 +188,21 @@ export function AdminPage() {
/>
</>
)}
{tab === "standorte" && (
{aktiverTab === "standorte" && (
<StandortSection standorte={standorte} onGeaendert={ladeAlles} onFehler={setFehler} />
)}
{tab === "benutzer" && (
{aktiverTab === "benutzer" && (
<BenutzerSection benutzer={benutzer} onGeaendert={ladeAlles} onFehler={setFehler} />
)}
{tab === "zustaendigkeit" && (
{aktiverTab === "zustaendigkeit" && (
<ZustaendigkeitSection standorte={standorte} objekte={objekte} benutzer={benutzer} onFehler={setFehler} />
)}
{tab === "kontrollverantwortung" && (
{aktiverTab === "kontrollverantwortung" && (
<KontrollverantwortungSection objekte={objekte} benutzer={benutzer} onFehler={setFehler} />
)}
{tab === "historie" && <HistorieSection onFehler={setFehler} />}
{tab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "historie" && <HistorieSection onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === null && <p className="text-muted">Keine Bereiche für deine Rolle verfügbar.</p>}
</main>
);
}
+32 -10
View File
@@ -9,11 +9,25 @@ interface Props {
vorlagen: Beladungsvorlage[];
standorte: Standort[];
materialien: Material[];
// Materialverantwortliche dürfen laut Backend nur Objektpositionen pflegen
// (Ablauf/Charge/SN/Prüfintervall/Geräte-Instanzen), nicht Objekte anlegen/
// duplizieren/koppeln (require_roles(administration) dort) - Anlegen-Formular
// und Duplizieren/Kopplung blenden wir entsprechend aus statt sie ins Leere
// laufen zu lassen (403).
nurPflegen?: boolean;
onGeaendert: () => Promise<void>;
onFehler: (text: string) => void;
}
export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGeaendert, onFehler }: Props) {
export function ObjektSection({
objekte,
vorlagen,
standorte,
materialien,
nurPflegen = false,
onGeaendert,
onFehler,
}: Props) {
const [praefix, setPraefix] = useState("");
const [code, setCode] = useState("");
const [name, setName] = useState("");
@@ -142,8 +156,9 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
return (
<section className="section">
<h2>Objekt aus Vorlage anlegen</h2>
<div className="card">
<h2>{nurPflegen ? "Objekte" : "Objekt aus Vorlage anlegen"}</h2>
{!nurPflegen && (
<div className="card" style={{ marginBottom: "1.25rem" }}>
{vorlagen.length === 0 ? (
<p className="text-muted">
Keine aktive Vorlage vorhanden erst Excel-Import oder Vorlage per API anlegen.
@@ -216,9 +231,12 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
</button>
</div>
)}
</div>
)}
<div className="card">
{objekte.length > 0 && (
<ul className="card-list" style={{ marginTop: "1.25rem" }}>
<ul className="card-list">
{objekte.map((o) => (
<li key={o.id}>
<div className="row-between" style={{ padding: "0.4rem 0" }}>
@@ -235,12 +253,16 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
>
{ausgeklapptId === o.id ? "Positionen ausblenden" : "Ablauf/Charge/SN pflegen"}
</button>
<button className="btn btn-secondary" onClick={() => kopplungBearbeiten(o)}>
{kopplungBearbeitenId === o.id ? "Schließen" : "Kopplung ändern"}
</button>
<button className="btn btn-secondary" onClick={() => duplizierenStarten(o)}>
{duplizierId === o.id ? "Schließen" : "Duplizieren"}
</button>
{!nurPflegen && (
<button className="btn btn-secondary" onClick={() => kopplungBearbeiten(o)}>
{kopplungBearbeitenId === o.id ? "Schließen" : "Kopplung ändern"}
</button>
)}
{!nurPflegen && (
<button className="btn btn-secondary" onClick={() => duplizierenStarten(o)}>
{duplizierId === o.id ? "Schließen" : "Duplizieren"}
</button>
)}
</div>
</div>
{kopplungBearbeitenId === o.id && (