# CapaKraken — Umsetzungsplan Gitea-Repo: `https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY` Stand: 2026-04-02 | Issues: #38–#42 (MFA Post-Activation Bugs) --- ## Plan: MFA Post-Activation Bugs #38–#42 ### Anforderungsanalyse 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. **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 & 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 | --- ### Task-Liste #### Block A — #41: Auth.js v5 MFA-Signal-Fix (KRITISCH) - [ ] **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 A2:** Signin-Page: Error-Code-Prüfung anpassen. In Auth.js v5 beta mit `redirect: false` liefert `signIn()` bei `CredentialsSignin`-Subklassen: `result.error = ""` (also `"MFA_REQUIRED"`, `"MFA_REQUIRED_SETUP"`, `"INVALID_TOTP"`) **oder** `result.error = "CredentialsSignin"` + `result.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 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) --- #### Block B — #38: MFA-Banner als Client-Component - [ ] **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` --- #### 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 - [ ] `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 --- ### 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.