@@ -31,8 +31,13 @@ import EmailErrorActions from "./components/email_error_actions";
3131import ThirdPartyIdentityProviders from "./components/third_party_identity_providers" ;
3232import TwoFactorForm from "./components/two_factor_form" ;
3333import RecoveryCodeForm from "./components/recovery_code_form" ;
34+ import {
35+ RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY ,
36+ DEFAULT_RECOVERY_CODES_LOW_THRESHOLD ,
37+ } from "../shared/recovery_codes" ;
3438
3539import styles from "./login.module.scss" ;
40+ import recoveryCodesStyles from "../components/recovery_codes.module.scss" ;
3641import "./third_party_identity_providers.scss" ;
3742import {
3843 FLOW ,
@@ -93,6 +98,11 @@ class LoginPage extends React.Component {
9398 // change writes otp_lifetime atomically with flow, so props.otpLifetime
9499 // is only ever missing when there's no pending OTP to show a countdown for.
95100 passwordlessLifetime : props . otpLifetime ?? null ,
101+ // Set once a recovery-code login succeeds with a low remaining count, so
102+ // the redirect can be held until the user acknowledges the warning -
103+ // this is the only point where the SPA still controls the page (see
104+ // onVerifyRecovery()/onContinueAfterLowRecoveryCodes() below).
105+ lowRecoveryCodesWarning : null ,
96106 } ;
97107
98108 if ( props . authError != "" && ! this . state . user_fullname ) {
@@ -130,6 +140,7 @@ class LoginPage extends React.Component {
130140 this . onVerify2FA = this . onVerify2FA . bind ( this ) ;
131141 this . onResend2FA = this . onResend2FA . bind ( this ) ;
132142 this . onVerifyRecovery = this . onVerifyRecovery . bind ( this ) ;
143+ this . onContinueAfterLowRecoveryCodes = this . onContinueAfterLowRecoveryCodes . bind ( this ) ;
133144 this . onUseRecovery = this . onUseRecovery . bind ( this ) ;
134145 this . onBackToOtp = this . onBackToOtp . bind ( this ) ;
135146 this . resetToPasswordFlow = this . resetToPasswordFlow . bind ( this ) ;
@@ -540,17 +551,38 @@ class LoginPage extends React.Component {
540551
541552 verifyRecoveryCode ( recoveryCode , this . props . token ) . then (
542553 ( payload ) => {
543- // See onVerify2FA() for rationale.
544554 const { response } = payload ;
545- window . location . href =
555+ const redirectUrl =
546556 ( response && response . redirect_url ) || window . location . href ;
557+ const remaining = response && response . recovery_codes_remaining ;
558+ const threshold =
559+ ( response && response . recovery_codes_low_threshold ) ??
560+ DEFAULT_RECOVERY_CODES_LOW_THRESHOLD ;
561+ const alreadyDismissed =
562+ sessionStorage . getItem ( RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY ) === "1" ;
563+
564+ if ( typeof remaining === "number" && remaining < threshold && ! alreadyDismissed ) {
565+ this . setState ( {
566+ ...this . state ,
567+ lowRecoveryCodesWarning : { remaining, redirectUrl } ,
568+ } ) ;
569+ return ;
570+ }
571+
572+ // See onVerify2FA() for rationale on using a real top-level navigation.
573+ window . location . href = redirectUrl ;
547574 } ,
548575 ( error ) => {
549576 this . handleMfaError ( error , "recovery" ) ;
550577 } ,
551578 ) ;
552579 }
553580
581+ onContinueAfterLowRecoveryCodes ( ) {
582+ sessionStorage . setItem ( RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY , "1" ) ;
583+ window . location . href = this . state . lowRecoveryCodesWarning . redirectUrl ;
584+ }
585+
554586 onUseRecovery ( ) {
555587 this . setState ( {
556588 ...this . state ,
@@ -833,7 +865,7 @@ class LoginPage extends React.Component {
833865 onVerify = { this . onVerify2FA }
834866 />
835867 ) }
836- { showRecoveryForm && (
868+ { showRecoveryForm && ! this . state . lowRecoveryCodesWarning && (
837869 < RecoveryCodeForm
838870 recoveryCode = { this . state . recoveryCode }
839871 recoveryError = { this . state . errors . recovery }
@@ -844,6 +876,27 @@ class LoginPage extends React.Component {
844876 onCancel = { this . resetToPasswordFlow }
845877 />
846878 ) }
879+ { showRecoveryForm && this . state . lowRecoveryCodesWarning && (
880+ < div
881+ className = { recoveryCodesStyles . low_code_warning }
882+ data-testid = "low-recovery-codes-warning"
883+ >
884+ < Typography variant = "body2" >
885+ You have { this . state . lowRecoveryCodesWarning . remaining } recovery
886+ code{ this . state . lowRecoveryCodesWarning . remaining === 1 ? "" : "s" } left.
887+ Regenerate them from your profile after signing in to avoid getting
888+ locked out.
889+ </ Typography >
890+ < Button
891+ variant = "contained"
892+ color = "primary"
893+ onClick = { this . onContinueAfterLowRecoveryCodes }
894+ data-testid = "continue-after-low-recovery-codes"
895+ >
896+ Continue
897+ </ Button >
898+ </ div >
899+ ) }
847900 { isPasswordFlow && (
848901 // proceed to ask for password ( 2nd step )
849902 < div data-testid = "password-form" >
0 commit comments