Summary
Surface 2FA recovery code state to users and let them download / print their codes safely. Today, recovery codes are generated and shown exactly once — if the user closes the tab, they're locked out the moment they lose their authenticator app.
Why we need this
UserManager.CountRecoveryCodesAsync exists and is the standard way for a user to see "how many recovery codes do I have left". It is currently not exposed anywhere in SimpleModule. Combined with the once-only display behavior of GenerateRecoveryCodesEndpoint, this creates a real user-experience risk:
- User enables 2FA, sees the codes, thinks "I'll save these later", closes the tab. Now they have 10 unknown codes that are still valid but unrecoverable.
- User uses some codes over time and has no idea how many remain. The first time they find out is when login fails.
- User wants to print the codes for a safe deposit box but the only output today is a copy-paste-friendly list rendered in the browser — no print stylesheet, no download.
The fix is small, additive, and aligned with how every mature Identity-based product (GitHub, Microsoft, Google) handles this.
How the user will use it
On Manage/TwoFactorAuthentication (the existing 2FA management page):
- Status row: "Recovery codes: 7 of 10 remaining" with a warning style when ≤3 remain. Calls
CountRecoveryCodesAsync.
- "Download codes" button on the page that displays codes after generation — emits a plain-text
.txt file (simplemodule-recovery-codes.txt) containing the codes plus a header with the user's email and a generated-on timestamp.
- "Print codes" button that triggers
window.print() against a print-styled view (clean black-on-white list, no chrome).
- "Generate new codes" CTA when remaining ≤3, linking to the existing
GenerateRecoveryCodesEndpoint. Make clear that doing so invalidates all existing codes.
- After regeneration, show the same download / print buttons.
Important: we do not need to (and should not) re-display previously generated codes. Identity stores them hashed, like passwords — they cannot be recovered. The only paths are "download at generation time" or "regenerate, which invalidates the old set". The UI must communicate this clearly so users don't expect retrieval.
Implementation notes
- Add a
recoveryCodesRemaining field to the props returned by Manage/TwoFactorAuthentication's view endpoint (AccountSecurityEndpoint or wherever the page is loaded). Compute via await userManager.CountRecoveryCodesAsync(user).
- Update
GenerateRecoveryCodesEndpoint (or its view counterpart) to pass the freshly generated codes through to the page once, and include a printable / downloadable rendering. Existing ShowRecoveryCodes page is the right place.
- Front-end: extend the
ShowRecoveryCodes page with Download and Print buttons. Print view = a dedicated <div> styled with a @media print rule that hides everything else.
- Front-end: extend the 2FA management page with the remaining-count display + warning when low.
- Do not add an endpoint that returns existing codes — by design, they are hashed and unretrievable. Add a comment in the endpoint explaining this so future contributors don't try.
- Tests:
recoveryCodesRemaining decreases after a redemption; warning threshold renders correctly at boundaries.
Benefits
- Eliminates a self-inflicted lockout class for 2FA users.
- Tiny implementation cost —
CountRecoveryCodesAsync exists and works; the rest is markup.
- Brings UX in line with industry norms — feature parity with GitHub / Microsoft / Google account pages.
- Reduces support workload (recovery requests are expensive to handle securely).
Acceptance criteria
Summary
Surface 2FA recovery code state to users and let them download / print their codes safely. Today, recovery codes are generated and shown exactly once — if the user closes the tab, they're locked out the moment they lose their authenticator app.
Why we need this
UserManager.CountRecoveryCodesAsyncexists and is the standard way for a user to see "how many recovery codes do I have left". It is currently not exposed anywhere in SimpleModule. Combined with the once-only display behavior ofGenerateRecoveryCodesEndpoint, this creates a real user-experience risk:The fix is small, additive, and aligned with how every mature Identity-based product (GitHub, Microsoft, Google) handles this.
How the user will use it
On
Manage/TwoFactorAuthentication(the existing 2FA management page):CountRecoveryCodesAsync..txtfile (simplemodule-recovery-codes.txt) containing the codes plus a header with the user's email and a generated-on timestamp.window.print()against a print-styled view (clean black-on-white list, no chrome).GenerateRecoveryCodesEndpoint. Make clear that doing so invalidates all existing codes.Important: we do not need to (and should not) re-display previously generated codes. Identity stores them hashed, like passwords — they cannot be recovered. The only paths are "download at generation time" or "regenerate, which invalidates the old set". The UI must communicate this clearly so users don't expect retrieval.
Implementation notes
recoveryCodesRemainingfield to the props returned byManage/TwoFactorAuthentication's view endpoint (AccountSecurityEndpointor wherever the page is loaded). Compute viaawait userManager.CountRecoveryCodesAsync(user).GenerateRecoveryCodesEndpoint(or its view counterpart) to pass the freshly generated codes through to the page once, and include a printable / downloadable rendering. ExistingShowRecoveryCodespage is the right place.ShowRecoveryCodespage withDownloadandPrintbuttons. Print view = a dedicated<div>styled with a@media printrule that hides everything else.recoveryCodesRemainingdecreases after a redemption; warning threshold renders correctly at boundaries.Benefits
CountRecoveryCodesAsyncexists and works; the rest is markup.Acceptance criteria
CountRecoveryCodesAsync..txtdownload and a print-styled view.