Files
Nexus/apps/web/src/components/security/MfaPromptBanner.tsx
T
Hartmut 74ed45ddfc fix(web): add missing loading and error states to MfaPromptBanner, Step1Identity, MobileSummaryClient
- MfaPromptBanner: silently hide on query error (non-critical advisory banner)
- Step1Identity: show skeleton placeholders while blueprint list loads
- MobileSummaryClient: add error state with retry button for dashboard queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:22:18 +02:00

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, isError } = 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; silently hide on error
if (isError || 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>
);
}