fix(auth): resolve MFA post-activation login failures — tickets #38 #40 #41

#41 (critical): Replace plain Error throws in authorize() with CredentialsSignin
subclasses (MfaRequiredError / MfaRequiredSetupError / InvalidTotpError).
Auth.js v5 forwards CredentialsSignin.code to the client via SignInResponse.code;
plain throws become CallbackRouteError and the message is never visible.
Signin page now checks result.code ?? result.error for exact code matching.

#38: MfaPromptBanner converted to fully client-side component via
trpc.user.getMfaStatus.useQuery() — disappears immediately after MFA enable
without requiring page reload. Snooze key remains userId-scoped via useSession().
Server-side prisma.user.findUnique call removed from (app)/layout.tsx.

#40: NEXTAUTH_URL default fallback removed from docker-compose.yml.
The variable is now required (:?) — docker compose up fails with a descriptive
error if the value is missing, preventing silent localhost redirect bugs.

Tests: auth.test.ts (5), MfaPromptBanner.test.ts (7), reset-password.test.ts (6)
All new tests green. pnpm --filter @capakraken/web exec tsc --noEmit clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-04-02 00:20:47 +02:00
parent 435c871e1f
commit e5ecea81c5
8 changed files with 341 additions and 459 deletions
@@ -1,34 +1,41 @@
"use client";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
const SNOOZE_KEY = "capakraken_mfa_prompt_snoozed_until";
const SNOOZE_DAYS = 7;
/**
* Banner shown to ADMIN / MANAGER users who have not yet enabled TOTP MFA.
* The user can dismiss it for SNOOZE_DAYS days ("Remind me later").
*
* Props are resolved server-side in the layout, so no client-side DB fetch
* is needed here.
* Fetches MFA status client-side via tRPC so the banner reacts immediately
* after the user enables MFA — no full-page reload required.
* Snooze state is scoped by userId to prevent cross-user leakage on shared browsers.
*/
export function MfaPromptBanner({ userId }: { userId: string }) {
const [visible, setVisible] = useState(false);
export function MfaPromptBanner() {
const { data: mfaStatus } = trpc.user.getMfaStatus.useQuery();
const { data: session } = useSession();
const userId = (session?.user as { id?: string } | undefined)?.id ?? "";
const [snoozed, setSnoozed] = useState<boolean | null>(null);
// Read snooze state from localStorage on mount (keyed by userId)
useEffect(() => {
if (!userId) return;
try {
const raw = localStorage.getItem(`${SNOOZE_KEY}_${userId}`);
if (raw) {
const until = Number(raw);
if (!isNaN(until) && Date.now() < until) {
return; // still snoozed
setSnoozed(true);
return;
}
}
} catch {
// localStorage unavailable (SSR guard — should not happen in a client component)
// localStorage unavailable
}
setVisible(true);
setSnoozed(false);
}, [userId]);
function snooze() {
@@ -38,10 +45,15 @@ export function MfaPromptBanner({ userId }: { userId: string }) {
} catch {
// ignore
}
setVisible(false);
setSnoozed(true);
}
if (!visible) return null;
// Don't render until we know the MFA status and snooze state
if (mfaStatus === undefined || snoozed === null) return null;
// Already enabled — no banner needed
if (mfaStatus.totpEnabled) return null;
// Snoozed
if (snoozed) return null;
return (
<div