From 460e078925715254743f0c2ebfbc12d4e911fc4c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 27 Jun 2026 22:23:09 +0200
Subject: [PATCH] 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.
---
messages/de.json | 4 +-
messages/en.json | 4 +-
messages/es.json | 4 +-
messages/fr.json | 4 +-
messages/it.json | 4 +-
messages/pl.json | 4 +-
.../__tests__/totp-card.test.tsx | 46 ++++++++++++
.../settings/security-section/totp-card.tsx | 75 ++++++++++++++-----
8 files changed, 122 insertions(+), 23 deletions(-)
create mode 100644 src/components/settings/security-section/__tests__/totp-card.test.tsx
diff --git a/messages/de.json b/messages/de.json
index 7c4a91435..628c3fd9f 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5064,7 +5064,9 @@
"remainingOther": "{count} Wiederherstellungscodes übrig.",
"regenerate": "Codes neu erzeugen",
"regenFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden.",
- "savedIt": "Ich habe sie gespeichert"
+ "savedIt": "Ich habe sie gespeichert",
+ "regenerateTitle": "Wiederherstellungscodes neu erzeugen?",
+ "regenerateConfirm": "Damit werden deine aktuellen Wiederherstellungscodes ersetzt. Deine bisherigen Codes funktionieren sofort nicht mehr, und der neue Satz wird nur einmal angezeigt."
},
"keys": {
"title": "Sicherheitsschlüssel",
diff --git a/messages/en.json b/messages/en.json
index b93195156..79eddff16 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5064,7 +5064,9 @@
"remainingOther": "{count} recovery codes remaining.",
"regenerate": "Regenerate codes",
"regenFailed": "Could not regenerate recovery codes.",
- "savedIt": "I've saved them"
+ "savedIt": "I've saved them",
+ "regenerateTitle": "Regenerate recovery codes?",
+ "regenerateConfirm": "This replaces your current recovery codes. Your existing codes will stop working immediately, and a new set is shown only once."
},
"keys": {
"title": "Security keys",
diff --git a/messages/es.json b/messages/es.json
index 482776912..50f8d9dd4 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5064,7 +5064,9 @@
"remainingOther": "Quedan {count} códigos de recuperación.",
"regenerate": "Regenerar códigos",
"regenFailed": "No se pudieron regenerar los códigos de recuperación.",
- "savedIt": "Los he guardado"
+ "savedIt": "Los he guardado",
+ "regenerateTitle": "¿Regenerar los códigos de recuperación?",
+ "regenerateConfirm": "Esto reemplaza tus códigos de recuperación actuales. Tus códigos existentes dejarán de funcionar de inmediato y el nuevo conjunto se muestra solo una vez."
},
"keys": {
"title": "Llaves de seguridad",
diff --git a/messages/fr.json b/messages/fr.json
index 2cd706a63..122199d74 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5064,7 +5064,9 @@
"remainingOther": "{count} codes de récupération restants.",
"regenerate": "Régénérer les codes",
"regenFailed": "Impossible de régénérer les codes de récupération.",
- "savedIt": "Je les ai enregistrés"
+ "savedIt": "Je les ai enregistrés",
+ "regenerateTitle": "Régénérer les codes de récupération ?",
+ "regenerateConfirm": "Cela remplace vos codes de récupération actuels. Vos codes existants cesseront de fonctionner immédiatement et le nouveau jeu n'est affiché qu'une seule fois."
},
"keys": {
"title": "Clés de sécurité",
diff --git a/messages/it.json b/messages/it.json
index 028f384d3..743cb5e34 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5064,7 +5064,9 @@
"remainingOther": "{count} codici di recupero rimanenti.",
"regenerate": "Rigenera i codici",
"regenFailed": "Impossibile rigenerare i codici di recupero.",
- "savedIt": "Li ho salvati"
+ "savedIt": "Li ho salvati",
+ "regenerateTitle": "Rigenerare i codici di recupero?",
+ "regenerateConfirm": "Questa operazione sostituisce i codici di recupero attuali. I codici esistenti smetteranno di funzionare immediatamente e il nuovo set viene mostrato una sola volta."
},
"keys": {
"title": "Chiavi di sicurezza",
diff --git a/messages/pl.json b/messages/pl.json
index b0f909a44..7d344e71d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5064,7 +5064,9 @@
"remainingOther": "Pozostało {count} kodów odzyskiwania.",
"regenerate": "Wygeneruj kody ponownie",
"regenFailed": "Nie udało się ponownie wygenerować kodów odzyskiwania.",
- "savedIt": "Zapisałem je"
+ "savedIt": "Zapisałem je",
+ "regenerateTitle": "Wygenerować nowe kody odzyskiwania?",
+ "regenerateConfirm": "Spowoduje to zastąpienie obecnych kodów odzyskiwania. Dotychczasowe kody natychmiast przestaną działać, a nowy zestaw zostanie pokazany tylko raz."
},
"keys": {
"title": "Klucze bezpieczeństwa",
diff --git a/src/components/settings/security-section/__tests__/totp-card.test.tsx b/src/components/settings/security-section/__tests__/totp-card.test.tsx
new file mode 100644
index 000000000..9b778c031
--- /dev/null
+++ b/src/components/settings/security-section/__tests__/totp-card.test.tsx
@@ -0,0 +1,46 @@
+/**
+ * v1.23 Phase-Q — `` 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(
+
+ {node}
+ ,
+ );
+}
+
+describe(" — recovery-code regeneration", () => {
+ it("SSRs the enabled card with the regenerate action", () => {
+ const html = render();
+ 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()");
+ });
+});
diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx
index 80e19d06b..2d1d78791 100644
--- a/src/components/settings/security-section/totp-card.tsx
+++ b/src/components/settings/security-section/totp-card.tsx
@@ -74,6 +74,7 @@ export function TotpCard({
const { t } = useTranslations();
const queryClient = useQueryClient();
const codeFieldId = useId();
+ const disableCodeId = useId();
const [setup, setSetup] = useState(null);
const [qrDataUrl, setQrDataUrl] = useState(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({
)}