From 212829bc35dffd3cecb0cd2e03a21fa352af45d3 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 15 May 2026 19:10:24 +0000 Subject: [PATCH 1/4] feat(identity): recovery codes status row + download/print (closes #180) - 2FA management page now shows 'Recovery codes: N remaining' as a neutral status row when count is >= 4. The existing low-threshold alerts at <=3, 1, and 0 are kept. - ShowRecoveryCodes page gains Download (.txt) and Print buttons. Download writes a header line (user email + generated-at) followed by the codes. - Print uses an inline @media print stylesheet that hides chrome and re-styles the codes block as black-on-white monospace. - Both render paths in AccountSecurityEndpoint now thread userEmail and generatedAt through so the header is accurate. - Added a comment on GenerateRecoveryCodesEndpoint explaining that codes are hashed (like passwords) and the only honest paths are 'download/print at generation time' or 'regenerate (invalidates prior set)'. Prevents future contributors from trying to add a retrieve-existing-codes endpoint. No new API contracts; UI-only + props passthrough. Existing Users suite still 70/70. --- .../Account/AccountSecurityEndpoint.cs | 4 + .../src/SimpleModule.Users/Locales/en.json | 4 + .../src/SimpleModule.Users/Locales/keys.ts | 4 + .../Account/GenerateRecoveryCodesEndpoint.cs | 6 ++ .../Pages/Account/ShowRecoveryCodes.tsx | 82 +++++++++++++++---- .../Pages/Account/TwoFactorAuthentication.tsx | 8 ++ 6 files changed, 90 insertions(+), 18 deletions(-) diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs index 2c9963be..ce18f746 100644 --- a/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs @@ -108,6 +108,8 @@ ILogger logger new { recoveryCodes = recoveryCodes!.ToArray(), + userEmail = await userManager.GetEmailAsync(user), + generatedAt = DateTimeOffset.UtcNow.ToString("u"), statusMessage = "Your authenticator app has been verified.", } ); @@ -197,6 +199,8 @@ ILogger logger new { recoveryCodes = recoveryCodes!.ToArray(), + userEmail = await userManager.GetEmailAsync(user), + generatedAt = DateTimeOffset.UtcNow.ToString("u"), statusMessage = "You have generated new recovery codes.", } ); diff --git a/modules/Users/src/SimpleModule.Users/Locales/en.json b/modules/Users/src/SimpleModule.Users/Locales/en.json index febdec87..6be328f5 100644 --- a/modules/Users/src/SimpleModule.Users/Locales/en.json +++ b/modules/Users/src/SimpleModule.Users/Locales/en.json @@ -13,6 +13,7 @@ "TwoFactor.FewRecoveryCodesLinkText": "generate a new set of recovery codes", "TwoFactor.ForgetBrowser": "Forget this browser", "TwoFactor.Disable2fa": "Disable 2FA", + "TwoFactor.RecoveryCodesRemaining": "Recovery codes: {count} remaining", "TwoFactor.ResetRecoveryCodes": "Reset recovery codes", "TwoFactor.AddAuthenticatorApp": "Add authenticator app", "TwoFactor.SetUpAuthenticatorApp": "Set up authenticator app", @@ -49,5 +50,8 @@ "ShowRecoveryCodes.Title": "Recovery codes", "ShowRecoveryCodes.WarningTitle": "Put these codes in a safe place.", "ShowRecoveryCodes.WarningDescription": "If you lose your device and don't have the recovery codes you will lose access to your account.", + "ShowRecoveryCodes.DownloadButton": "Download (.txt)", + "ShowRecoveryCodes.PrintButton": "Print", + "ShowRecoveryCodes.PrintHeader": "SimpleModule recovery codes — generated for {email} on {date}", "ShowRecoveryCodes.BackButton": "Back to two-factor authentication" } diff --git a/modules/Users/src/SimpleModule.Users/Locales/keys.ts b/modules/Users/src/SimpleModule.Users/Locales/keys.ts index 45ffbf26..ecc8a5ad 100644 --- a/modules/Users/src/SimpleModule.Users/Locales/keys.ts +++ b/modules/Users/src/SimpleModule.Users/Locales/keys.ts @@ -39,6 +39,9 @@ export const UsersKeys = { }, ShowRecoveryCodes: { BackButton: 'ShowRecoveryCodes.BackButton', + DownloadButton: 'ShowRecoveryCodes.DownloadButton', + PrintButton: 'ShowRecoveryCodes.PrintButton', + PrintHeader: 'ShowRecoveryCodes.PrintHeader', Title: 'ShowRecoveryCodes.Title', WarningDescription: 'ShowRecoveryCodes.WarningDescription', WarningTitle: 'ShowRecoveryCodes.WarningTitle', @@ -59,6 +62,7 @@ export const UsersKeys = { OneRecoveryCodeLinkText: 'TwoFactor.OneRecoveryCodeLinkText', OneRecoveryCodeTitle: 'TwoFactor.OneRecoveryCodeTitle', ResetAuthenticatorApp: 'TwoFactor.ResetAuthenticatorApp', + RecoveryCodesRemaining: 'TwoFactor.RecoveryCodesRemaining', ResetRecoveryCodes: 'TwoFactor.ResetRecoveryCodes', SetUpAuthenticatorApp: 'TwoFactor.SetUpAuthenticatorApp', Status: { diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs index ff5ff5a4..ccced98c 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs @@ -9,6 +9,12 @@ namespace SimpleModule.Users.Pages.Account; +// Recovery codes are stored hashed (like passwords). There is intentionally +// no "get my existing codes" endpoint — once the user closes the +// ShowRecoveryCodes page they can only download/print the fresh set if they +// did so at generation time, or regenerate, which invalidates the old set. +// Do not add a "retrieve codes" code path; the cryptographic contract makes +// it impossible to honor and users would build a false expectation. public class GenerateRecoveryCodesEndpoint : IViewEndpoint { public const string Route = UsersConstants.Routes.GenerateRecoveryCodes; diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx index 9cbbd6b0..bfac71ab 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx @@ -5,14 +5,49 @@ import { UsersKeys } from '@/Locales/keys'; interface Props { recoveryCodes: string[]; + userEmail?: string | null; + generatedAt?: string | null; statusMessage?: string; } -export default function ShowRecoveryCodes({ recoveryCodes, statusMessage }: Props) { +function downloadCodes(codes: string[], header: string, fileName = 'simplemodule-recovery-codes.txt') { + const body = [header, '', ...codes].join('\n'); + const blob = new Blob([body], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +export default function ShowRecoveryCodes({ + recoveryCodes, + userEmail, + generatedAt, + statusMessage, +}: Props) { const { t } = useTranslation('Users'); + const printHeader = t(UsersKeys.ShowRecoveryCodes.PrintHeader, { + email: userEmail ?? '', + date: generatedAt ?? new Date().toISOString(), + }); return ( + +

{t(UsersKeys.ShowRecoveryCodes.Title)}

{statusMessage && ( @@ -26,25 +61,36 @@ export default function ShowRecoveryCodes({ recoveryCodes, statusMessage }: Prop {t(UsersKeys.ShowRecoveryCodes.WarningDescription)} -
- {recoveryCodes.map((code) => ( - - {code} - - ))} +
+

{printHeader}

+
+ {recoveryCodes.map((code) => ( + + {code} + + ))} +
- +
+ + + +
); } diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/TwoFactorAuthentication.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/TwoFactorAuthentication.tsx index f7f426c2..e0931258 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/TwoFactorAuthentication.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/TwoFactorAuthentication.tsx @@ -45,6 +45,14 @@ export default function TwoFactorAuthentication({ {is2faEnabled && ( <> + {recoveryCodesLeft >= 4 && ( +

+ {t(UsersKeys.TwoFactor.RecoveryCodesRemaining, { + count: String(recoveryCodesLeft), + })} +

+ )} + {recoveryCodesLeft === 0 && ( {t(UsersKeys.TwoFactor.NoRecoveryCodesTitle)} From 245c6439463f99b0870779606213afbc295741ba Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 20 May 2026 15:56:43 +0200 Subject: [PATCH 2/4] style(identity): biome format ShowRecoveryCodes.tsx Wrap long downloadCodes signature to satisfy 100-char line width. --- .../SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx index bfac71ab..6718e1ef 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx @@ -10,7 +10,11 @@ interface Props { statusMessage?: string; } -function downloadCodes(codes: string[], header: string, fileName = 'simplemodule-recovery-codes.txt') { +function downloadCodes( + codes: string[], + header: string, + fileName = 'simplemodule-recovery-codes.txt', +) { const body = [header, '', ...codes].join('\n'); const blob = new Blob([body], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); From 972b01626e1bb785416448179b042d388f94a2f4 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 20 May 2026 16:18:05 +0200 Subject: [PATCH 3/4] refactor(identity): tighten recovery-codes flow - Pass statusKey instead of hardcoded English message; resolve via i18n on client (one was a duplicate of TwoFactor.Status.AuthenticatorVerified). - Use user.Email directly and DateTimeOffset.ToString("O") to match module convention. - Make userEmail/generatedAt required props; drop dead-code fallbacks. - Trim verbose comment in GenerateRecoveryCodesEndpoint to the load-bearing why. --- .../Account/AccountSecurityEndpoint.cs | 12 ++++++------ .../src/SimpleModule.Users/Locales/en.json | 1 + .../src/SimpleModule.Users/Locales/keys.ts | 1 + .../Account/GenerateRecoveryCodesEndpoint.cs | 8 ++------ .../Pages/Account/ShowRecoveryCodes.tsx | 17 +++++++++++------ 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs index ce18f746..530d3461 100644 --- a/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs @@ -108,9 +108,9 @@ ILogger logger new { recoveryCodes = recoveryCodes!.ToArray(), - userEmail = await userManager.GetEmailAsync(user), - generatedAt = DateTimeOffset.UtcNow.ToString("u"), - statusMessage = "Your authenticator app has been verified.", + userEmail = user.Email, + generatedAt = DateTimeOffset.UtcNow.ToString("O"), + statusKey = "authenticator-verified", } ); } @@ -199,9 +199,9 @@ ILogger logger new { recoveryCodes = recoveryCodes!.ToArray(), - userEmail = await userManager.GetEmailAsync(user), - generatedAt = DateTimeOffset.UtcNow.ToString("u"), - statusMessage = "You have generated new recovery codes.", + userEmail = user.Email, + generatedAt = DateTimeOffset.UtcNow.ToString("O"), + statusKey = "recovery-codes-generated", } ); } diff --git a/modules/Users/src/SimpleModule.Users/Locales/en.json b/modules/Users/src/SimpleModule.Users/Locales/en.json index 6be328f5..e4b6e24b 100644 --- a/modules/Users/src/SimpleModule.Users/Locales/en.json +++ b/modules/Users/src/SimpleModule.Users/Locales/en.json @@ -21,6 +21,7 @@ "TwoFactor.Status.BrowserForgotten": "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2FA code.", "TwoFactor.Status.2faDisabled": "2FA has been disabled. You can reenable 2FA when you setup an authenticator app.", "TwoFactor.Status.AuthenticatorVerified": "Your authenticator app has been verified.", + "TwoFactor.Status.RecoveryCodesGenerated": "You have generated new recovery codes.", "EnableAuthenticator.Title": "Configure authenticator app", "EnableAuthenticator.Intro": "To use an authenticator app go through the following steps:", "EnableAuthenticator.Step1": "Download a two-factor authenticator app like Microsoft Authenticator or Google Authenticator.", diff --git a/modules/Users/src/SimpleModule.Users/Locales/keys.ts b/modules/Users/src/SimpleModule.Users/Locales/keys.ts index ecc8a5ad..9122f952 100644 --- a/modules/Users/src/SimpleModule.Users/Locales/keys.ts +++ b/modules/Users/src/SimpleModule.Users/Locales/keys.ts @@ -69,6 +69,7 @@ export const UsersKeys = { '2faDisabled': 'TwoFactor.Status.2faDisabled', AuthenticatorVerified: 'TwoFactor.Status.AuthenticatorVerified', BrowserForgotten: 'TwoFactor.Status.BrowserForgotten', + RecoveryCodesGenerated: 'TwoFactor.Status.RecoveryCodesGenerated', }, Title: 'TwoFactor.Title', }, diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs index ccced98c..4f9129d5 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs @@ -9,12 +9,8 @@ namespace SimpleModule.Users.Pages.Account; -// Recovery codes are stored hashed (like passwords). There is intentionally -// no "get my existing codes" endpoint — once the user closes the -// ShowRecoveryCodes page they can only download/print the fresh set if they -// did so at generation time, or regenerate, which invalidates the old set. -// Do not add a "retrieve codes" code path; the cryptographic contract makes -// it impossible to honor and users would build a false expectation. +// Recovery codes are stored hashed: there is no "retrieve codes" endpoint by design. +// The plaintext is only available at generation time (here and on regenerate). public class GenerateRecoveryCodesEndpoint : IViewEndpoint { public const string Route = UsersConstants.Routes.GenerateRecoveryCodes; diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx index 6718e1ef..4d8d2ec8 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx @@ -5,9 +5,9 @@ import { UsersKeys } from '@/Locales/keys'; interface Props { recoveryCodes: string[]; - userEmail?: string | null; - generatedAt?: string | null; - statusMessage?: string; + userEmail: string; + generatedAt: string; + statusKey?: 'authenticator-verified' | 'recovery-codes-generated'; } function downloadCodes( @@ -31,13 +31,18 @@ export default function ShowRecoveryCodes({ recoveryCodes, userEmail, generatedAt, - statusMessage, + statusKey, }: Props) { const { t } = useTranslation('Users'); const printHeader = t(UsersKeys.ShowRecoveryCodes.PrintHeader, { - email: userEmail ?? '', - date: generatedAt ?? new Date().toISOString(), + email: userEmail, + date: generatedAt, }); + const statusKeyToTranslation = { + 'authenticator-verified': UsersKeys.TwoFactor.Status.AuthenticatorVerified, + 'recovery-codes-generated': UsersKeys.TwoFactor.Status.RecoveryCodesGenerated, + } as const; + const statusMessage = statusKey ? t(statusKeyToTranslation[statusKey]) : null; return ( From bf8e081b6a38bd332b6290399ff0987d25def93f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 20 May 2026 16:26:29 +0200 Subject: [PATCH 4/4] chore(users): alphabetize Users i18n keys to match generator output --- modules/Users/src/SimpleModule.Users/Locales/keys.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/Users/src/SimpleModule.Users/Locales/keys.ts b/modules/Users/src/SimpleModule.Users/Locales/keys.ts index 9122f952..4a90bcd4 100644 --- a/modules/Users/src/SimpleModule.Users/Locales/keys.ts +++ b/modules/Users/src/SimpleModule.Users/Locales/keys.ts @@ -61,8 +61,8 @@ export const UsersKeys = { OneRecoveryCodeDescription: 'TwoFactor.OneRecoveryCodeDescription', OneRecoveryCodeLinkText: 'TwoFactor.OneRecoveryCodeLinkText', OneRecoveryCodeTitle: 'TwoFactor.OneRecoveryCodeTitle', - ResetAuthenticatorApp: 'TwoFactor.ResetAuthenticatorApp', RecoveryCodesRemaining: 'TwoFactor.RecoveryCodesRemaining', + ResetAuthenticatorApp: 'TwoFactor.ResetAuthenticatorApp', ResetRecoveryCodes: 'TwoFactor.ResetRecoveryCodes', SetUpAuthenticatorApp: 'TwoFactor.SetUpAuthenticatorApp', Status: {