e5ecea81c5
#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>
86 lines
2.9 KiB
TypeScript
86 lines
2.9 KiB
TypeScript
"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.
|
|
* 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() {
|
|
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) {
|
|
setSnoozed(true);
|
|
return;
|
|
}
|
|
}
|
|
} catch {
|
|
// localStorage unavailable
|
|
}
|
|
setSnoozed(false);
|
|
}, [userId]);
|
|
|
|
function snooze() {
|
|
try {
|
|
const until = Date.now() + SNOOZE_DAYS * 24 * 60 * 60 * 1000;
|
|
localStorage.setItem(`${SNOOZE_KEY}_${userId}`, String(until));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
setSnoozed(true);
|
|
}
|
|
|
|
// 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
|
|
role="alert"
|
|
aria-label="MFA setup recommendation"
|
|
className="flex items-center justify-between gap-4 bg-amber-50 px-4 py-2.5 text-sm text-amber-900 dark:bg-amber-900/20 dark:text-amber-200 border-b border-amber-200 dark:border-amber-700/50"
|
|
>
|
|
<span>
|
|
<strong className="font-semibold">Protect your account:</strong>{" "}
|
|
Your role has elevated permissions. We recommend enabling multi-factor authentication (MFA).
|
|
</span>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<Link
|
|
href="/account/security"
|
|
className="rounded-md bg-amber-600 px-3 py-1 text-xs font-semibold text-white hover:bg-amber-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-amber-600 dark:bg-amber-500 dark:hover:bg-amber-400"
|
|
>
|
|
Set up MFA
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={snooze}
|
|
className="rounded-md px-2 py-1 text-xs text-amber-700 hover:bg-amber-100 dark:text-amber-300 dark:hover:bg-amber-800/50"
|
|
>
|
|
Remind me later
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|