Rollen-differenzierte Verwaltungs-UI (Material-/Leitungsverantwortliche)
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:
@@ -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 ++++++++
|
||||
|
||||
---
|
||||
|
||||
@@ -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()]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user