diff --git a/DEVLOG.md b/DEVLOG.md index cd2ab7b..959ec11 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 ++++++++ + +--- diff --git a/backend/app/api/v1/endpoints/benutzer.py b/backend/app/api/v1/endpoints/benutzer.py index b955838..f7508ba 100644 --- a/backend/app/api/v1/endpoints/benutzer.py +++ b/backend/app/api/v1/endpoints/benutzer.py @@ -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()] diff --git a/backend/tests/test_benutzer_admin.py b/backend/tests/test_benutzer_admin.py index f268bca..77ccb90 100644 --- a/backend/tests/test_benutzer_admin.py +++ b/backend/tests/test_benutzer_admin.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3ec3082..cd5345f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ; } - 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 ; } return {children}; diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index f84400b..676b592 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -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; logout: () => void; } @@ -29,7 +37,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ 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); diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 75ce031..5f9a6b9 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -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 }) { (isActive ? "active" : "")}> Objekte - {istAdmin && ( + {istVerantwortlich && ( (isActive ? "active" : "")}> - Administration + {istAdmin ? "Administration" : "Verwaltung"} )} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 3642d7e..05e0f92 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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([]); const [kategorien, setKategorien] = useState([]); const [objekttypen, setObjekttypen] = useState([]); @@ -62,10 +77,16 @@ export function AdminPage() { const [faecher, setFaecher] = useState([]); const [fehler, setFehler] = useState(null); const [laedt, setLaedt] = useState(true); - const [tab, setTab] = useState("objekte"); + const [tab, setTab] = useState(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("/bereiche"), apiRequest("/kategorien"), apiRequest("/objekttypen"), @@ -76,15 +97,16 @@ export function AdminPage() { apiRequest("/benutzer"), apiRequest("/faecher"), ]); - setBereiche(be); - setKategorien(ka); - setObjekttypen(ot); - setStandorte(s); - setVorlagen(v); - setObjekte(o); - setMaterialien(m); - setBenutzer(b); - setFaecher(f); + const wert = (r: PromiseSettledResult): 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 ( - Administration + {istAdmin ? "Administration" : "Verwaltung"} {fehler && {fehler}} - {TABS.map((t) => ( + {sichtbareTabs.map((t) => ( setTab(t.key)} > {t.label} @@ -118,17 +140,18 @@ export function AdminPage() { ))} - {tab === "objekte" && ( + {aktiverTab === "objekte" && ( v.status === "aktiv")} standorte={standorte} materialien={materialien} + nurPflegen={!istAdmin} onGeaendert={ladeAlles} onFehler={setFehler} /> )} - {tab === "vorlagen" && ( + {aktiverTab === "vorlagen" && ( )} - {tab === "material" && ( + {aktiverTab === "material" && ( )} - {tab === "struktur" && ( + {aktiverTab === "struktur" && ( <> > )} - {tab === "standorte" && ( + {aktiverTab === "standorte" && ( )} - {tab === "benutzer" && ( + {aktiverTab === "benutzer" && ( )} - {tab === "zustaendigkeit" && ( + {aktiverTab === "zustaendigkeit" && ( )} - {tab === "kontrollverantwortung" && ( + {aktiverTab === "kontrollverantwortung" && ( )} - {tab === "historie" && } - {tab === "eskalation" && } + {aktiverTab === "historie" && } + {aktiverTab === "eskalation" && } + {aktiverTab === null && Keine Bereiche für deine Rolle verfügbar.} ); } diff --git a/frontend/src/pages/admin/ObjektSection.tsx b/frontend/src/pages/admin/ObjektSection.tsx index 173f885..708f976 100644 --- a/frontend/src/pages/admin/ObjektSection.tsx +++ b/frontend/src/pages/admin/ObjektSection.tsx @@ -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; 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 ( - Objekt aus Vorlage anlegen - + {nurPflegen ? "Objekte" : "Objekt aus Vorlage anlegen"} + {!nurPflegen && ( + {vorlagen.length === 0 ? ( Keine aktive Vorlage vorhanden – erst Excel-Import oder Vorlage per API anlegen. @@ -216,9 +231,12 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea )} + + )} + {objekte.length > 0 && ( - + {objekte.map((o) => ( @@ -235,12 +253,16 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea > {ausgeklapptId === o.id ? "Positionen ausblenden" : "Ablauf/Charge/SN pflegen"} - kopplungBearbeiten(o)}> - {kopplungBearbeitenId === o.id ? "Schließen" : "Kopplung ändern"} - - duplizierenStarten(o)}> - {duplizierId === o.id ? "Schließen" : "Duplizieren"} - + {!nurPflegen && ( + kopplungBearbeiten(o)}> + {kopplungBearbeitenId === o.id ? "Schließen" : "Kopplung ändern"} + + )} + {!nurPflegen && ( + duplizierenStarten(o)}> + {duplizierId === o.id ? "Schließen" : "Duplizieren"} + + )} {kopplungBearbeitenId === o.id && (
Keine Bereiche für deine Rolle verfügbar.
Keine aktive Vorlage vorhanden – erst Excel-Import oder Vorlage per API anlegen. @@ -216,9 +231,12 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea