fix(security): confirm before regenerating recovery codes

Regenerating recovery codes invalidates the current set immediately, so the
action now sits behind a confirmation dialog matching the disable-MFA pattern,
with a consequence line spelling out that the existing codes stop working. While
in the card: give the disable-step TOTP input the same digit-strip + 6-char cap
and exact-length submit guard as setup, swap the static disable-code id for a
useId value, and lift the copy-secret button to a 44px touch target on phones.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 22:23:09 +02:00
1 parent 2c1151dd40
commit 460e078925
8 files changed
+122 -23

No files matched your search

@@ -0,0 +1,46 @@
/**
* v1.23 Phase-Q — `<TotpCard>` recovery-code regeneration guard.
*
* Regenerating recovery codes is destructive: the previous set stops working
* immediately. This suite pins that the regenerate action is gated behind a
* confirmation dialog (matching the disable-MFA pattern), not a bare click.
*
* Project convention is SSR-only tests (no `@testing-library/react`); the
* dialog's open-state behaviour is Radix's own, so we assert the source wires
* the trigger + consequence copy rather than driving a portal at runtime.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { TotpCard } from "../totp-card";
function render(node: React.ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
describe("<TotpCard> — recovery-code regeneration", () => {
it("SSRs the enabled card with the regenerate action", () => {
const html = render(<TotpCard enabled recoveryCodesRemaining={3} />);
expect(html).toContain("Regenerate codes");
});
it("wraps regenerate in a confirmation dialog with a consequence line", () => {
const src = readFileSync(resolve(__dirname, "../totp-card.tsx"), "utf8");
// The regenerate action is a dialog trigger, not a bare mutate-on-click.
expect(src).toContain("settings.security.recovery.regenerateTitle");
expect(src).toContain("settings.security.recovery.regenerateConfirm");
// The mutate only fires from inside the dialog's confirm action.
expect(src).toContain("regenerate.mutate()");
});
});
@@ -74,6 +74,7 @@ export function TotpCard({
const { t } = useTranslations();
const queryClient = useQueryClient();
const codeFieldId = useId();
const disableCodeId = useId();
const [setup, setSetup] = useState<SetupData | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
@@ -262,7 +263,7 @@ export function TotpCard({
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
className="min-h-11 min-w-11 shrink-0 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
onClick={copySecret}
aria-label={t("common.copy")}
>
@@ -350,18 +351,47 @@ export function TotpCard({
)}
</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => regenerate.mutate()}
disabled={regenerate.isPending}
>
{regenerate.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.recovery.regenerate")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={regenerate.isPending}
>
{regenerate.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.recovery.regenerate")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.security.recovery.regenerateTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.security.recovery.regenerateConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={regenerate.isPending}
aria-busy={regenerate.isPending || undefined}
onClick={(e) => {
e.preventDefault();
regenerate.mutate();
}}
>
{regenerate.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.recovery.regenerate")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -383,13 +413,19 @@ export function TotpCard({
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="disable-code">
<Label htmlFor={disableCodeId}>
{t("settings.security.totp.currentCode")}
</Label>
<Input
id="disable-code"
id={disableCodeId}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value)}
onChange={(e) =>
setDisableCode(
disableMethod === "totp"
? e.target.value.replace(/\D/g, "").slice(0, 6)
: e.target.value,
)
}
inputMode={disableMethod === "totp" ? "numeric" : "text"}
autoComplete="one-time-code"
placeholder={
@@ -415,7 +451,12 @@ export function TotpCard({
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={disable.isPending || disableCode.length < 6}
disabled={
disable.isPending ||
(disableMethod === "totp"
? disableCode.length !== 6
: disableCode.length < 6)
}
aria-busy={disable.isPending || undefined}
onClick={(e) => {
e.preventDefault();