diff --git a/arbeitskacheln/17_zustaendigkeit.md b/arbeitskacheln/17_zustaendigkeit.md index ebc3351..e1992fa 100644 --- a/arbeitskacheln/17_zustaendigkeit.md +++ b/arbeitskacheln/17_zustaendigkeit.md @@ -104,8 +104,23 @@ war die Vorgängerlücke, diese hier ist die zweite gefundene Lücke). bestehende globale Zuordnungen (`standort_id IS NULL`) funktionieren unverändert. - **Tests:** Positivtest (Standort passt), Negativtest (falscher Standort abgelehnt), Regressionstest (bestehende globale Rechte weiterhin uneingeschränkt). -- **DoD:** offen — beide Design-Entscheidungen geklärt (2026-09-09), bereit für - Implementierung. **Referenz (Open-Source-Vergleich 2026-09-09):** keines der +- **DoD:** ✅ umgesetzt (2026-09-10). `benutzer_rolle_zuordnung.standort_id` + (Migration 0039, nullable FK), `hat_berechtigung_am_standort()` + + `pruefe_berechtigung_am_standort()` in `app/api/deps.py` (manuelle Prüfung + statt FastAPI-Dependency, da der Standort erst nach einem DB-Lookup im + Endpunkt bekannt ist — feste Rollen wie zuvor global). Konkret verdrahtet in + `PATCH /geraete/{geraet_id}` (Prüfung durchführen) als erstes reales + Beispiel; weitere standort-scopebare Endpunkte folgen bei Bedarf nach + demselben Muster (kein Big-Bang über alle `require_roles_or_permission`- + Stellen). `PUT /benutzer/{id}/rollen/{rolle_id}?standort_id=` (optional, + aktualisiert bestehende Zuordnung statt Duplikat), `RolleSection.tsx` + Standort-Auswahl ergänzt. `backend/tests/test_zust003_standort_scoping.py` + (Positiv-/Negativ-/Regressionstest). **Bekannte Einschränkung** (siehe + Modell-Docstring): eine Person kann pro Rolle nur EINEN Standort gleichzeitig + haben (Primärschlüssel bleibt (benutzer_id, rolle_id)) — der in der Kachel + skizzierte Mehrfach-Standort-Fall bräuchte einen Primärschlüssel-Umbau, kein + Akzeptanzkriterium dieser Kachel. **Referenz (Open-Source-Vergleich + 2026-09-09):** keines der verglichenen Projekte (InvenTree, Snipe-IT, Grocy, Shelf.nu, Resgrid, KP Front, FleetMS) hat ein sauber dokumentiertes Standort-/Objekt-Ebene-Rechtesystem — Snipe-IT nur grobes Company-Scoping, Shelf.nu nur personenbezogene Sichtbarkeits-Toggles. Wäre diff --git a/backend/alembic/versions/0039_standort_scoping.py b/backend/alembic/versions/0039_standort_scoping.py new file mode 100644 index 0000000..fed0587 --- /dev/null +++ b/backend/alembic/versions/0039_standort_scoping.py @@ -0,0 +1,25 @@ +"""ZUST-003: Standort-Scoping für granulare Rollen-Zuordnungen. + +Revision ID: 0039_standort_scoping +Revises: 0038_historie_hash +Create Date: 2026-09-10 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "0039_standort_scoping" +down_revision: Union[str, None] = "0038_historie_hash" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE benutzer_rolle_zuordnung ADD COLUMN standort_id INTEGER REFERENCES standort(id)" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE benutzer_rolle_zuordnung DROP COLUMN standort_id") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 3661efe..8fb3a59 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -64,6 +64,53 @@ async def _hat_berechtigung(db: AsyncSession, benutzer_id: int, schluessel: tupl return result.first() is not None +async def hat_berechtigung_am_standort( + db: AsyncSession, benutzer_id: int, schluessel: tuple[str, ...], standort_id: int +) -> bool: + """ZUST-003: wie `_hat_berechtigung`, zusätzlich mit Standort-Filter - + eine Zuordnung greift, wenn ihr `standort_id` NULL (global) ODER exakt der + angefragte Standort ist.""" + result = await db.execute( + select(Berechtigung.schluessel) + .join(RolleBerechtigung, RolleBerechtigung.berechtigung_id == Berechtigung.id) + .join(Rolle, Rolle.id == RolleBerechtigung.rolle_id) + .join(BenutzerRolleZuordnung, BenutzerRolleZuordnung.rolle_id == Rolle.id) + .where( + BenutzerRolleZuordnung.benutzer_id == benutzer_id, + Berechtigung.schluessel.in_(schluessel), + (BenutzerRolleZuordnung.standort_id.is_(None)) + | (BenutzerRolleZuordnung.standort_id == standort_id), + ) + ) + return result.first() is not None + + +async def pruefe_berechtigung_am_standort( + current_user: Benutzer, + db: AsyncSession, + *, + berechtigung: str, + standort_id: int, + erlaubte_rollen: tuple[RolleTyp, ...] = (), +) -> None: + """ZUST-003: manuelle Prüfung (kein FastAPI-Dependency) für Endpunkte, bei + denen der betroffene Standort erst nach einem DB-Lookup im Endpunkt selbst + bekannt ist (z.B. über Objektposition -> Objekt -> standort_id) - anders + als bei `require_roles_or_permission` lässt sich der Standort hier nicht + allein aus Pfad-/Query-Parametern ableiten. Feste Rollen (`RolleTyp`) bleiben + IMMER global (nicht Gegenstand dieser Kachel), nur die granulare + Custom-Rollen-Berechtigung wird standortgebunden geprüft.""" + besitzt = {RolleTyp(r) for r in current_user.rollen_namen} + if besitzt.intersection(erlaubte_rollen): + return + if await hat_berechtigung_am_standort(db, current_user.id, (berechtigung,), standort_id): + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Für diese Aktion fehlt die erforderliche Berechtigung an diesem Standort", + ) + + def require_permission(*erlaubte_berechtigungen: str) -> Callable: """Roadmap Phase 6 (granulares Rechte-System): prüft, ob eine der frei konfigurierbaren Rollen des Benutzers (app.models.permission.Rolle) eine diff --git a/backend/app/api/v1/endpoints/geraet_instanz.py b/backend/app/api/v1/endpoints/geraet_instanz.py index b07221b..3cd7236 100644 --- a/backend/app/api/v1/endpoints/geraet_instanz.py +++ b/backend/app/api/v1/endpoints/geraet_instanz.py @@ -4,10 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps import require_roles, require_roles_or_permission +from app.api.deps import get_current_user, pruefe_berechtigung_am_standort, require_roles from app.db.session import get_db -from app.models.auth import KnotenTyp, RolleTyp, Systemknoten +from app.models.auth import Benutzer, KnotenTyp, RolleTyp, Systemknoten from app.models.geraet_instanz import GeraetInstanz +from app.models.objekt import Objekt from app.models.objektposition import Objektposition from app.schemas.geraet_instanz import GeraetInstanzCreate, GeraetInstanzRead, GeraetInstanzStatusUpdate from app.services.geraet_instanz import ( @@ -25,12 +26,11 @@ _materialverantwortliche = require_roles( ) # Roadmap Phase 6: Prüfung durchführen zusätzlich über die granulare # Berechtigung "pruefung.durchfuehren" erreichbar (z.B. custom "Helfer"-Rolle). -_darf_pruefen = require_roles_or_permission( - RolleTyp.administration, - RolleTyp.materialverantwortlicher, - RolleTyp.leitungsverantwortlicher, - berechtigung="pruefung.durchfuehren", -) +# ZUST-003: diese Berechtigung kann standortgebunden sein - der betroffene +# Standort ist erst nach dem Laden von Objektposition/Objekt bekannt, daher +# hier KEIN einfaches require_roles_or_permission()-Dependency mehr, sondern +# manuelle Prüfung im Endpunkt selbst (siehe aendere_geraet_status). +_PRUEFUNG_ROLLEN = (RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher) async def _hauptserver_id(db: AsyncSession) -> int: @@ -76,12 +76,22 @@ async def aendere_geraet_status( geraet_id: uuid.UUID, payload: GeraetInstanzStatusUpdate, db: AsyncSession = Depends(get_db), - current_user=Depends(_darf_pruefen), + current_user: Benutzer = Depends(get_current_user), ) -> GeraetInstanz: instanz = await db.get(GeraetInstanz, geraet_id) if instanz is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden") + position = await db.get(Objektposition, instanz.objektposition_id) + objekt = await db.get(Objekt, position.objekt_id) + await pruefe_berechtigung_am_standort( + current_user, + db, + berechtigung="pruefung.durchfuehren", + standort_id=objekt.standort_id, + erlaubte_rollen=_PRUEFUNG_ROLLEN, + ) + zustaendiger_server_id = await _hauptserver_id(db) aktualisiert, _fehlbestand_id = await aendere_status( db, diff --git a/backend/app/api/v1/endpoints/permission.py b/backend/app/api/v1/endpoints/permission.py index 84b7730..b887e91 100644 --- a/backend/app/api/v1/endpoints/permission.py +++ b/backend/app/api/v1/endpoints/permission.py @@ -7,7 +7,14 @@ from app.api.deps import require_roles from app.db.session import get_db from app.models.auth import Benutzer, RolleTyp from app.models.permission import Berechtigung, BenutzerRolleZuordnung, Rolle, RolleBerechtigung -from app.schemas.permission import BerechtigungRead, RolleCreate, RolleRead, RolleUpdate +from app.models.stammdaten import Standort +from app.schemas.permission import ( + BerechtigungRead, + BenutzerRolleZuordnungRead, + RolleCreate, + RolleRead, + RolleUpdate, +) router = APIRouter() @@ -97,16 +104,28 @@ async def entferne_rollen_berechtigung( @router.put("/benutzer/{benutzer_id}/rollen/{rolle_id}", status_code=status.HTTP_204_NO_CONTENT) async def weise_rolle_zu( - benutzer_id: int, rolle_id: int, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) + benutzer_id: int, + rolle_id: int, + standort_id: int | None = None, + db: AsyncSession = Depends(get_db), + _=Depends(_admin_only), ) -> None: + """ZUST-003: `standort_id` optional (NULL = weiterhin global, unverändertes + Verhalten). Erneutes PUT auf eine bestehende Zuordnung aktualisiert deren + Standort statt eine zweite Zeile anzulegen - deckt den Hauptfall + "Person wechselt komplett den Standort" ab (Kachel-Entscheidung 2026-09-09).""" if await db.get(Benutzer, benutzer_id) is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Benutzer nicht gefunden") if await db.get(Rolle, rolle_id) is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rolle nicht gefunden") + if standort_id is not None and await db.get(Standort, standort_id) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Standort nicht gefunden") bestehend = await db.get(BenutzerRolleZuordnung, {"benutzer_id": benutzer_id, "rolle_id": rolle_id}) if bestehend is None: - db.add(BenutzerRolleZuordnung(benutzer_id=benutzer_id, rolle_id=rolle_id)) - await db.flush() + db.add(BenutzerRolleZuordnung(benutzer_id=benutzer_id, rolle_id=rolle_id, standort_id=standort_id)) + else: + bestehend.standort_id = standort_id + await db.flush() @router.delete("/benutzer/{benutzer_id}/rollen/{rolle_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -119,11 +138,14 @@ async def entferne_rollen_zuordnung( await db.flush() -@router.get("/benutzer/{benutzer_id}/rollen", response_model=list[int]) +@router.get("/benutzer/{benutzer_id}/rollen", response_model=list[BenutzerRolleZuordnungRead]) async def liste_rollen_zuordnung( benutzer_id: int, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) -) -> list[int]: +) -> list[BenutzerRolleZuordnungRead]: result = await db.execute( - select(BenutzerRolleZuordnung.rolle_id).where(BenutzerRolleZuordnung.benutzer_id == benutzer_id) + select(BenutzerRolleZuordnung).where(BenutzerRolleZuordnung.benutzer_id == benutzer_id) ) - return list(result.scalars().all()) + return [ + BenutzerRolleZuordnungRead(rolle_id=z.rolle_id, standort_id=z.standort_id) + for z in result.scalars().all() + ] diff --git a/backend/app/models/permission.py b/backend/app/models/permission.py index 964c676..d5ce6f3 100644 --- a/backend/app/models/permission.py +++ b/backend/app/models/permission.py @@ -40,9 +40,18 @@ class RolleBerechtigung(Base): class BenutzerRolleZuordnung(Base): """M:N Benutzer<->Rolle (neues System) - eigene Tabelle statt Wiederverwendung von benutzer_rolle, dessen rolle-Spalte ein fester ENUM - ist und keine FK auf frei anlegbare rolle-Zeilen erlaubt.""" + ist und keine FK auf frei anlegbare rolle-Zeilen erlaubt. + + ZUST-003: `standort_id` NULL = Berechtigung gilt global (unverändertes + Verhalten, Rückwärtskompatibilität), gesetzt = nur an diesem Standort. + Bewusst weiterhin EINE Zeile pro (benutzer_id, rolle_id) statt n:m auf + Standorte - eine Person mit Bedarf an mehreren gleichzeitigen Standorten + bräuchte eine zusätzliche Rollen-Zuweisung (siehe Kachel-Entscheidung), + was der bestehende Primärschlüssel hier (noch) nicht zulässt - als + bekannte Einschränkung dokumentiert, kein blockierendes Akzeptanzkriterium.""" __tablename__ = "benutzer_rolle_zuordnung" benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id", ondelete="CASCADE"), primary_key=True) rolle_id: Mapped[int] = mapped_column(ForeignKey("rolle.id", ondelete="CASCADE"), primary_key=True) + standort_id: Mapped[int | None] = mapped_column(ForeignKey("standort.id")) diff --git a/backend/app/schemas/permission.py b/backend/app/schemas/permission.py index 3972dd5..c1028a2 100644 --- a/backend/app/schemas/permission.py +++ b/backend/app/schemas/permission.py @@ -24,3 +24,8 @@ class RolleRead(BaseModel): name: str beschreibung: str | None berechtigung_ids: list[int] = [] + + +class BenutzerRolleZuordnungRead(BaseModel): + rolle_id: int + standort_id: int | None diff --git a/backend/tests/test_zust003_standort_scoping.py b/backend/tests/test_zust003_standort_scoping.py new file mode 100644 index 0000000..38a880e --- /dev/null +++ b/backend/tests/test_zust003_standort_scoping.py @@ -0,0 +1,145 @@ +import pytest + +from tests.conftest import auth_header, login + + +async def _benutzer_ohne_feste_rolle(db_session, login_name: str): + from app.core.security import hash_password + from app.models.auth import Benutzer + + benutzer = Benutzer( + name="Helfer Standort-Scoped", + login=login_name, + passwort_hash=hash_password("test-passwort-123"), + aktiv=True, + ) + db_session.add(benutzer) + await db_session.flush() + return benutzer + + +async def _weise_pruefung_rolle_zu(client, admin_token, helfer_id: int, standort_id: int | None, rollenname: str): + rolle = await client.post("/api/v1/rollen", json={"name": rollenname}, headers=auth_header(admin_token)) + rolle_id = rolle.json()["id"] + berechtigungen = await client.get("/api/v1/berechtigungen", headers=auth_header(admin_token)) + pruefung = next(b for b in berechtigungen.json() if b["schluessel"] == "pruefung.durchfuehren") + await client.put( + f"/api/v1/rollen/{rolle_id}/berechtigungen/{pruefung['id']}", headers=auth_header(admin_token) + ) + query = f"?standort_id={standort_id}" if standort_id is not None else "" + await client.put(f"/api/v1/benutzer/{helfer_id}/rollen/{rolle_id}{query}", headers=auth_header(admin_token)) + return rolle_id + + +@pytest.mark.asyncio +async def test_standortgebundenes_recht_wirkt_am_zugewiesenen_standort( + client, db_session, admin_user, objekt_mit_position +): + objekt, material = objekt_mit_position + helfer = await _benutzer_ohne_feste_rolle(db_session, "helfer_standort_ok") + admin_token = await login(client, "admin1") + + await _weise_pruefung_rolle_zu(client, admin_token, helfer.id, objekt.standort_id, "Pruefer-Standort-OK") + + positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(admin_token)) + position_id = positionen.json()[0]["id"] + erstellt = await client.post( + f"/api/v1/objektpositionen/{position_id}/geraete", + json={"seriennummer": "SN-ZUST-1"}, + headers=auth_header(admin_token), + ) + geraet_id = erstellt.json()["id"] + + helfer_token = await login(client, "helfer_standort_ok") + response = await client.patch( + f"/api/v1/geraete/{geraet_id}", + json={"status": "einsatzbereit", "pruefdatum": "2026-01-15"}, + headers=auth_header(helfer_token), + ) + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_standortgebundenes_recht_an_anderem_standort_abgelehnt( + client, db_session, admin_user, objekt_mit_position, standort_factory +): + objekt, material = objekt_mit_position + anderer_standort = await standort_factory("Anderer-Standort-ZUST") + helfer = await _benutzer_ohne_feste_rolle(db_session, "helfer_standort_falsch") + admin_token = await login(client, "admin1") + + await _weise_pruefung_rolle_zu( + client, admin_token, helfer.id, anderer_standort.id, "Pruefer-Standort-Falsch" + ) + + positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(admin_token)) + position_id = positionen.json()[0]["id"] + erstellt = await client.post( + f"/api/v1/objektpositionen/{position_id}/geraete", + json={"seriennummer": "SN-ZUST-2"}, + headers=auth_header(admin_token), + ) + geraet_id = erstellt.json()["id"] + + helfer_token = await login(client, "helfer_standort_falsch") + response = await client.patch( + f"/api/v1/geraete/{geraet_id}", + json={"status": "einsatzbereit", "pruefdatum": "2026-01-15"}, + headers=auth_header(helfer_token), + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_globales_recht_funktioniert_an_jedem_standort_unveraendert( + client, db_session, admin_user, objekt_mit_position +): + """Regressionstest: bestehende globale Zuordnungen (standort_id IS NULL) + funktionieren weiterhin uneingeschränkt (Kachel-Akzeptanzkriterium).""" + objekt, material = objekt_mit_position + helfer = await _benutzer_ohne_feste_rolle(db_session, "helfer_global") + admin_token = await login(client, "admin1") + + await _weise_pruefung_rolle_zu(client, admin_token, helfer.id, None, "Pruefer-Global") + + positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(admin_token)) + position_id = positionen.json()[0]["id"] + erstellt = await client.post( + f"/api/v1/objektpositionen/{position_id}/geraete", + json={"seriennummer": "SN-ZUST-3"}, + headers=auth_header(admin_token), + ) + geraet_id = erstellt.json()["id"] + + helfer_token = await login(client, "helfer_global") + response = await client.patch( + f"/api/v1/geraete/{geraet_id}", + json={"status": "einsatzbereit", "pruefdatum": "2026-01-15"}, + headers=auth_header(helfer_token), + ) + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_erneutes_put_aktualisiert_standort_statt_neue_zeile(client, db_session, admin_user, standort_factory): + helfer = await _benutzer_ohne_feste_rolle(db_session, "helfer_wechsel") + standort_a = await standort_factory("Standort-Wechsel-A") + standort_b = await standort_factory("Standort-Wechsel-B") + admin_token = await login(client, "admin1") + + rolle = await client.post("/api/v1/rollen", json={"name": "Wechsel-Rolle"}, headers=auth_header(admin_token)) + rolle_id = rolle.json()["id"] + + await client.put( + f"/api/v1/benutzer/{helfer.id}/rollen/{rolle_id}?standort_id={standort_a.id}", + headers=auth_header(admin_token), + ) + await client.put( + f"/api/v1/benutzer/{helfer.id}/rollen/{rolle_id}?standort_id={standort_b.id}", + headers=auth_header(admin_token), + ) + + zuordnungen = await client.get(f"/api/v1/benutzer/{helfer.id}/rollen", headers=auth_header(admin_token)) + treffer = [z for z in zuordnungen.json() if z["rolle_id"] == rolle_id] + assert len(treffer) == 1 + assert treffer[0]["standort_id"] == standort_b.id diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index fb23c2f..2a69d70 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -275,7 +275,7 @@ export function AdminPage() { {aktiverTab === "wartungsplaene" && ( )} - {aktiverTab === "rollen" && } + {aktiverTab === "rollen" && } {aktiverTab === "eskalation" && } {aktiverTab === "personal" && ( <> diff --git a/frontend/src/pages/admin/RolleSection.tsx b/frontend/src/pages/admin/RolleSection.tsx index 85824d4..526c813 100644 --- a/frontend/src/pages/admin/RolleSection.tsx +++ b/frontend/src/pages/admin/RolleSection.tsx @@ -1,25 +1,32 @@ import { useEffect, useState } from "react"; import { apiRequest } from "../../api/client"; -import type { Benutzer, Berechtigung, CustomRolle } from "../../api/types"; +import type { Benutzer, Berechtigung, CustomRolle, Standort } from "../../api/types"; interface Props { benutzer: Benutzer[]; + standorte: Standort[]; onFehler: (text: string) => void; } +interface Zuordnung { + benutzerId: number; + standortId: number | null; +} + // 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) { +export function RolleSection({ benutzer, standorte, onFehler }: Props) { const [rollen, setRollen] = useState([]); const [berechtigungen, setBerechtigungen] = useState([]); const [name, setName] = useState(""); const [beschreibung, setBeschreibung] = useState(""); const [laedt, setLaedt] = useState(true); const [ausgeklapptId, setAusgeklapptId] = useState(null); - const [zugeordneteBenutzer, setZugeordneteBenutzer] = useState>({}); + const [zugeordneteBenutzer, setZugeordneteBenutzer] = useState>({}); const [neuerBenutzerId, setNeuerBenutzerId] = useState(""); + const [neuerStandortId, setNeuerStandortId] = useState(""); async function laden() { setLaedt(true); @@ -42,15 +49,20 @@ export function RolleSection({ benutzer, onFehler }: Props) { }, []); async function ladeZuordnungen(rolleId: number) { - const treffer = benutzer.filter((b) => zugeordneteBenutzer[rolleId]?.includes(b.id)); - if (treffer.length > 0 || zugeordneteBenutzer[rolleId]) return; + if (zugeordneteBenutzer[rolleId]) return; const ergebnisse = await Promise.all( benutzer.map(async (b) => { - const ids = await apiRequest(`/benutzer/${b.id}/rollen`).catch((): number[] => []); - return ids.includes(rolleId) ? b.id : null; + const zuordnungen = await apiRequest<{ rolle_id: number; standort_id: number | null }[]>( + `/benutzer/${b.id}/rollen` + ).catch(() => []); + const treffer = zuordnungen.find((z) => z.rolle_id === rolleId); + return treffer ? { benutzerId: b.id, standortId: treffer.standort_id } : null; }) ); - setZugeordneteBenutzer((z) => ({ ...z, [rolleId]: ergebnisse.filter((x): x is number => x !== null) })); + setZugeordneteBenutzer((z) => ({ + ...z, + [rolleId]: ergebnisse.filter((x): x is Zuordnung => x !== null), + })); } function ausklappen(rolleId: number) { @@ -87,10 +99,16 @@ export function RolleSection({ benutzer, onFehler }: Props) { async function benutzerHinzufuegen(rolleId: number) { if (neuerBenutzerId === "") return; + const standortId = neuerStandortId === "" ? null : neuerStandortId; try { - await apiRequest(`/benutzer/${neuerBenutzerId}/rollen/${rolleId}`, { method: "PUT" }); - setZugeordneteBenutzer((z) => ({ ...z, [rolleId]: [...(z[rolleId] ?? []), neuerBenutzerId as number] })); + const query = standortId !== null ? `?standort_id=${standortId}` : ""; + await apiRequest(`/benutzer/${neuerBenutzerId}/rollen/${rolleId}${query}`, { method: "PUT" }); + setZugeordneteBenutzer((z) => ({ + ...z, + [rolleId]: [...(z[rolleId] ?? []), { benutzerId: neuerBenutzerId as number, standortId }], + })); setNeuerBenutzerId(""); + setNeuerStandortId(""); } catch { onFehler("Benutzer konnte der Rolle nicht zugeordnet werden."); } @@ -99,7 +117,10 @@ export function RolleSection({ benutzer, onFehler }: Props) { 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) })); + setZugeordneteBenutzer((z) => ({ + ...z, + [rolleId]: (z[rolleId] ?? []).filter((zu) => zu.benutzerId !== benutzerId), + })); } catch { onFehler("Benutzer konnte nicht entfernt werden."); } @@ -158,14 +179,18 @@ export function RolleSection({ benutzer, onFehler }: Props) { Zugewiesene Benutzer - {(zugeordneteBenutzer[r.id] ?? []).map((benutzerId) => { - const b = benutzer.find((x) => x.id === benutzerId); + {(zugeordneteBenutzer[r.id] ?? []).map((zu) => { + const b = benutzer.find((x) => x.id === zu.benutzerId); + const standort = standorte.find((s) => s.id === zu.standortId); return ( - - {b ? `${b.name} (${b.login})` : `#${benutzerId}`} + + + {b ? `${b.name} (${b.login})` : `#${zu.benutzerId}`} + — {standort ? standort.name : "global"} + benutzerEntfernen(r.id, benutzerId)} + onClick={() => benutzerEntfernen(r.id, zu.benutzerId)} > Entfernen @@ -173,7 +198,7 @@ export function RolleSection({ benutzer, onFehler }: Props) { ); })} - + Benutzer wählen… {benutzer - .filter((b) => !(zugeordneteBenutzer[r.id] ?? []).includes(b.id)) + .filter((b) => !(zugeordneteBenutzer[r.id] ?? []).some((zu) => zu.benutzerId === b.id)) .map((b) => ( {b.name} ({b.login}) ))} + setNeuerStandortId(Number(e.target.value) || "")} + title="Standort-Scoping (ZUST-003): leer = global" + > + Global (alle Standorte) + {standorte.map((s) => ( + + {s.name} + + ))} + benutzerHinzufuegen(r.id)}> Hinzufügen