diff --git a/.planning/quick/260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-/260921-a1d-PLAN.md b/.planning/quick/260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-/260921-a1d-PLAN.md
new file mode 100644
index 0000000..704c36f
--- /dev/null
+++ b/.planning/quick/260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-/260921-a1d-PLAN.md
@@ -0,0 +1,388 @@
+---
+phase: quick-260921-a1d
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - apps/web/src/app/(portal)/admin/users/page.tsx
+ - apps/web/src/app/(portal)/admin/users/users-page.test.tsx
+ - apps/web/src/messages/de.json
+ - apps/web/src/messages/en.json
+ # nur falls der Umlaut-Waechter ein neues Wort meldet, siehe Aufgabe 1
+ - apps/web/src/messages/umlaut-dictionary.ts
+autonomous: true
+requirements: [WINDOWS-36]
+
+estimate:
+ tokens: 55000
+ raw_tokens: 55000
+ tasks: 3
+ confidence: low
+
+must_haves:
+ truths:
+ - "Wird eine Benutzeraenderung vom Server mit 403 abgewiesen, erscheint im Formular eine sichtbare Meldung; das Formular bleibt offen und der Text des Servers steht darin."
+ - "Wird ein Loeschvorgang vom Server mit 403 abgewiesen, erscheint im Loeschdialog eine sichtbare Meldung; der Dialog bleibt offen."
+ - "Traegt die Antwort keinen verwertbaren Text (kein JSON, leerer Rumpf) oder schlaegt die Verbindung ganz fehl, erscheint stattdessen eine uebersetzte Ersatzmeldung — nie eine leere Reaktion."
+ - "Scheitert das Laden der Benutzerliste, sagt die Seite das; sie zeigt nicht mehr faelschlich 'Keine Benutzer gefunden'."
+ - "Ein Benutzer mit der Rolle ADMIN bekommt in der Zeile des SUPER_ADMIN weder einen Bearbeiten- noch einen Loeschen-Knopf angeboten; ein SUPER_ADMIN bekommt beide."
+ - "Alle neuen Texte liegen in de.json und en.json mit identischem Schluesselsatz vor; kein Text steht fest verdrahtet im Quelltext."
+ - "Die Serverpruefungen in apps/api sind unveraendert — die ausgeblendeten Knoepfe sind Ergonomie, kein Berechtigungsersatz."
+ artifacts:
+ - "apps/web/src/app/(portal)/admin/users/page.tsx — drei Fehlerzustaende, drei Meldungsflaechen, Rollenfilter fuer die Aktionsknoepfe"
+ - "apps/web/src/app/(portal)/admin/users/users-page.test.tsx — neue Vitest-Datei mit den Verhaltensnachweisen"
+ - "apps/web/src/messages/de.json und en.json — Zweig admin.users.errors mit vier Schluesseln je Sprache"
+ key_links:
+ - "readApiMessage(res) -> t('errors.serverRejected', { detail }) -> sichtbares Banner: die Kette, an der heute der 403 verschwindet"
+ - "currentUser.role aus dem auth-store -> Sichtbarkeit der Aktionsknoepfe je Zeile"
+ - "de.json/en.json Schluesselgleichheit -> umlaut-guard.spec.ts (prueft den GESAMTEN Katalog, nicht nur einen Zweig)"
+---
+
+
+WINDOWS #36 schliessen: die Benutzerverwaltung schluckt abgewiesene Serverantworten heute vollstaendig.
+`handleSubmit` und `handleDelete` in `apps/web/src/app/(portal)/admin/users/page.tsx` pruefen nur `res.ok`,
+haben keinen Sonst-Zweig und fangen Ausnahmen mit einem Rumpf, der nur einen Kommentar enthaelt. Ein 403
+fuehrt damit zu gar keiner sichtbaren Reaktion — das Formular bleibt offen, der Loeschdialog bleibt stehen,
+es erscheint keine Meldung. Fuer die bedienende Person sieht das aus, als haenge die Anwendung.
+
+Seit Quick 260914-ebg (WINDOWS #29) ist dieser Fall im Alltag erreichbar: die Zeile des SUPER_ADMIN steht in
+der Benutzerliste eines ADMIN, und Aendern oder Loeschen darauf liefert jetzt 403
+(`apps/api/src/user/user.controller.ts`, Zielrollen-Riegel in `update` und `remove`).
+
+Zwei Haelften, beide verbindlich:
+1. Das Scheitern sichtbar machen — mit dem Text aus dem Antwortrumpf, wo der Server einen liefert, sonst mit
+ einer uebersetzten Ersatzmeldung.
+2. Das Unmoegliche gar nicht erst anbieten — fuer einen ADMIN entfallen Bearbeiten und Loeschen in der Zeile
+ des SUPER_ADMIN. Der 403 ist danach das Sicherungsnetz, nicht der Regelweg.
+
+Purpose: Die Benutzerverwaltung gibt bei jeder abgewiesenen Aktion eine Antwort, die man lesen kann.
+Output: Sichtbare Fehlermeldungen in Formular, Loeschdialog und Listenkopf; rollenrichtige Aktionsknoepfe;
+eine neue Vitest-Datei, die beides nachweist.
+
+## Entscheidungen, die in diesen Plan eingeflossen sind
+
+- **D-01 (gesetzt):** Alle neuen Texte laufen ueber next-intl in `de.json` UND `en.json`. Keine fest
+ verdrahteten Zeichenketten — Quick 260701-abc hat genau diese Fehlerklasse schon einmal beseitigt.
+- **D-02 (gesetzt):** Deutsche Oberflaechentexte in der Sie-Form, wie der restliche Katalog.
+- **D-03 (gesetzt):** Der Text des Servers hat Vorrang, wenn die Antwort einen traegt; sonst greift eine
+ uebersetzte Ersatzmeldung. Kein Fall endet ohne Rueckmeldung.
+- **D-04 (gesetzt):** Die Serverpruefung wird nicht angefasst. `apps/api` steht nicht in der Dateiliste.
+ Das Ausblenden eines Knopfes ist eine Schicht OBERHALB der Serverpruefung, niemals ihr Ersatz
+ (siehe ``, T-A1D-02).
+- **D-05 (gesetzt):** Aenderung bleibt in der Benutzerseite und den beiden Katalogen. Vorhandene Muster
+ werden wiederverwendet statt neu erfunden — gemessen: `apps/web/src/app/(portal)/admin/groups/page.tsx`
+ hat bereits ein Fehlerbanner, `apps/web/src/lib/tender-radar-api.ts` bereits eine Rumpf-Auswertung.
+- **D-06 (gesetzt):** Keine Umformatierung der Datei, keine Versionsspruenge.
+
+## Gemessene Ausgangslage (2026-09-21, vor der Planung geprueft)
+
+- `apps/web/src/app/(portal)/admin/users/page.tsx`: 428 Zeilen. Drei Stellen verschlucken still:
+ `fetchUsers` (Zeile 60-73), `handleSubmit` (107-142), `handleDelete` (144-157).
+- **Dritte Fundstelle ist IN SCOPE.** `fetchUsers` wird mitbehandelt: scheitert das Laden, zeigt die Seite
+ heute "Keine Benutzer gefunden" — eine falsche Aussage, dieselbe Fehlerfamilie, dieselbe Datei, sehr
+ geringe Zusatzkosten. Nichts wird ausgelassen.
+- Serverantworten fuer die 403-Wege, woertlich gemessen in `apps/api/src/user/user.controller.ts`:
+ `Cannot modify a SUPER_ADMIN user` (Z. 199), `Cannot delete a SUPER_ADMIN user` (Z. 262),
+ `Cannot modify users from other tenants` (Z. 188), `Cannot delete users from other tenants` (Z. 255),
+ `Cannot delete your own account` (Z. 247), `Cannot assign SUPER_ADMIN role` (Z. 151/204).
+ Es gibt keinen eigenen ExceptionFilter in `apps/api/src` (geprueft), also gilt die Standardform von
+ NestJS: `{ statusCode, message, error }`, bei 500 lautet `message` schlicht `Internal server error`.
+- **Bekannte Eigenheit, ausdruecklich NICHT Teil dieses Plans:** diese Servertexte sind englisch. Nach D-03
+ werden sie angezeigt wie sie sind, eingefasst in einen deutschen Rahmensatz. Eine Uebersetzung der
+ Servertexte waere eine Aenderung an `apps/api` und damit eine eigene Aufgabe — hier bewusst nicht getan,
+ damit der Plan die Serverantwort nicht anfasst (D-04).
+- Testbestand `apps/web`, gemessen mit `pnpm --filter @tessera/web test`:
+ **65 Dateien, 447 Tests, alle gruen.** Erwartung nach diesem Plan: 66 Dateien, 447 + neue Tests.
+- `pnpm --filter @tessera/web type-check`: Exit 0.
+- `pnpm lint` (Wurzel, Biome 2.5.0 ueber alle fuenf Workspaces): 5 von 5 erfolgreich. Bestehende Warnungen
+ blockieren nicht, jede NEUE Meldung im Fehlerrang faerbt den Lauf rot.
+- Der Waechter `apps/web/src/messages/umlaut-guard.spec.ts` prueft dreierlei: keine Ersatzschreibung im
+ Deutschen, kein neues Wort mit ae/oe/ue/ss ausserhalb der Erlaubnisliste, und **Schluesselgleichheit von
+ de.json und en.json ueber den gesamten Katalog**. Ein Schluessel nur in einer Sprache faellt sofort durch.
+
+
+
+@~/.claude/gsd-core/workflows/execute-plan.md
+@~/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/STATE.md
+@apps/web/src/app/(portal)/admin/users/page.tsx
+@apps/web/src/app/(portal)/admin/groups/page.tsx
+@apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx
+@apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx
+@apps/web/src/messages/umlaut-guard.spec.ts
+
+
+
+
+
+ Aufgabe 1: Loeschweg von der Serverantwort bis zur sichtbaren Meldung durchziehen
+ apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/app/(portal)/admin/users/page.tsx, apps/web/src/app/(portal)/admin/users/users-page.test.tsx
+
+ apps/web/src/app/(portal)/admin/users/page.tsx (Zeilen 36-73 Zustand und Laden, 144-157 handleDelete, 393-416 Loeschdialog)
+ apps/web/src/app/(portal)/admin/groups/page.tsx (Zeilen 100-110 Rumpf-Auswertung, 136-140 Bannerklassen)
+ apps/web/src/lib/tender-radar-api.ts (Zeilen 428-446, Funktion extractErrorMessage als Formvorlage)
+ apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx (Zeilen 1-70, Konvention fuer den next-intl-Ersatz)
+ apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx (Zeilen 92-190, Konvention fuer auth-store- und fetch-Ersatz)
+
+
+ Neue Datei `users-page.test.tsx`, Aufbau exakt wie `groups-page.test.tsx`: namensraum-bewusster
+ next-intl-Ersatz ueber `vi.mock('next-intl', ...)`, `vi.mock('@/lib/stores/auth-store', ...)` mit
+ Selektorweitergabe, `vi.stubGlobal('fetch', ...)` je Test, `cleanup()` und `vi.restoreAllMocks()` in
+ `afterEach`. Angemeldete Person in dieser Aufgabe: Rolle ADMIN, id `u1`, tenantId `t1`.
+ - Test 1 (403 mit Text): Die Liste laedt zwei Benutzer. Nach Klick auf Loeschen und Bestaetigen
+ antwortet fetch mit `{ ok: false, status: 403, json: () => Promise.resolve({ statusCode: 403,
+ message: 'Cannot delete a SUPER_ADMIN user' }) }`. Erwartet: der Text
+ "Der Server hat die Aktion abgelehnt: Cannot delete a SUPER_ADMIN user" steht im Dokument, und der
+ Bestaetigungstext des Dialogs steht weiterhin im Dokument (der Dialog bleibt offen).
+ - Test 2 (Antwort ohne verwertbaren Rumpf): `{ ok: false, status: 500, json: () => Promise.reject(new
+ Error('not json')) }`. Erwartet: die Ersatzmeldung "Die Aktion konnte nicht durchgefuehrt werden."
+ als Teiltext im Dokument; nicht der Rahmensatz aus Test 1.
+ - Test 3 (Verbindung scheitert): fetch wirft beim Loeschaufruf. Erwartet: die Netzmeldung
+ "Der Server ist nicht erreichbar." als Teiltext im Dokument.
+ - Test 4 (Erfolgsfall unveraendert): `{ ok: true, json: ... }`. Erwartet: der Bestaetigungstext des
+ Dialogs ist verschwunden, keine Meldungsflaeche im Dokument, und fetch wurde fuer das Neuladen der
+ Liste erneut aufgerufen.
+
+
+ Erstens die Texte. In `apps/web/src/messages/de.json` und `apps/web/src/messages/en.json` jeweils unter
+ `admin.users` ein neues Objekt `errors` mit genau vier Schluesseln anlegen — gleicher Schluesselsatz in
+ beiden Sprachen, sonst faellt der Waechter durch. Deutsch, Sie-Form, mit echten Umlauten:
+ `serverRejected` = `Der Server hat die Aktion abgelehnt: {detail}`,
+ `generic` = `Die Aktion konnte nicht durchgeführt werden. Bitte erneut versuchen.`,
+ `network` = `Der Server ist nicht erreichbar. Bitte erneut versuchen.`,
+ `loadFailed` = `Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu.`
+ Englisch: `serverRejected` = `The server rejected the action: {detail}`,
+ `generic` = `The action could not be completed. Please try again.`,
+ `network` = `The server is not reachable. Please try again.`,
+ `loadFailed` = `The user list could not be loaded. Please reload the page.`
+ Diese Formulierungen sind bewusst so gewaehlt, dass kein Wort eine ae/oe/ue/ss-Folge enthaelt — der
+ Waechter muss ohne Aenderung an `umlaut-dictionary.ts` gruen bleiben. Sollte er wider Erwarten doch ein
+ Wort melden, ist der einzige erlaubte Eingriff dort das Eintragen genau dieses Wortes in
+ `UMLAUT_ALLOWLIST`, so wie es die Meldung des Waechters selbst anweist; die bestehenden Eintraege
+ bleiben unberuehrt.
+
+ Zweitens die Auswertung des Antwortrumpfs. In `page.tsx` auf Modulebene (ausserhalb der Komponente,
+ unterhalb der Schnittstellen-Deklarationen) eine Funktion `readApiMessage` mit der Signatur
+ `(res: Response) => Promise` anlegen. Sie liest `await res.json()`, gibt `body.message`
+ zurueck, wenn es eine nicht-leere Zeichenkette ist, verbindet ein Feld von Zeichenketten mit
+ `, ` (so liefert NestJS Pruefmeldungen aus class-validator), und gibt in jedem anderen Fall sowie bei
+ einer Ausnahme aus `res.json()` `null` zurueck. Formvorlage ist `extractErrorMessage` in
+ `apps/web/src/lib/tender-radar-api.ts`; bewusst lokal kopiert statt importiert, weil jene Datei zum
+ Modul Ausschreibungs-Radar gehoert und die Verwaltungsseite nicht davon abhaengen soll. Ausdruecklich
+ NUR das Feld `message` lesen — niemals `res.text()` des ganzen Rumpfes, damit eine fremde HTML-
+ Fehlerseite eines vorgelagerten Dienstes nicht in die Oberflaeche geraet (T-A1D-01).
+
+ Drittens der Loeschweg. Einen Zustand `deleteError` vom Typ `string | null` ergaenzen. In `handleDelete`
+ zu Beginn auf `null` setzen; im Sonst-Zweig zu `res.ok` den Text ueber `readApiMessage` holen und
+ `t('errors.serverRejected', { detail })` setzen, wenn ein Text kam, sonst `t('errors.generic')`. Im
+ Fang-Zweig — dessen Rumpf bisher nur einen Kommentar enthaelt — `t('errors.network')` setzen; der
+ Kommentar entfaellt ersatzlos. Beim Oeffnen des Dialogs (`setDeleteConfirm(user.id)`) ebenfalls auf
+ `null` zuruecksetzen, damit eine alte Meldung nicht an einer neuen Zeile klebt.
+
+ Viertens die Meldungsflaeche. Im Loeschdialog oberhalb der Knopfreihe ein Banner rendern, wenn
+ `deleteError` gesetzt ist, mit `role="alert"` und exakt den Klassen aus
+ `apps/web/src/app/(portal)/admin/groups/page.tsx`:
+ `rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive`. Der Text wird
+ als React-Kind gerendert, niemals ueber `dangerouslySetInnerHTML` (T-A1D-03). Anders als die
+ Gruppenseite wird weder `res.status` noch der Rohrumpf angezeigt — nur der gerahmte Servertext.
+
+ Die uebrige Datei bleibt unangetastet: keine Umformatierung, keine Umsortierung der Einfuhren, keine
+ Aenderung an `apps/api`.
+
+
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 'admin/users' 2>&1 | tail -8 # erwartet: 2 Dateien, alle Tests gruen (Ausgangslage war 1 Datei / 8 Tests)
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 'messages/umlaut-guard' 2>&1 | tail -8 # erwartet: 3 Tests gruen — belegt Schluesselgleichheit de/en und saubere Umlaute
+ cd /home/vicolab/projects/tessera-ctl && node -e "const d=require('./apps/web/src/messages/de.json'),e=require('./apps/web/src/messages/en.json');const k=o=>Object.keys(o.admin.users.errors).sort().join(',');if(k(d)!=='generic,loadFailed,network,serverRejected')throw new Error('de errors keys: '+k(d));if(k(e)!==k(d))throw new Error('en weicht ab: '+k(e));console.log('OK',k(d))"
+
+
+ Ein mit 403 abgewiesener Loeschvorgang zeigt den Servertext im Loeschdialog; der Dialog bleibt offen.
+ Eine Antwort ohne verwertbaren Rumpf und ein Verbindungsfehler zeigen jeweils ihre uebersetzte
+ Ersatzmeldung. Der Erfolgsfall schliesst den Dialog und laedt die Liste neu wie bisher. Vier Schluessel
+ unter `admin.users.errors` in beiden Katalogen, Umlaut-Waechter gruen.
+
+ Zusaetzlicher Zustand und ein Banner in einer Datei — ruecknehmbar durch Zuruecksetzen der Datei.
+
+
+
+ Aufgabe 2: Formularweg und Listenladen auf dieselbe Rueckmeldung heben
+ apps/web/src/app/(portal)/admin/users/page.tsx, apps/web/src/app/(portal)/admin/users/users-page.test.tsx
+
+ apps/web/src/app/(portal)/admin/users/page.tsx (Zeilen 60-73 fetchUsers, 83-105 openCreate/openEdit, 107-142 handleSubmit, 178-196 Kopfbereich und Ladezustand, 281-391 Formulardialog)
+
+
+ Weitere Tests in `users-page.test.tsx`, gleiche Bauart wie in Aufgabe 1:
+ - Test 5 (Aendern wird abgewiesen): Liste laedt, Klick auf Bearbeiten, Absenden des Formulars; fetch
+ antwortet `{ ok: false, status: 403, json: () => Promise.resolve({ message: 'Cannot modify a
+ SUPER_ADMIN user' }) }`. Erwartet: "Der Server hat die Aktion abgelehnt: Cannot modify a SUPER_ADMIN
+ user" steht im Dokument UND das Formular ist weiterhin offen (das Feld Benutzername ist noch da).
+ - Test 6 (Pruefmeldungen als Feld): Rumpf `{ message: ['username must be longer', 'email must be an
+ email'] }`. Erwartet: beide Teiltexte erscheinen, mit `, ` verbunden, im Rahmensatz.
+ - Test 7 (Verbindung scheitert beim Speichern): fetch wirft. Erwartet: die Netzmeldung steht im
+ Dokument, das Formular bleibt offen.
+ - Test 8 (Liste laedt nicht): der erste fetch antwortet `{ ok: false, status: 500, json: () =>
+ Promise.reject(new Error('not json')) }`. Erwartet: "Die Benutzerliste konnte nicht geladen werden."
+ steht im Dokument und der Text "Keine Benutzer gefunden" steht NICHT im Dokument.
+ - Test 9 (Meldung ueberdauert nicht): nach einem abgewiesenen Speichern das Formular schliessen und
+ erneut Bearbeiten oeffnen — die alte Meldung ist verschwunden.
+
+
+ Zwei weitere Zustaende ergaenzen: `formError` und `loadError`, beide `string | null`.
+
+ `handleSubmit`: zu Beginn `formError` auf `null` setzen. Sonst-Zweig zu `res.ok` und Fang-Zweig genau
+ wie in Aufgabe 1 fuer den Loeschweg aufgebaut — `readApiMessage` befragen, bei Text
+ `t('errors.serverRejected', { detail })`, sonst `t('errors.generic')`, im Fang-Zweig
+ `t('errors.network')`. Der Rumpf des Fang-Zweigs enthaelt danach echte Zuweisung statt eines
+ Kommentars. Zusaetzlich in `openCreate` und `openEdit` `formError` auf `null` setzen, damit eine
+ Meldung nicht in den naechsten Dialogaufruf hinueberwandert.
+
+ Meldungsflaeche im Formulardialog: unterhalb des Rollenfeldes und oberhalb der Knopfreihe
+ (Abbrechen/Speichern) ein Banner mit `role="alert"` und denselben Klassen wie in Aufgabe 1. Die
+ Platzierung innerhalb des `
+
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 'admin/users' 2>&1 | tail -8 # erwartet: 2 Dateien gruen, Testzahl gegenueber Aufgabe 1 gestiegen
+ cd /home/vicolab/projects/tessera-ctl && grep -v '^\s*//' "apps/web/src/app/(portal)/admin/users/page.tsx" | grep -c 'role="alert"' # erwartet: 3 (Loeschdialog, Formular, Listenkopf)
+ cd /home/vicolab/projects/tessera-ctl && test "$(grep -c 'silently fail' "apps/web/src/app/(portal)/admin/users/page.tsx")" = "0" && echo OK # kein still verschluckender Fang-Zweig mehr
+
+
+ Alle drei bisher stillen Wege melden sich: abgewiesenes Speichern im Formular, abgewiesenes Loeschen im
+ Dialog, gescheitertes Laden im Listenkopf. Bei gescheitertem Laden erscheint nicht mehr faelschlich
+ "Keine Benutzer gefunden". Meldungen ueberdauern das Schliessen eines Dialogs nicht.
+
+ Reine Ergaenzung in einer Datei.
+
+
+
+ Aufgabe 3: Aktionsknoepfe der SUPER_ADMIN-Zeile einem ADMIN nicht anbieten, Gesamtlauf
+ apps/web/src/app/(portal)/admin/users/page.tsx, apps/web/src/app/(portal)/admin/users/users-page.test.tsx
+
+ apps/web/src/app/(portal)/admin/users/page.tsx (Zeilen 221-275, Tabellenzeile mit der Knopfreihe)
+ apps/api/src/user/user.controller.ts (Zeilen 196-205 und 258-264, Zielrollen-Riegel — nur lesen, nicht aendern)
+
+
+ Weitere Tests in `users-page.test.tsx`, Liste enthaelt drei Zeilen: ein SUPER_ADMIN (`u0`), die
+ angemeldete Person selbst (`u1`) und ein gewoehnlicher Benutzer (`u2`).
+ - Test 10 (ADMIN sieht keine Knoepfe an der obersten Rolle): angemeldet als ADMIN. Erwartet: in der
+ Zeile des SUPER_ADMIN gibt es weder einen Knopf "Bearbeiten" noch "Löschen"; der Knopf "Details"
+ ist weiterhin da. In der Zeile von `u2` gibt es beide Knoepfe. Die Zuordnung Zeile-zu-Knopf ueber
+ `within(...)` auf der Tabellenzeile pruefen, nicht ueber Gesamtzaehlungen.
+ - Test 11 (SUPER_ADMIN sieht beide Knoepfe): angemeldet als SUPER_ADMIN. Erwartet: in der Zeile des
+ SUPER_ADMIN sind Bearbeiten und Löschen vorhanden.
+ - Test 12 (Selbstloeschung bleibt wie bisher): angemeldet als ADMIN `u1`. Erwartet: der Loeschknopf in
+ der eigenen Zeile ist vorhanden und gesperrt (`toBeDisabled`) — dieses Verhalten wird nicht
+ veraendert.
+
+
+ Eine Hilfsfunktion innerhalb der Komponente anlegen, zum Beispiel `canManageRow`, die genau die
+ Serverbedingung spiegelt: eine Zeile ist gesperrt, wenn `user.role === 'SUPER_ADMIN'` und
+ `currentUser?.role !== 'SUPER_ADMIN'`. Dieselbe Bedingung steht serverseitig in
+ `apps/api/src/user/user.controller.ts` in `update` und in `remove`; sie wird hier gespiegelt, nicht
+ ersetzt (D-04, T-A1D-02).
+
+ In der Knopfreihe der Tabellenzeile: Bearbeiten und Löschen nur rendern, wenn die Zeile nicht gesperrt
+ ist. Gesperrte Zeilen zeigen gar keinen dieser Knoepfe — nicht einen gesperrten, sondern keinen. Das
+ ist die Anforderung aus der Fehlerliste, und ein sichtbarer, aber gesperrter Knopf wuerde denselben
+ Ratespielraum lassen wie heute. "Details" bleibt in jeder Zeile stehen, weil der lesende Zugriff auf
+ die Zugriffsuebersicht davon nicht betroffen ist.
+
+ Die vorhandene Sperre des Loeschknopfes fuer die eigene Zeile (`disabled={user.id === currentUser?.id}`
+ samt ihren Klassen) bleibt unveraendert bestehen — sie ist ein anderer Fall und nicht Teil von
+ WINDOWS #36.
+
+ Keine Aenderung an `apps/api`. Keine Aenderung an der Rollenauswahl im Formular (die Einschraenkung der
+ Option SUPER_ADMIN dort besteht bereits und bleibt, wie sie ist).
+
+
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 'admin/users' 2>&1 | tail -8 # erwartet: 2 Dateien, alle Tests gruen
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 2>&1 | tail -6 # erwartet: 66 Dateien (Ausgangslage 65), mindestens 447 Tests, 0 Fehler
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web type-check 2>&1 | tail -3; echo "EXIT=${PIPESTATUS[0]}" # erwartet: EXIT=0 wie in der Ausgangslage
+ cd /home/vicolab/projects/tessera-ctl && pnpm lint 2>&1 | tail -4 # erwartet: 5 successful, 5 total — keine NEUE Meldung im Fehlerrang
+ cd /home/vicolab/projects/tessera-ctl && ST=$(git status --porcelain) || { echo "git fehlgeschlagen"; exit 1; }; case "$ST" in *apps/api/*) echo "FEHLER: apps/api wurde angefasst"; exit 1;; *) echo "OK: apps/api unberuehrt";; esac # T-A1D-02, kein Pipe: der Status von git wird zuerst gesichert
+
+
+ Ein ADMIN bekommt in der Zeile des SUPER_ADMIN keine Aktionsknoepfe mehr angeboten, ein SUPER_ADMIN
+ schon; die Sperre gegen Selbstloeschung ist unveraendert. Gesamter Testbestand von apps/web gruen
+ (66 Dateien), `type-check` mit Exit 0, `pnpm lint` in allen fuenf Workspaces erfolgreich, und
+ `apps/api` ist unberuehrt.
+
+ Sichtbarkeitsbedingung in einer Zeile der Tabelle — ruecknehmbar.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| API → Browser (Antwortrumpf) | Text aus der Serverantwort wird neu in die Oberflaeche uebernommen und angezeigt. Bisher wurde er verworfen. |
+| Browser → API (Aktionsaufruf) | Unveraendert: jeder Aendern-/Loeschaufruf laeuft weiterhin durch Wache, Rollenpruefung und Zielrollen-Riegel der API. |
+| Rolle des Anmeldenachweises → Darstellung | `currentUser.role` steuert ab jetzt zusaetzlich, welche Knoepfe eine Zeile anbietet. Ein Wert, den der Browser haelt — daher niemals eine Berechtigungsgrenze. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-A1D-01 | Information Disclosure | `readApiMessage` in `apps/web/src/app/(portal)/admin/users/page.tsx` | low | mitigate | Vor der Planung gemessen: saemtliche Ausnahmen der Benutzer-Endpunkte in `apps/api/src/user/user.controller.ts` werfen feste englische Zeichenketten ohne Benutzernamen, Kennungen oder Aufrufspuren; in `apps/api/src` existiert kein eigener ExceptionFilter, es gilt also die NestJS-Standardform `{ statusCode, message, error }` und ein 500 traegt nur `Internal server error`. Zusaetzlich liest die Auswertung ausschliesslich das Feld `message` — niemals `res.text()` des ganzen Rumpfes. Eine fremde HTML-Fehlerseite (Nginx Proxy Manager davor) liefert damit `null` und fuehrt zur uebersetzten Ersatzmeldung statt zu fremdem Inhalt in der Oberflaeche. |
+| T-A1D-02 | Elevation of Privilege | Sichtbarkeit der Aktionsknoepfe in der Benutzertabelle | high | mitigate | Das Ausblenden ist ausschliesslich Ergonomie und liegt OBERHALB der Serverpruefung. Der Zielrollen-Riegel in `update` und `remove` (`apps/api/src/user/user.controller.ts`) bleibt unveraendert und bleibt die einzige wirksame Grenze; wer den Aufruf direkt absetzt, bekommt weiterhin 403. `apps/api` steht nicht in `files_modified`, und Aufgabe 3 prueft das mit einem eigenen Gatter (`git status --porcelain` darf keinen Pfad unter `apps/api/` zeigen). |
+| T-A1D-03 | Tampering | Darstellung des Servertextes im Banner | medium | mitigate | Der Text wird als React-Kind gerendert und damit maskiert; `dangerouslySetInnerHTML` ist fuer diese Flaechen ausgeschlossen. Damit kann ein manipulierter Antwortrumpf kein Markup in die Seite bringen. |
+| T-A1D-04 | Information Disclosure | Meldung `Cannot modify users from other tenants` | low | accept | Diese Meldung bestaetigt einem ADMIN die Existenz einer Kennung in einem fremden Mandanten. Das ist bestehendes Serververhalten: die Antwort erreicht den Browser schon heute vollstaendig, nur wird sie verworfen. Das Anzeigen aendert nichts daran, wer sie lesen kann. Abstellen hiesse die Serverantwort aendern, was D-04 ausschliesst — als Beobachtung fuer die Fehlerliste vermerkt, nicht in diesem Lauf behandelt. |
+| T-A1D-05 | Denial of Service | Ersatzmeldungen bei fehlender Antwort | low | mitigate | Jeder Zweig endet in einer Meldung: Servertext, `generic`, `network` oder `loadFailed`. Kein Pfad laesst die Oberflaeche ohne Rueckmeldung stehen — genau das war der Befund von WINDOWS #36. |
+
+
+
+Nach allen drei Aufgaben, aus dem Projektverzeichnis:
+
+1. `pnpm --filter @tessera/web test` — erwartet 66 Testdateien (Ausgangslage 65) und mindestens 447 Tests,
+ 0 Fehler. Eine niedrigere Zahl oder ein roter Lauf ist ein Rueckschritt gegenueber der Ausgangsmessung.
+2. `pnpm --filter @tessera/web type-check` — Exit 0 (Ausgangslage: Exit 0).
+3. `pnpm lint` — 5 von 5 Workspaces erfolgreich. Bestehende Warnungen bleiben zulaessig, jede neue Meldung
+ im Fehlerrang faerbt den Lauf rot und ist zu beheben.
+4. `git status --porcelain` zeigt ausschliesslich Pfade unter `apps/web/src/` — kein Pfad unter `apps/api/`.
+
+**Von Hand, ausdruecklich nicht automatisiert** (optional, die Tests decken das Verhalten bereits ab; hier
+geht es allein um Platzierung und Lesbarkeit): in der laufenden Anwendung als ADMIN die Benutzerverwaltung
+oeffnen. Sichtprobe a — in der Zeile des SUPER_ADMIN stehen nur noch "Details". Sichtprobe b — eine
+abgewiesene Aktion (etwa ueber die Entwicklerwerkzeuge erzwungen) zeigt das rote Banner an der erwarteten
+Stelle, im Formular oberhalb der Knopfreihe und im Loeschdialog oberhalb der Knopfreihe.
+
+
+
+- Keiner der drei bisher stillen Wege in `page.tsx` endet noch ohne sichtbare Rueckmeldung.
+- Bei vorhandenem Servertext steht dieser im Rahmensatz; sonst greift die passende uebersetzte
+ Ersatzmeldung.
+- Ein ADMIN bekommt an der SUPER_ADMIN-Zeile keine Aktionsknoepfe angeboten; ein SUPER_ADMIN schon.
+- Alle Texte liegen in beiden Katalogen mit gleichem Schluesselsatz; der Umlaut-Waechter ist gruen.
+- 66 Testdateien gruen, `type-check` Exit 0, `pnpm lint` 5 von 5 erfolgreich.
+- `apps/api` ist unveraendert; die 403-Antworten sind exakt dieselben wie vorher.
+
+
+
\ No newline at end of file