Skip to content

feat(ui): add two-factor authentication (TOTP) support#5888

Open
luannmoreira wants to merge 3 commits intomasterfrom
feat/ui-react-mfa-login
Open

feat(ui): add two-factor authentication (TOTP) support#5888
luannmoreira wants to merge 3 commits intomasterfrom
feat/ui-react-mfa-login

Conversation

@luannmoreira
Copy link
Member

@luannmoreira luannmoreira commented Feb 25, 2026

Summary

Adds two-factor authentication (TOTP) support to the admin UI, allowing users to secure their accounts with
authenticator apps like Google Authenticator, Authy, or 1Password.

Key Features:

  • MFA Setup - Users can enable 2FA from their profile page with QR code scanning
  • Recovery Codes - Generates 10 single-use backup codes for account recovery
  • MFA Login Flow - Dedicated login page with 6-digit code input (supports paste)
  • Recovery Flow - Users can regain access using backup codes if they lose their TOTP device
  • Seamless Navigation - Programmatic routing without page reloads preserves auth state

Implementation Details:

  • Auth flow handles MFA challenges (401 with x-mfa-token header) without page reloads
  • MFA tokens are stored in memory only (not persisted) for security
  • Recovery codes displayed once during setup with copy/download options
  • Countdown timer shows recovery code expiry (7 days after use)

Dependencies Added:

  • qrcode.react - QR code generation for authenticator app setup
  • otpauth - TOTP URI formatting (otpauth://totp/...)

Closes https://github.com/shellhub-io/team/issues/62

@luannmoreira luannmoreira self-assigned this Feb 25, 2026
@luannmoreira luannmoreira requested review from a team as code owners February 25, 2026 12:44
@luannmoreira luannmoreira added kind/feature New feature or request area/ui javascript Pull requests that update Javascript code product/cloud Issue/PR related to Cloud Edition status/ready-for-testing labels Feb 25, 2026
@github-actions
Copy link

github-actions bot commented Feb 25, 2026

Claude finished @luannmoreira's task in 9m 15s —— View job


Code Review Complete

Reviewed 18 files across code quality, security, testing, TypeScript/React patterns, and architecture. 14 inline issues posted (2 critical, 4 high, 7 medium, 1 low).

Additional notes

MFA endpoints are cloud/enterprise-only: All /api/user/mfa/* endpoints are registered exclusively in cloud/internal/cloud/routes/routes.go. The community edition API has no MFA route handlers, meaning the entire MFA UI will produce 404 errors in CE. The Profile page MFA section needs a feature gate (similar to how FeatureGate is used for firewall rules) to hide MFA when running community edition, or the MFA endpoints need to be added to the core API.

No test coverage for any new MFA code: The PR adds ~2100 lines of new code with zero tests. The existing authStore.test.ts and interceptors.test.ts don't cover the new MFA actions or the modified 401 handling. Critical test gaps include:

  • loginWithMfa, recoverWithCode, and MFA-aware login flow in authStore
  • 401 interceptor branch for x-mfa-token header
  • useCountdown hook (time-sensitive security logic)
  • MFA login/recover page components

Code duplication: OTP input handling (state, validation, auto-advance, backspace) is duplicated across 3 components. Footer links are duplicated across 3 pages. Download/copy utility logic is duplicated across 2 modals. Consider extracting shared OtpInput component, AuthPageFooter, and downloadTextFile utility.

MfaEnableDrawer is 552 lines: Manages 4 distinct steps with separate state, handlers, and UI. Consider extracting each step into its own component.


If you've addressed the feedback and want a new review, tag @shellhub-io/admin and a team member can trigger it.

@luannmoreira luannmoreira force-pushed the feat/ui-react-mfa-login branch from 9c2f663 to 2be77c1 Compare February 25, 2026 12:45
@luannmoreira luannmoreira force-pushed the feat/ui-react-mfa-login branch 2 times, most recently from 23f5243 to b81ea57 Compare February 25, 2026 19:17
@otavio
Copy link
Member

otavio commented Feb 25, 2026

/review

luannmoreira added a commit that referenced this pull request Mar 4, 2026
- Rename MFA routes: /mfa-login → /login-mfa, /mfa-recover → /recover-mfa,
  /mfa-reset-request → /reset-request-mfa for consistent action-mfa pattern
- Remove MfaRecoveryCodesModal (never rendered; regeneration not supported)
- Remove MfaResetVerify page (replaced by MfaResetComplete flow)
- Replace useShallow object selectors with individual per-field selectors
  in MfaRecover, MfaResetRequest, MfaDisableDialog, and Profile
- Fix stale closure in MfaRecoveryTimeoutModal: capture onClose in ref
- Memoize getValue/isComplete in useOtpInput; add try-finally in
  useRecoveryCodeActions for reliable blob URL cleanup
- Clear mfaToken/pendingMfaUser/mfaRecoveryExpiry in setCompleteSession
- Clear qrLink immediately after MFA enable success alongside secret/recoveryCodes
- Use strict equality for login endpoint check in interceptors (=== "/api/login")
- Remove FormEvent from MfaEnableDrawer button handlers (not form submits)
- Add aria-label to OTP inputs in MfaResetComplete for screen reader support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@luannmoreira luannmoreira force-pushed the feat/ui-react-mfa-login branch from 20e4bc6 to 8c4106a Compare March 4, 2026 12:28
luannmoreira added a commit that referenced this pull request Mar 4, 2026
- Rename MFA routes: /mfa-login → /login-mfa, /mfa-recover → /recover-mfa,
  /mfa-reset-request → /reset-request-mfa for consistent action-mfa pattern
- Remove MfaRecoveryCodesModal (never rendered; regeneration not supported)
- Remove MfaResetVerify page (replaced by MfaResetComplete flow)
- Replace useShallow object selectors with individual per-field selectors
  in MfaRecover, MfaResetRequest, MfaDisableDialog, and Profile
- Fix stale closure in MfaRecoveryTimeoutModal: capture onClose in ref
- Memoize getValue/isComplete in useOtpInput; add try-finally in
  useRecoveryCodeActions for reliable blob URL cleanup
- Clear mfaToken/pendingMfaUser/mfaRecoveryExpiry in setCompleteSession
- Clear qrLink immediately after MFA enable success alongside
secret/recoveryCodes
- Use strict equality for login endpoint check in interceptors
(=== "/api/login")
- Remove FormEvent from MfaEnableDrawer button handlers (not form submits)
- Add aria-label to OTP inputs in MfaResetComplete for screen reader support
@luannmoreira luannmoreira force-pushed the feat/ui-react-mfa-login branch from 8c4106a to 58acdae Compare March 4, 2026 12:47
@gustavosbarreto
Copy link
Member

/review

@github-actions
Copy link

github-actions bot commented Mar 4, 2026

Code Review

  • Gathered PR context
  • Reviewing with 5 specialized agents
  • Posting feedback

@otavio
Copy link
Member

otavio commented Mar 4, 2026

/review

@github-actions
Copy link

github-actions bot commented Mar 4, 2026

Claude finished @otavio's task in 7m 21s —— View job


Review: Round 6

  • Gathered PR context
  • Reviewed with 5 specialized agents (code quality, security, testing, TS/React patterns, architecture)
  • Aggregated and deduplicated findings against 5 prior review rounds
  • Posted inline feedback

Summary

Found 5 new issues across the MFA implementation:

# Severity File Issue
1 🔴 high MfaRecover.tsx:186 Recovery timeout modal never renders when X-Expires-At header is missing — user gets stuck after successful recovery
2 🟡 medium MfaDisableDialog.tsx:31 Dialog state (OTP digits, mode, error) not reset on reopen — stale UI from previous interaction
3 🟡 medium MfaRecover.tsx:61-66 handleDisableMfa silently swallows errors — modal closes with no user feedback on failure
4 🟡 medium useOtpInput.ts:48 Paste silently discarded when content length doesn't match — no feedback for common formats like 123-456
5 🟡 medium MfaLogin.test.tsx:69-72 Test pre-sets error in store, making the "error on invalid code" assertion vacuous

No issues found by: Security review, Architecture/API contract review

🤖 Generated with Claude Code

@luannmoreira luannmoreira force-pushed the feat/ui-react-mfa-login branch from 58acdae to ee17601 Compare March 4, 2026 14:30
luannmoreira added a commit that referenced this pull request Mar 4, 2026
- Rename MFA routes: /mfa-login → /login-mfa, /mfa-recover → /recover-mfa,
  /mfa-reset-request → /reset-request-mfa for consistent action-mfa pattern
- Remove MfaRecoveryCodesModal (never rendered; regeneration not supported)
- Remove MfaResetVerify page (replaced by MfaResetComplete flow)
- Replace useShallow object selectors with individual per-field selectors
  in MfaRecover, MfaResetRequest, MfaDisableDialog, and Profile
- Fix stale closure in MfaRecoveryTimeoutModal: capture onClose in ref
- Memoize getValue/isComplete in useOtpInput; partial paste fills available
  digits instead of silently discarding; add try-finally in
  useRecoveryCodeActions for reliable blob URL cleanup
- Clear mfaToken/pendingMfaUser/mfaRecoveryExpiry in setCompleteSession
- Clear qrLink immediately after MFA enable success
- Use strict equality for login endpoint check in interceptors
- Remove FormEvent from MfaEnableDrawer button handlers
- Add aria-label to OTP inputs in MfaResetComplete
- Navigate to /dashboard when backend omits X-Expires-At header
- Show error feedback when disable MFA fails in recovery window
- Reset MfaDisableDialog state on reopen to avoid stale inputs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
luannmoreira added a commit that referenced this pull request Mar 4, 2026
- Rename MFA routes: /mfa-login → /login-mfa, /mfa-recover → /recover-mfa,
  /mfa-reset-request → /reset-request-mfa for consistent action-mfa pattern
- Remove MfaRecoveryCodesModal (never rendered; regeneration not supported)
- Remove MfaResetVerify page (replaced by MfaResetComplete flow)
- Replace useShallow object selectors with individual per-field selectors
  in MfaRecover, MfaResetRequest, MfaDisableDialog, and Profile
- Fix stale closure in MfaRecoveryTimeoutModal: capture onClose in ref
- Memoize getValue/isComplete in useOtpInput; partial paste fills available
  digits instead of silently discarding; add try-finally in
  useRecoveryCodeActions for reliable blob URL cleanup
- Clear mfaToken/pendingMfaUser/mfaRecoveryExpiry in setCompleteSession
- Clear qrLink immediately after MFA enable success
- Use strict equality for login endpoint check in interceptors
- Remove FormEvent from MfaEnableDrawer button handlers
- Add aria-label to OTP inputs in MfaResetComplete
- Navigate to /dashboard when backend omits X-Expires-At header
- Show error feedback when disable MFA fails in recovery window
- Reset MfaDisableDialog state on reopen to avoid stale inputs
@luannmoreira luannmoreira force-pushed the feat/ui-react-mfa-login branch from ee17601 to 7955b3e Compare March 4, 2026 14:32
@gustavosbarreto
Copy link
Member

/review

@github-actions
Copy link

github-actions bot commented Mar 4, 2026

Claude finished @gustavosbarreto's task in 9m 39s —— View job


Review: Round 7

  • Gathered PR context
  • Reviewed with 5 specialized agents (code quality, security, testing, TS/React patterns, architecture)
  • Aggregated and deduplicated findings against 6 prior review rounds
  • Validated all candidates against source code

Summary

Reviewed 33 files across code quality, security, testing, language patterns, and architecture (including cross-repo validation against cloud/ backend) — no new issues found. All candidate findings from this round were either already reported in prior reviews, not actually bugs upon source code validation, or below the high-signal threshold.

Validated and discarded:

  • MfaDisableDialog email-reset form submission: not exploitable (no text inputs, submit button hidden, all buttons are type="button")
  • MfaRecover recovery code stale closure: not a bug (setRecoveryCode("") only runs in catch, handleDisableMfa only callable from success-path modal)
  • useCountdown timestamp unit mismatch: verified correct (backend uses .Unix() → seconds, frontend correctly multiplies by 1000)
  • Over-broad Zustand selectors in Login.tsx/MfaResetComplete.tsx: same pattern already reported in round 2

Previously reported issues still open (not re-reported inline):

  • disableMfa({ recovery_code: recoveryCode }) in MfaRecover.tsx:64 — recovery code was consumed by recoverWithCode; relies on backend cache window
  • mfaResetUserId stores username/email, not a MongoDB user ID — breaks in-app reset path
  • MfaDisableRequest type includes main_email_code/recovery_email_code fields the backend ignores
  • Backend email template generates /reset-mfa link but React UI expects /v2/ui/reset-mfa
  • MfaRecoveryCodesModal is dead code (never imported outside its test)

If you've addressed the feedback and want a new review, tag @shellhub-io/admin and a team member can trigger it.

🤖 Generated with Claude Code

luannmoreira added a commit that referenced this pull request Mar 5, 2026
- Rename MFA routes: /mfa-login → /login-mfa, /mfa-recover → /recover-mfa,
  /mfa-reset-request → /reset-request-mfa for consistent action-mfa pattern
- Remove MfaRecoveryCodesModal (never rendered; regeneration not supported)
- Remove MfaResetVerify page (replaced by MfaResetComplete flow)
- Replace useShallow object selectors with individual per-field selectors
  in MfaRecover, MfaResetRequest, MfaDisableDialog, and Profile
- Fix stale closure in MfaRecoveryTimeoutModal: capture onClose in ref
- Memoize getValue/isComplete in useOtpInput; partial paste fills available
  digits instead of silently discarding; add try-finally in
  useRecoveryCodeActions for reliable blob URL cleanup
- Clear mfaToken/pendingMfaUser/mfaRecoveryExpiry in setCompleteSession
- Clear qrLink immediately after MFA enable success
- Use strict equality for login endpoint check in interceptors
- Remove FormEvent from MfaEnableDrawer button handlers
- Add aria-label to OTP inputs in MfaResetComplete
- Navigate to /dashboard when backend omits X-Expires-At header
- Show error feedback when disable MFA fails in recovery window
- Reset MfaDisableDialog state on reopen to avoid stale inputs
@luannmoreira luannmoreira force-pushed the feat/ui-react-mfa-login branch from 7955b3e to 3243558 Compare March 5, 2026 12:25
luannmoreira added a commit that referenced this pull request Mar 5, 2026
- Rename MFA routes: /mfa-login → /login-mfa, /mfa-recover → /recover-mfa,
  /mfa-reset-request → /reset-request-mfa for consistent action-mfa pattern
- Remove MfaRecoveryCodesModal (never rendered; regeneration not supported)
- Remove MfaResetVerify page (replaced by MfaResetComplete flow)
- Replace useShallow object selectors with individual per-field selectors
  in MfaRecover, MfaResetRequest, MfaDisableDialog, and Profile
- Fix stale closure in MfaRecoveryTimeoutModal: capture onClose in ref
- Memoize getValue/isComplete in useOtpInput; partial paste fills available
  digits instead of silently discarding; add try-finally in
  useRecoveryCodeActions for reliable blob URL cleanup
- Clear mfaToken/pendingMfaUser/mfaRecoveryExpiry in setCompleteSession
- Clear qrLink immediately after MFA enable success
- Use strict equality for login endpoint check in interceptors
- Remove FormEvent from MfaEnableDrawer button handlers
- Add aria-label to OTP inputs in MfaResetComplete
- Navigate to /dashboard when backend omits X-Expires-At header
- Show error feedback when disable MFA fails in recovery window
- Reset MfaDisableDialog state on reopen to avoid stale inputs
@luannmoreira luannmoreira force-pushed the feat/ui-react-mfa-login branch from 3243558 to 73ee7c6 Compare March 5, 2026 12:34
- Add complete MFA flow with TOTP verification
- Add MFA recovery codes and email-based reset flow
- Add MFA login, recovery, and reset pages
- Fix recovery code reuse bug in MfaRecover
- Fix recovery codes modal to prevent generating invalid codes
- Add comprehensive error handling and accessibility improvements
- Optimize OTP input with useCallback
- Add ARIA labels and keyboard navigation support
- Add security enhancements (autocomplete=off, QRCode validation)
- Document TOTP secret trust boundary security tradeoff
- Fix import organization and remove duplicate types
- MfaEnableDrawer: step flow, recovery email, QR/OTP verification,
  sensitive data cleanup (secret, recoveryCodes, qrLink)
- MfaDisableDialog: TOTP/recovery code modes, email-reset flow
- MfaRecoveryTimeoutModal: countdown display, disable action, close behavior
- MfaLogin: OTP input, submit, countdown/timeout flow
- MfaRecover: recovery code submission, timeout modal integration
- useOtpInput: input handling, paste, keyboard nav, completion state
- useCountdown: countdown logic, expiry detection
- authStore: login/logout flows, MFA token handling, partialize exclusions
- interceptors: MFA token storage scoped to /api/login only
- Fix auto-cleanup (afterEach cleanup) in test setup for jsdom environment
- Rename MFA routes: /mfa-login → /login-mfa, /mfa-recover → /recover-mfa,
  /mfa-reset-request → /reset-request-mfa for consistent action-mfa pattern
- Remove MfaRecoveryCodesModal (never rendered; regeneration not supported)
- Remove MfaResetVerify page (replaced by MfaResetComplete flow)
- Replace useShallow object selectors with individual per-field selectors
  in MfaRecover, MfaResetRequest, MfaDisableDialog, and Profile
- Fix stale closure in MfaRecoveryTimeoutModal: capture onClose in ref
- Memoize getValue/isComplete in useOtpInput; partial paste fills available
  digits instead of silently discarding; add try-finally in
  useRecoveryCodeActions for reliable blob URL cleanup
- Clear mfaToken/pendingMfaUser/mfaRecoveryExpiry in setCompleteSession
- Clear qrLink immediately after MFA enable success
- Use strict equality for login endpoint check in interceptors
- Remove FormEvent from MfaEnableDrawer button handlers
- Add aria-label to OTP inputs in MfaResetComplete
- Navigate to /dashboard when backend omits X-Expires-At header
- Show error feedback when disable MFA fails in recovery window
- Reset MfaDisableDialog state on reopen to avoid stale inputs
@luannmoreira luannmoreira force-pushed the feat/ui-react-mfa-login branch from 73ee7c6 to a62562c Compare March 9, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/ui javascript Pull requests that update Javascript code kind/feature New feature or request product/cloud Issue/PR related to Cloud Edition status/ready-for-testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants