feat: user invite flow, deactivate/delete, favicon, dashboard loading fix, admin full-width

- Invite flow: admin can invite users by email with role selection; accept-invite page
  sets password and creates the account; 72-hour token expiry; E2E tests
- User deactivate/reactivate/delete: new tRPC procedures + UI buttons; deactivation
  revokes all active sessions immediately; delete cascades vacation/broadcast records;
  isActive field added via migration 20260402000000_user_isactive
- Auth: block login for inactive users with audit entry
- Favicon: SVG favicon + ICO/PNG fallbacks (16, 32, 180, 192, 512px); manifest updated
- Dashboard: GridLayout dynamic-import loading skeleton prevents blank dark area
  on first login before react-grid-layout chunk is cached
- Admin users: remove max-w-5xl constraint so table uses full page width
- Dev: docker container restart workflow documented in LEARNINGS.md; Prisma generate
  must run inside the container after schema changes (named node_modules volume)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-04-02 20:19:26 +02:00
parent dc5bbdc47d
commit 41eb722369
33 changed files with 6755 additions and 169 deletions
+61 -142
View File
@@ -1,175 +1,94 @@
# CapaKraken — Umsetzungsplan
# Plan: App Base URL — configurable via env, no localhost fallback in production
Gitea-Repo: `https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY`
Stand: 2026-04-02 | Issues: #38#42 (MFA Post-Activation Bugs)
Stand: 2026-04-02
---
## Plan: MFA Post-Activation Bugs #38#42
## Anforderungsanalyse
### Anforderungsanalyse
Email-Links (Invite, Password Reset) werden aktuell mit `process.env["NEXTAUTH_URL"] ?? "http://localhost:3100"` gebaut. Das ist korrekt, wenn `NEXTAUTH_URL` gesetzt ist. Das Problem: wenn der Wert fehlt oder leer ist, enthalten Produktions-E-Mails localhost-Links. Außerdem gibt es keine `.env.example`, die dokumentiert, welche Variablen gesetzt werden müssen.
Nach der Implementierung der MFA-Tickets (#28#35) wurden 5 Folgefehler identifiziert, die den Login-Flow für MFA-Nutzer vollständig blockieren. Betroffen sind `apps/web` und `packages/api`. Kein Prisma-Schema-Change nötig.
**Was gebaut wird:**
1. Zentrale `getAppBaseUrl()` Funktion in `packages/api` — liest `NEXTAUTH_URL`, wirft in production einen Fehler wenn nicht gesetzt, fällt in dev auf localhost zurück.
2. Beide Router (`invite.ts`, `auth.ts`) verwenden diese Funktion statt duplizierter Inline-Logik.
3. `.env.example` mit allen benötigten Variablen.
4. Health-Route zeigt ob `NEXTAUTH_URL` konfiguriert ist.
**Kritischer Pfad:** #41 muss zuerst rein — solange Auth.js v5 alle `throw`s aus `authorize()` als `CallbackRouteError` wrappt, ist der MFA-Login für jeden Nutzer gebrochen. Alle anderen Bugs sind unabhängig davon lösbar, aber #41 ist Voraussetzung für testbare Akzeptanzkriterien von #38 und #39.
**Betroffene Pakete:** `packages/api`, `apps/web`
**Audit-Ergebnis — intentionale localhost-Referenzen (NICHT ändern):**
- `apps/web/e2e/dev-system/` — alle E2E-Helfer, Specs, global-setup.ts
- `apps/web/playwright.dev.config.ts`
- `apps/web/src/middleware.test.ts`
- `.github/workflows/deploy-test.yml`
---
### Betroffene Pakete & Dateien
## Betroffene Pakete & Dateien
| Paket | Datei | Art |
|-------|-------|-----|
| `apps/web` | `src/server/auth.ts` | edit — Custom `CredentialsSignin`-Subklassen |
| `apps/web` | `src/app/auth/signin/page.tsx` | edit — Error-Code-Prüfung anpassen |
| `apps/web` | `src/app/(app)/layout.tsx` | edit — server-seitiges `showMfaPrompt` entfernen |
| `apps/web` | `src/components/security/MfaPromptBanner.tsx` | edit — client-seitig via tRPC |
| `apps/web` | `src/app/api/perf/route.test.ts` | edit — neuer Test für Auth-Fehler-Signale |
| `apps/web` | `docker-compose.yml` | edit — `NEXTAUTH_URL` required machen |
| `packages/api` | `src/router/user.ts` | edit — `resetUserPassword` Admin-Mutation |
| `packages/api` | `src/router/user-procedure-support.ts` | edit — `resetUserPassword`-Support-Fn |
| `packages/api` | `src/__tests__/reset-password.test.ts` | create — Unit-Tests |
| `packages/api` | `src/lib/app-base-url.ts` | create |
| `packages/api` | `src/router/invite.ts` | edit |
| `packages/api` | `src/router/auth.ts` | edit |
| `apps/web` | `src/app/api/health/route.ts` | edit |
| root | `.env.example` | create |
---
### Task-Liste
## Task-Liste
#### Block A — #41: Auth.js v5 MFA-Signal-Fix (KRITISCH)
- [ ] **Task 1:** `getAppBaseUrl()` in `packages/api/src/lib/app-base-url.ts` erstellen.
- Liest `process.env["NEXTAUTH_URL"]` (trimmed).
- Wenn gesetzt und nicht leer → gibt den Wert zurück (trailing slash entfernen).
- Wenn leer/fehlend **und** `NODE_ENV === "production"` → wirft `Error("NEXTAUTH_URL must be set in production — email links will be broken")`.
- Sonst (development/test) → gibt `"http://localhost:3100"` zurück und loggt einmalig eine Warnung.
- → Datei: `packages/api/src/lib/app-base-url.ts`
- [ ] **Task A1:** Custom Error-Klassen in `apps/web/src/server/auth.ts` definieren.
Importiere `CredentialsSignin` aus `"next-auth"` und erstelle 3 Subklassen direkt in der Datei:
```typescript
class MfaRequiredError extends CredentialsSignin {
code = "MFA_REQUIRED" as const;
}
class MfaRequiredSetupError extends CredentialsSignin {
code = "MFA_REQUIRED_SETUP" as const;
}
class InvalidTotpError extends CredentialsSignin {
code = "INVALID_TOTP" as const;
}
```
Ersetze alle 3 `throw new Error(...)` in `authorize()`:
- `throw new Error("MFA_REQUIRED:...")` → `throw new MfaRequiredError()`
- `throw new Error("MFA_REQUIRED_SETUP:...")` → `throw new MfaRequiredSetupError()`
- `throw new Error("INVALID_TOTP")` → `throw new InvalidTotpError()`
- `throw new Error("Too many login attempts...")` → bleibt als normaler `throw` (nicht durch MFA verursacht, `return null` wäre die sauberere Variante — aber Audit-Eintrag muss davor kommen)
→ Datei: `apps/web/src/server/auth.ts`
- [ ] **Task 2:** `invite.ts` auf `getAppBaseUrl()` umstellen.
- Ersetze `const baseUrl = process.env["NEXTAUTH_URL"] ?? "http://localhost:3100";` durch `const baseUrl = getAppBaseUrl();`
- → Datei: `packages/api/src/router/invite.ts` Zeile 53
- [ ] **Task A2:** Signin-Page: Error-Code-Prüfung anpassen.
In Auth.js v5 beta mit `redirect: false` liefert `signIn()` bei `CredentialsSignin`-Subklassen:
`result.error = "<code>"` (also `"MFA_REQUIRED"`, `"MFA_REQUIRED_SETUP"`, `"INVALID_TOTP"`) **oder** `result.error = "CredentialsSignin"` + `result.code = "<code>"` — je nach beta-Version.
**Strategie für beta.25:** Exakte string-Gleichheit statt `.includes()` verwenden:
```typescript
if (result.error === "MFA_REQUIRED_SETUP" || result.code === "MFA_REQUIRED_SETUP") { ... }
if (result.error === "MFA_REQUIRED" || result.code === "MFA_REQUIRED") { ... }
if (result.error === "INVALID_TOTP" || result.code === "INVALID_TOTP") { ... }
```
Rate-Limit-Fall: `return null` statt `throw` macht in `authorize()` aus `.includes("Too many")`-Prüfung ein separates Problem — hier stattdessen im `!isValid`-Pfad bleiben und für Rate-Limit `result.error` = `"CredentialsSignin"` annehmen (der Nutzer sieht keinen spezifischen Text mehr, was ok ist — Rate-Limit-Feedback ist absichtlich vage).
→ Datei: `apps/web/src/app/auth/signin/page.tsx`
- [ ] **Task 3:** `auth.ts` auf `getAppBaseUrl()` umstellen.
- Ersetze `const baseUrl = process.env["NEXTAUTH_URL"] ?? "http://localhost:3100";` durch `const baseUrl = getAppBaseUrl();`
- → Datei: `packages/api/src/router/auth.ts` Zeile 50
- [ ] **Task A3:** Unit-Test für Auth-Fehler-Signale.
Teste `authorize()` direkt mit einem gemockten prisma: MFA-aktivierter User ohne `totp`-Feld → wirft `MfaRequiredError`. Falsches TOTP → wirft `InvalidTotpError`. Richtiger TOTP-Code → gibt User-Objekt zurück.
→ Datei: `apps/web/src/server/auth.test.ts` (neu)
- [ ] **Task 4:** `.env.example` anlegen mit allen required und optionalen Variablen.
- Sections: App, Auth, Database, Redis, SMTP, AI, Dev-Tools (pgAdmin)
- Jede Variable: Kommentar (required/optional, Beschreibung), Beispielwert.
- `NEXTAUTH_URL` als REQUIRED mit Hinweis "must be the public URL (e.g. https://capakraken.example.com) — used in email links; do not use localhost in production"
- → Datei: `.env.example`
- [ ] **Task 5:** Health-Route um `baseUrl`-Check erweitern.
- Liest `NEXTAUTH_URL` direkt (kein `getAppBaseUrl()` — soll nie werfen, nur reporten).
- Fügt `"baseUrl": { "configured": bool, "isLocalhost": bool }` zum JSON-Response hinzu.
- `configured: false` wenn Var fehlt/leer; `isLocalhost: true` wenn Wert mit `http://localhost` beginnt.
- → Datei: `apps/web/src/app/api/health/route.ts`
---
#### Block B — #38: MFA-Banner als Client-Component
## Abhängigkeiten
- [ ] **Task B1:** `MfaPromptBanner.tsx` zu vollständig client-seitigem Component umbauen.
Statt `userId`-Prop: eigene tRPC-Query `trpc.user.getMfaStatus.useQuery()`. Der Banner rendert sich selbst unsichtbar (`return null`) solange der Query lädt oder `totpEnabled === true`. Kein `userId`-Prop mehr nötig.
→ Datei: `apps/web/src/components/security/MfaPromptBanner.tsx`
- [ ] **Task B2:** `(app)/layout.tsx` bereinigen.
Den `prisma.user.findUnique`-Call für `showMfaPrompt` entfernen. Den `prisma`-Import entfernen. `MfaPromptBanner` ohne Props rendern — immer eingebunden für `MFA_PROMPT_ROLES` (oder direkt bedingungslos, da der Banner selbst den Status kennt).
→ Datei: `apps/web/src/app/(app)/layout.tsx`
- Task 2 **und** Task 3 benötigen Task 1 (Funktion muss existieren).
- Task 2 und Task 3 können **parallel** ausgeführt werden (unterschiedliche Dateien).
- Task 4 und Task 5 sind **unabhängig** von allen anderen und können parallel ausgeführt werden.
---
#### Block C — #39: Dashboard-Widgets nach MFA-Aktivierung (Investigation)
- [ ] **Task C1:** Nach #41-Fix: Login mit MFA-Nutzer testen und prüfen, ob Dashboard-Widgets laden.
Hypothese: Die Widgets schlugen fehl weil die Session nach MFA-Aktivierung als ungültig galt (der active-session-Check in `auth.ts` könnte nach einem tRPC-Call eine veraltete Session sehen). Nach #41-Fix und erneutem Login sollte die Session gültig sein.
Falls das Problem dann noch besteht: Untersuche ob `enableTotp` die laufende Session invalidiert (fehlende Session-Neuausstellung nach MFA-Aktivierung).
→ Kein Code-Change bis C1-Prüfung abgeschlossen. Falls nötig: `user.enableTotp` gibt nach Erfolg `{ requireRelogin: true }` zurück und das Frontend leitet auf `/auth/signin` weiter.
---
#### Block D — #40: NEXTAUTH_URL Required
- [ ] **Task D1:** `NEXTAUTH_URL`-Fallback in `docker-compose.yml` entfernen.
```yaml
# vorher:
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3100}
# nachher:
NEXTAUTH_URL: ${NEXTAUTH_URL:?NEXTAUTH_URL must be set in .env}
```
→ Datei: `docker-compose.yml`
- [ ] **Task D2:** `.env.example` aktualisieren — `NEXTAUTH_URL=https://your-domain.com` dokumentieren.
→ Datei: `.env.example` (falls vorhanden, sonst erstellen)
---
#### Block E — #42: Admin-Password-Reset-Mutation
- [ ] **Task E1:** `resetUserPassword`-Funktion in `user-procedure-support.ts` hinzufügen.
Die vorhandene `setUserPassword` setzt das Passwort beim User-Create. Eine neue Funktion `resetUserPassword(ctx, input: { userId, newPassword })` hasht `newPassword` mit argon2id und schreibt `passwordHash`. Nur erreichbar via `adminProcedure`.
→ Datei: `packages/api/src/router/user-procedure-support.ts`
- [ ] **Task E2:** Mutation in `user.ts` registrieren.
```typescript
resetPassword: adminProcedure
.input(z.object({ userId: z.string(), newPassword: z.string().min(8) }))
.mutation(({ ctx, input }) => resetUserPassword(ctx, input)),
```
→ Datei: `packages/api/src/router/user.ts`
- [ ] **Task E3:** Unit-Test für `resetUserPassword`.
Happy path: Mutation updated `passwordHash`. Negative cases: non-admin schlägt fehl, leeres Passwort schlägt fehl.
→ Datei: `packages/api/src/__tests__/reset-password.test.ts`
- [ ] **Task E4:** Admin-UI — Password-Reset-Button in der User-Verwaltung.
Im Admin-Bereich (vermutlich `apps/web/src/app/(app)/admin/users/`) Button "Reset Password" mit einem Modal, das ein neues Passwort verlangt (min. 8 Zeichen). Kein separates Ticket nötig — gehört zu #42.
→ Datei: Admin-User-List-Page (Pfad nach aktuellem Stand ermitteln)
---
### Abhängigkeiten
```
A1 → A2 → A3 (Sequential: A2 braucht den finalen Code aus A1)
B1 → B2 (Sequential: B2 entfernt was B1 ersetzt)
A3, B2, D1, E3 (Können parallel laufen, sobald A1 fertig)
C1 nach A1+A2 (Test)
E4 nach E1+E2
```
**Reihenfolge:**
1. A1 (kritisch, zuerst — entsperrt alle anderen)
2. A2 (direkt danach, am selben Batch)
3. Parallel: B1+B2, D1+D2, E1+E2
4. A3, E3, E4
5. C1 (manuelle Verifikation nach Deploy)
---
### Akzeptanzkriterien
## Akzeptanzkriterien
- [ ] `pnpm test:unit` läuft grün
- [ ] `pnpm --filter @capakraken/web exec tsc --noEmit` — keine neuen Fehler
- [ ] Login mit `hn@hartmut-noerenberg.com` + `Admin12345!` zeigt TOTP-Eingabe (nicht "Invalid email or password")
- [ ] Korrekter TOTP-Code → erfolgreicher Login
- [ ] Nach MFA-Aktivierung via UI verschwindet der Banner sofort ohne Reload
- [ ] Nach Logout von `capakraken.hartmut-noerenberg.com` landet man auf der Login-Seite der gleichen Domain
- [ ] `docker compose up` ohne `NEXTAUTH_URL` schlägt mit lesebarer Fehlermeldung fehl
- [ ] Admin kann Passwort eines anderen Users über die UI zurücksetzen
- [ ] `pnpm --filter @capakraken/web exec tsc --noEmit` — keine neuen TS-Errors
- [ ] `pnpm test:e2e:email` — alle 3 E2E-Tests bestehen weiterhin
- [ ] Wenn `NEXTAUTH_URL=https://capakraken.hartmut-noerenberg.com`: E-Mail-Links enthalten diese Domain
- [ ] Wenn `NEXTAUTH_URL` fehlt und `NODE_ENV=production`: `createInvite` / `requestPasswordReset` werfen einen klaren Fehler beim Link-Bau
- [ ] `GET /api/health` liefert `baseUrl.configured: true/false` und `baseUrl.isLocalhost: bool`
- [ ] `.env.example` existiert und dokumentiert alle Pflichtfelder
---
### Risiken & offene Fragen
## Risiken & offene Fragen
1. **Auth.js beta.25 vs beta.30 `result.error` vs `result.code`:** Die genaue Rückgabestruktur von `signIn({ redirect: false })` variiert zwischen beta-Versionen. Task A2 muss nach A1 kurz manuell verifiziert werden — ggf. beide Felder (`result.error` UND `result.code`) prüfen.
2. **#39 Dashboard-Bug:** Ursache noch unklar. Nach #41-Fix ist zu prüfen, ob der Fehler von selbst verschwindet (Session-Neustart beim Re-Login). Falls nicht: Session-Re-Issue nach `enableTotp` nötig (Breaking Change in der MFA-Setup-UX).
3. **MFA_REQUIRED enthält `userId` im aktuellen throw:** Die userId war im `"MFA_REQUIRED:userId"`-String für eine geplante Re-Auth-Mechanik. Mit `CredentialsSignin` entfällt dieses Encoding — die userId wird in der TOTP-Verifikation direkt aus der Session gelesen (was korrekt ist, da die Session beim ersten erfolgreichen Password-Check bereits existiert). Kein Datenverlust.
- **Unit-Tests für Router:** Bestehende Tests für `invite.ts` und `auth.ts` müssen `NEXTAUTH_URL` in der Testumgebung gesetzt haben (oder `NODE_ENV=test` → Dev-Fallback greift). Vorhandene Tests prüfen, ob `process.env["NEXTAUTH_URL"]` dort gesetzt wird.
- **`docker-compose.prod.yml`** delegiert ENV-Vars an `.env.production` (gitignored). Das `.env.example` deckt ab, was dort stehen muss — kein Code-Change nötig.
- **Trailing Slash:** NEXTAUTH_URL könnte mit oder ohne `/` enden. `getAppBaseUrl()` sollte trailing slashes normalisieren.