diff --git a/resources/js/login/error_handling.js b/resources/js/login/error_handling.js new file mode 100644 index 00000000..e1141605 --- /dev/null +++ b/resources/js/login/error_handling.js @@ -0,0 +1,32 @@ +export const SESSION_EXPIRED_MESSAGE = + 'Your session has expired or your browser is blocking cookies. Please reload the page and try again.'; +export const GENERIC_RETRY_MESSAGE = 'Something went wrong. Please try again.'; +export const TOO_MANY_REQUESTS_MESSAGE = 'Too many requests. Try it later.'; + +export const ACCOUNT_NOT_FOUND = 'account_not_found'; +export const SESSION_EXPIRED = 'session_expired'; +export const TOO_MANY_REQUESTS = 'too_many_requests'; +export const GENERIC_ERROR = 'generic_error'; + +/** + * Classifies a failed POST /auth/login/account-verify. + * Only a real 404 means the account does not exist; anything else must never + * switch the page to the sign-up state. + * @param {number|undefined} status HTTP status (undefined on network errors) + * @returns {{type: string, message: string}} + */ +export const classifyAccountVerifyError = (status) => { + switch (Number(status)) { + case 404: + // non-empty on purpose: errors.email only has to be truthy for this branch + return {type: ACCOUNT_NOT_FOUND, message: ' '}; + case 419: + return {type: SESSION_EXPIRED, message: SESSION_EXPIRED_MESSAGE}; + case 429: + return {type: TOO_MANY_REQUESTS, message: TOO_MANY_REQUESTS_MESSAGE}; + default: + return {type: GENERIC_ERROR, message: GENERIC_RETRY_MESSAGE}; + } +}; + +export const isSessionExpired = (error) => Number(error?.status) === 419; diff --git a/resources/js/login/error_handling.test.js b/resources/js/login/error_handling.test.js new file mode 100644 index 00000000..e8314e24 --- /dev/null +++ b/resources/js/login/error_handling.test.js @@ -0,0 +1,42 @@ +import { + classifyAccountVerifyError, + isSessionExpired, + ACCOUNT_NOT_FOUND, + SESSION_EXPIRED, + TOO_MANY_REQUESTS, + GENERIC_ERROR, + SESSION_EXPIRED_MESSAGE, + GENERIC_RETRY_MESSAGE, + TOO_MANY_REQUESTS_MESSAGE, +} from './error_handling'; + +describe('classifyAccountVerifyError', () => { + test('404 is the only status that means account not found', () => { + const r = classifyAccountVerifyError(404); + expect(r.type).toBe(ACCOUNT_NOT_FOUND); + expect(r.message.trim()).toBe(''); + expect(r.message).toBeTruthy(); + }); + + test('419 is a session/cookies problem, not account not found', () => { + expect(classifyAccountVerifyError(419)).toEqual({type: SESSION_EXPIRED, message: SESSION_EXPIRED_MESSAGE}); + }); + + test('429 keeps the too many requests message', () => { + expect(classifyAccountVerifyError(429)).toEqual({type: TOO_MANY_REQUESTS, message: TOO_MANY_REQUESTS_MESSAGE}); + expect(TOO_MANY_REQUESTS_MESSAGE).toBe('Too many requests. Try it later.'); + }); + + test.each([500, 502, 503, 400, undefined, null])('status %p is a generic error', (status) => { + expect(classifyAccountVerifyError(status)).toEqual({type: GENERIC_ERROR, message: GENERIC_RETRY_MESSAGE}); + }); +}); + +describe('isSessionExpired', () => { + test('detects 419 only', () => { + expect(isSessionExpired({status: 419})).toBe(true); + expect(isSessionExpired({status: 404})).toBe(false); + expect(isSessionExpired({})).toBe(false); + expect(isSessionExpired(undefined)).toBe(false); + }); +}); diff --git a/resources/js/login/login.js b/resources/js/login/login.js index ee061b9a..1b184b5e 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -25,6 +25,13 @@ import CustomSnackbar from "../components/custom_snackbar"; import Banner from '../components/banner/banner'; import OtpInput from 'react-otp-input'; import {handleErrorResponse, handleThirdPartyProvidersVerbiage} from '../utils'; +import { + classifyAccountVerifyError, + isSessionExpired, + ACCOUNT_NOT_FOUND, + SESSION_EXPIRED, + SESSION_EXPIRED_MESSAGE +} from './error_handling'; import styles from './login.module.scss' import "./third_party_identity_providers.scss"; @@ -453,6 +460,8 @@ class LoginPage extends React.Component { user_verified: props.hasOwnProperty('user_verified') ? props.user_verified : false, user_active: props.hasOwnProperty('user_active') ? props.user_active : null, email_verified: props.hasOwnProperty('email_verified') ? props.email_verified : null, + account_not_found: false, + account_verify_error: null, errors: { email: '', otp: props.authError != '' ? props.authError : '', @@ -530,6 +539,10 @@ class LoginPage extends React.Component { this.showAlert(errors[0], 'error'); return; } + if (isSessionExpired(error)) { + this.showAlert(SESSION_EXPIRED_MESSAGE, 'error'); + return; + } this.showAlert('Oops... Something went wrong!', 'error'); }); return false; @@ -628,6 +641,8 @@ class LoginPage extends React.Component { user_pic: response.pic, user_fullname: response.full_name, user_verified: true, + account_not_found: false, + account_verify_error: null, user_active: response.is_active, email_verified: response.is_verified, authFlow: response.has_password_set ? password_flow : otp_flow, @@ -648,20 +663,20 @@ class LoginPage extends React.Component { let { response, status, message } = error; + const {type, message: errorMessage} = classifyAccountVerifyError(status); + let newErrors = {}; newErrors['password'] = ''; - newErrors['email'] = " "; - - if (status == 429) { - newErrors['email'] = "Too many requests. Try it later."; - } + newErrors['email'] = errorMessage; this.setState({ ...this.state, user_pic: null, user_fullname: null, user_verified: false, + account_not_found: type === ACCOUNT_NOT_FOUND, + account_verify_error: type === ACCOUNT_NOT_FOUND ? null : type, errors: newErrors, disableInput: false }); @@ -686,6 +701,10 @@ class LoginPage extends React.Component { 'We\'ve sent you a verification email. Please check your inbox and click the link to verify your account.', 'success'); }, (error) => { + if (isSessionExpired(error)) { + this.showAlert(SESSION_EXPIRED_MESSAGE, 'error'); + return; + } handleErrorResponse(error, (title, messageLines, type) => { const message = (messageLines ?? []).join(', ') this.showAlert(`${title}: ${message}`, type); @@ -701,6 +720,8 @@ class LoginPage extends React.Component { user_pic: null, user_fullname: null, user_verified: false, + account_not_found: false, + account_verify_error: null, user_active: null, email_verified: null, authFlow: "password", @@ -728,9 +749,9 @@ class LoginPage extends React.Component { } getSignUpSignInTitle() { - const { errors, user_active } = this.state; + const { account_not_found } = this.state; - if (errors.email && this.existingUserCanContinue()) { + if (account_not_found && this.existingUserCanContinue()) { return 'Create an account for:'; } return 'Sign in'; @@ -801,7 +822,28 @@ class LoginPage extends React.Component { // we already had an interaction and got an user error... this.state.errors.email !== '' && <> - {this.existingUserCanContinue() && + {this.state.account_verify_error === SESSION_EXPIRED && + + } + {this.state.account_verify_error !== null && + this.state.account_verify_error !== SESSION_EXPIRED && + this.state.account_verify_error !== 'too_many_requests' && + + } + {this.state.account_not_found && this.existingUserCanContinue() &&