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
This commit is contained in:
+388
@@ -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)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Loeschweg von der Serverantwort bis zur sichtbaren Meldung durchziehen</name>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
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.
|
||||
</behavior>
|
||||
<action>
|
||||
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`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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)</automated>
|
||||
<automated>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</automated>
|
||||
<automated>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))"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
<reversibility rating="reversible">Zusaetzlicher Zustand und ein Banner in einer Datei — ruecknehmbar durch Zuruecksetzen der Datei.</reversibility>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Formularweg und Listenladen auf dieselbe Rueckmeldung heben</name>
|
||||
<files>apps/web/src/app/(portal)/admin/users/page.tsx, apps/web/src/app/(portal)/admin/users/users-page.test.tsx</files>
|
||||
<read_first>
|
||||
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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
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.
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<automated>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)</automated>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
<reversibility rating="reversible">Reine Ergaenzung in einer Datei.</reversibility>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 3: Aktionsknoepfe der SUPER_ADMIN-Zeile einem ADMIN nicht anbieten, Gesamtlauf</name>
|
||||
<files>apps/web/src/app/(portal)/admin/users/page.tsx, apps/web/src/app/(portal)/admin/users/users-page.test.tsx</files>
|
||||
<read_first>
|
||||
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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
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.
|
||||
</behavior>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 'admin/users' 2>&1 | tail -8 # erwartet: 2 Dateien, alle Tests gruen</automated>
|
||||
<automated>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</automated>
|
||||
<automated>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</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm lint 2>&1 | tail -4 # erwartet: 5 successful, 5 total — keine NEUE Meldung im Fehlerrang</automated>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
<reversibility rating="reversible">Sichtbarkeitsbedingung in einer Zeile der Tabelle — ruecknehmbar.</reversibility>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
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.
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
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.
|
||||
</output>
|
||||
Reference in New Issue
Block a user