--- 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 `
` ist bewusst: die Meldung muss dort stehen, wo der Blick nach dem Klick auf Speichern ohnehin ist. `fetchUsers`: dieser dritte Fall ist ausdruecklich Teil dieses Plans und wird NICHT ausgelassen. Zu Beginn `loadError` auf `null` setzen, im Sonst-Zweig zu `res.ok` und im Fang-Zweig `t('errors.loadFailed')` setzen. Weil `fetchUsers` in `useCallback` mit leerer Abhaengigkeitsliste steckt und `t` aus `useTranslations` stammt: `t` der Abhaengigkeitsliste hinzufuegen, damit die Biome-Regel `useExhaustiveDependencies` keine neue Meldung erzeugt (sie steht auf Warnung, aber der Rueckstand soll nicht wachsen). `setLoading(false)` im `finally` bleibt unveraendert. Meldungsflaeche fuer `loadError`: direkt unter dem Seitenkopf, vor der Tabelle — dieselbe Stelle und dieselben Klassen wie das Fehlerbanner in `apps/web/src/app/(portal)/admin/groups/page.tsx`. Solange `loadError` gesetzt ist, darf der Hinweis "Keine Benutzer gefunden" nicht erscheinen: die leere Liste ist dann keine Aussage ueber den Datenbestand, sondern Folge des gescheiterten Ladens. Weiterhin nichts an `apps/api`, keine Umformatierung, keine Aenderung an der Erfolgslogik. 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. Create `.planning/quick/260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-/260921-a1d-SUMMARY.md` when done. In der Zusammenfassung festhalten: - gemessene Testzahlen vorher (65 Dateien / 447 Tests) und nachher, - dass `fetchUsers` bewusst mitbehandelt wurde (dritte Fundstelle, in scope), - dass die Servertexte englisch bleiben und eine Uebersetzung serverseitig waere (Beobachtung fuer die Fehlerliste, nicht Teil dieses Laufs — siehe T-A1D-04 und den Hinweis in der Ausgangslage), - dass WINDOWS #36 als geschlossen zu markieren ist, waehrend #28 und #32 derselben Familie offen bleiben.