From 24f51e932decc25fefcb84d0169c8d5ff443c697 Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 21 Sep 2026 07:24:05 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J --- .../260921-a1d-PLAN.md | 388 ++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 .planning/quick/260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-/260921-a1d-PLAN.md 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 `
` 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. + \ No newline at end of file