diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs index 2c9963be..530d3461 100644 --- a/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs @@ -108,7 +108,9 @@ ILogger logger new { recoveryCodes = recoveryCodes!.ToArray(), - statusMessage = "Your authenticator app has been verified.", + userEmail = user.Email, + generatedAt = DateTimeOffset.UtcNow.ToString("O"), + statusKey = "authenticator-verified", } ); } @@ -197,7 +199,9 @@ ILogger logger new { recoveryCodes = recoveryCodes!.ToArray(), - 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 febdec87..e4b6e24b 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", @@ -20,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.", @@ -49,5 +51,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..4a90bcd4 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', @@ -58,6 +61,7 @@ export const UsersKeys = { OneRecoveryCodeDescription: 'TwoFactor.OneRecoveryCodeDescription', OneRecoveryCodeLinkText: 'TwoFactor.OneRecoveryCodeLinkText', OneRecoveryCodeTitle: 'TwoFactor.OneRecoveryCodeTitle', + RecoveryCodesRemaining: 'TwoFactor.RecoveryCodesRemaining', ResetAuthenticatorApp: 'TwoFactor.ResetAuthenticatorApp', ResetRecoveryCodes: 'TwoFactor.ResetRecoveryCodes', SetUpAuthenticatorApp: 'TwoFactor.SetUpAuthenticatorApp', @@ -65,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 ff5ff5a4..4f9129d5 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs @@ -9,6 +9,8 @@ namespace SimpleModule.Users.Pages.Account; +// 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 9cbbd6b0..4d8d2ec8 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx @@ -5,14 +5,58 @@ import { UsersKeys } from '@/Locales/keys'; interface Props { recoveryCodes: string[]; - statusMessage?: string; + userEmail: string; + generatedAt: string; + statusKey?: 'authenticator-verified' | 'recovery-codes-generated'; } -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, + statusKey, +}: Props) { const { t } = useTranslation('Users'); + const printHeader = t(UsersKeys.ShowRecoveryCodes.PrintHeader, { + 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 ( + +

{t(UsersKeys.ShowRecoveryCodes.Title)}

{statusMessage && ( @@ -26,25 +70,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)}