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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user