Files
tessera-ctl/.planning/quick/260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-/260921-a1d-PLAN.md
T
schalli 24f51e932d docs(quick-260921-a1d): Plan fuer WINDOWS #36 (stille 403-Antworten)
Drei Aufgaben: Loeschweg end-to-end sichtbar (Tracer), Formular- und
Ladeweg nachziehen, Aktionsknoepfe der SUPER_ADMIN-Zeile fuer ADMIN
nicht anbieten. Texte ueber next-intl in de/en, Serverpruefung
unangetastet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 07:24:05 +02:00

30 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
quick-260921-a1d 01 execute 1
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
apps/web/src/messages/umlaut-dictionary.ts
true
WINDOWS-36
tokens raw_tokens tasks confidence
55000 55000 3 low
truths artifacts key_links
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.
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
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 <threat_model>, 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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.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<string | null>` 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 `<form>` 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.

<threat_model>

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.
</threat_model>
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.

<success_criteria>

  • 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. </success_criteria>
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.