From 1f2e9b6965caa5068bde708d5730fb8db68d2c91 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Wed, 24 Jun 2026 18:25:08 +0000 Subject: [PATCH 01/21] feat: Add Login UI MFA Flow --- package.json | 4 +- resources/js/base_actions.js | 34 + resources/js/login/actions.js | 39 +- .../login/components/email_error_actions.js | 60 + .../js/login/components/email_input_form.js | 61 + .../components/existing_account_actions.js | 47 + resources/js/login/components/help_links.js | 78 + .../js/login/components/otp_help_links.js | 20 + .../js/login/components/otp_input_form.js | 117 ++ .../login/components/password_input_form.js | 193 ++ .../js/login/components/recovery_code_form.js | 84 + .../third_party_identity_providers.js | 36 + .../js/login/components/two_factor_form.js | 149 ++ resources/js/login/constants.js | 32 + resources/js/login/login.js | 1793 +++++++++-------- resources/js/login/login.module.scss | 29 + resources/js/shared/HTMLRender.jsx | 27 + resources/views/auth/login.blade.php | 17 + yarn.lock | 14 +- 19 files changed, 1966 insertions(+), 868 deletions(-) create mode 100644 resources/js/login/components/email_error_actions.js create mode 100644 resources/js/login/components/email_input_form.js create mode 100644 resources/js/login/components/existing_account_actions.js create mode 100644 resources/js/login/components/help_links.js create mode 100644 resources/js/login/components/otp_help_links.js create mode 100644 resources/js/login/components/otp_input_form.js create mode 100644 resources/js/login/components/password_input_form.js create mode 100644 resources/js/login/components/recovery_code_form.js create mode 100644 resources/js/login/components/third_party_identity_providers.js create mode 100644 resources/js/login/components/two_factor_form.js create mode 100644 resources/js/login/constants.js create mode 100644 resources/js/shared/HTMLRender.jsx diff --git a/package.json b/package.json index ccf4660e..729fca9c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "clean": "find . -name \"node_modules\" -type d -prune -exec rm -rf '{}' + && yarn", "build-dev": "./node_modules/.bin/webpack --config webpack.dev.js", "build": "./node_modules/.bin/webpack --config webpack.prod.js", - "serve": "webpack-dev-server --open --port=8888 --https --config webpack.dev.js", + "serve": "webpack-dev-server --open --port=8888 --server-type https --config webpack.dev.js", "test": "jest --watch" }, "devDependencies": { @@ -81,6 +81,7 @@ "bootstrap-tagsinput": "^0.7.1", "chosen-js": "^1.8.7", "crypto-js": "^3.1.9-1", + "dompurify": "^3.4.11", "easymde": "^2.18.0", "font-awesome": "^4.7.0", "formik": "^2.2.9", @@ -95,6 +96,7 @@ "moment": "^2.29.4", "moment-timezone": "^0.5.21", "popper.js": "^1.14.3", + "prop-types": "^15.8.1", "pure": "^2.85.0", "pwstrength-bootstrap": "^3.0.10", "react-otp-input": "^3.1.1", diff --git a/resources/js/base_actions.js b/resources/js/base_actions.js index 18cd3b49..236d12aa 100644 --- a/resources/js/base_actions.js +++ b/resources/js/base_actions.js @@ -92,6 +92,40 @@ export const postRawRequest = (endpoint) => (params, headers = {}) => { }) } +// Like postRawRequest, but also surfaces the final URL the browser landed on after the +// XHR transparently followed any 3xx redirects (res.xhr.responseURL) and the HTTP status. +// Used by flows that complete via a server-side redirect (e.g. 2FA verify) so the SPA can +// navigate the top window to the post-login destination. +export const postRawRequestFull = (endpoint) => (params, headers = {}, queryParams = {}) => { + let url = URI(endpoint); + + if (!isObjectEmpty(queryParams)) + url = url.query(queryParams); + + let key = url.toString(); + + cancel(key); + + let req = http.post(url.toString()); + + schedule(key, req); + + return req.set(headers).send(params).timeout({ + response: 60000, + deadline: 60000, + }).then((res) => { + end(key); + return Promise.resolve({ + response: res.body, + status: res.status, + finalUrl: (res.xhr && res.xhr.responseURL) ? res.xhr.responseURL : null, + }); + }).catch((error) => { + end(key); + return Promise.reject(error); + }) +} + export const putRawRequest = (endpoint) => (payload = null, params={}, headers = {}) => { let url = URI(endpoint); diff --git a/resources/js/login/actions.js b/resources/js/login/actions.js index d0d20ad9..aaea626c 100644 --- a/resources/js/login/actions.js +++ b/resources/js/login/actions.js @@ -1,4 +1,4 @@ -import {postRawRequest} from '../base_actions' +import {postRawRequest, postRawRequestFull } from '../base_actions' export const verifyAccount = (email, token) => { @@ -27,3 +27,40 @@ export const resendVerificationEmail = (email, token) => { return postRawRequest(window.RESEND_VERIFICATION_EMAIL_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); } + +// verify / recovery complete login via a server-side redirect, so use the *Full helper to +// recover the final URL for top-window navigation. +export const verify2FA = (otpValue, method, trustDevice, token) => { + const params = { + otp_value: otpValue, + method: method, + trust_device: trustDevice ? 1 : 0 + }; + + return postRawRequestFull(window.VERIFY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const resend2FA = (method, token) => { + const params = { + method: method + }; + + return postRawRequestFull(window.RESEND_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const verifyRecoveryCode = (recoveryCode, token) => { + const params = { + recovery_code: recoveryCode + }; + + return postRawRequestFull(window.RECOVERY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const authenticateWithPassword = (formData, token) => { + const params = Object.fromEntries(formData.entries()); + return postRawRequestFull(window.FORM_ACTION_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const cancelLogin = (token) => { + return postRawRequest(window.CANCEL_LOGIN_ENDPOINT)({}, {'X-CSRF-TOKEN': token}); +} diff --git a/resources/js/login/components/email_error_actions.js b/resources/js/login/components/email_error_actions.js new file mode 100644 index 00000000..4e67ce3b --- /dev/null +++ b/resources/js/login/components/email_error_actions.js @@ -0,0 +1,60 @@ +import React from "react"; +import Grid from "@material-ui/core/Grid"; +import Button from "@material-ui/core/Button"; +import styles from "../login.module.scss"; + +const EmailErrorActions = ({ + emitOtpAction, + createAccountAction, + onValidateEmail, + disableInput, +}) => { + return ( + + + + + + + + + + + + + + ); +}; + +export default EmailErrorActions; diff --git a/resources/js/login/components/email_input_form.js b/resources/js/login/components/email_input_form.js new file mode 100644 index 00000000..ab26ff47 --- /dev/null +++ b/resources/js/login/components/email_input_form.js @@ -0,0 +1,61 @@ +import React from "react"; +import Paper from "@material-ui/core/Paper"; +import TextField from "@material-ui/core/TextField"; +import Button from "@material-ui/core/Button"; +import styles from "../login.module.scss"; +import HTMLRender from "../../shared/HTMLRender"; + +const EmailInputForm = ({ + value, + onValidateEmail, + onHandleUserNameChange, + disableInput, + emailError, +}) => { + return ( + <> + + + {emailError == "" && ( + + )} + + {emailError != "" && ( + + {emailError} + + )} + + ); +}; + +export default EmailInputForm; diff --git a/resources/js/login/components/existing_account_actions.js b/resources/js/login/components/existing_account_actions.js new file mode 100644 index 00000000..649d31fd --- /dev/null +++ b/resources/js/login/components/existing_account_actions.js @@ -0,0 +1,47 @@ +import React from "react"; +import Grid from "@material-ui/core/Grid"; +import Button from "@material-ui/core/Button"; +import Link from "@material-ui/core/Link"; +import styles from "../login.module.scss"; + +const ExistingAccountActions = ({ + emitOtpAction, + forgotPasswordAction, + userName, + disableInput, +}) => { + let forgotPasswordActionHref = forgotPasswordAction; + + if (userName) { + forgotPasswordActionHref = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; + } + + return ( + + + + + + e.preventDefault() : undefined} + href={forgotPasswordActionHref} + target="_self" + variant="body2" + > + Reset your password + + + + ); +}; + +export default ExistingAccountActions; diff --git a/resources/js/login/components/help_links.js b/resources/js/login/components/help_links.js new file mode 100644 index 00000000..c4668e00 --- /dev/null +++ b/resources/js/login/components/help_links.js @@ -0,0 +1,78 @@ +import React, { useMemo } from "react"; +import Link from "@material-ui/core/Link"; +import styles from "../login.module.scss"; + +const HelpLinks = ({ + userName, + showEmitOtpAction, + forgotPasswordAction, + showForgotPasswordAction, + showVerifyEmailAction, + verifyEmailAction, + showHelpAction, + helpAction, + appName, + emitOtpAction, +}) => { + const actions = useMemo(() => { + let forgotPasswordActionHref = forgotPasswordAction; + if (userName) { + const separator = forgotPasswordAction.includes("?") ? "&" : "?"; + forgotPasswordActionHref = `${forgotPasswordAction}${separator}email=${encodeURIComponent(userName)}`; + } + + return [ + { + show: showEmitOtpAction, + href: "#", + onClick: emitOtpAction, + label: "Get A Single-use Code emailed to you", + }, + { + show: showForgotPasswordAction, + href: forgotPasswordActionHref, + label: "Reset your password", + }, + { + show: showVerifyEmailAction, + href: verifyEmailAction, + label: `Verify ${appName}`, + }, + { + show: showHelpAction, + href: helpAction, + label: "Having trouble?", + }, + ].filter((action) => action.show); + }, [ + showEmitOtpAction, + showForgotPasswordAction, + showVerifyEmailAction, + showHelpAction, + userName, + forgotPasswordAction, + verifyEmailAction, + helpAction, + appName, + emitOtpAction, + ]); + + return ( + <> +
+ {actions.map((action, index) => ( + + {action.label} + + ))} + + ); +}; + +export default HelpLinks; diff --git a/resources/js/login/components/otp_help_links.js b/resources/js/login/components/otp_help_links.js new file mode 100644 index 00000000..2e51b26e --- /dev/null +++ b/resources/js/login/components/otp_help_links.js @@ -0,0 +1,20 @@ +import React from "react"; +import Link from "@material-ui/core/Link"; +import styles from "../login.module.scss"; + +const OTPHelpLinks = ({ emitOtpAction }) => { + return ( + <> +
+

Didn't receive it ?

+

+ Check your spam folder or{" "} + + resend email. + +

+ + ); +}; + +export default OTPHelpLinks; diff --git a/resources/js/login/components/otp_input_form.js b/resources/js/login/components/otp_input_form.js new file mode 100644 index 00000000..77bd83d2 --- /dev/null +++ b/resources/js/login/components/otp_input_form.js @@ -0,0 +1,117 @@ +import React, { useMemo } from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; +import Button from "@material-ui/core/Button"; +import Link from "@material-ui/core/Link"; +import OtpInput from "react-otp-input"; +import styles from "../login.module.scss"; +import HTMLRender from "../../shared/HTMLRender"; + +const OTPInputForm = ({ + disableInput, + formAction, + onAuthenticate, + otpCode, + otpError, + otpLength, + onCodeChange, + userNameValue, + csrfToken, + shouldShowCaptcha, + captchaPublicKey, + onChangeCaptchaProvider, + onExpireCaptchaProvider, + onErrorCaptchaProvider, + onReset, + loginAttempts, +}) => { + const showCaptcha = shouldShowCaptcha(); + const handleSubmit = (ev) => { + if (!onAuthenticate(ev.target)) + { + ev.preventDefault(); + } + } + + return ( +
+
+ Enter the single-use code sent to your email: +
+
+ } + shouldAutoFocus={true} + hasErrored={!!otpError} + errorStyle={{ border: "1px solid #e5424d" }} + data-testid="otp_code" + /> +
+ {otpError && ( + + {otpError} + + )} +
+ +
+
+

+ + Sign in using a different e-mail + +

+
+
After you login you will be e-mailed a link to
+
set a password and complete your account.
+
+
+ + + + + + + {showCaptcha && captchaPublicKey && ( + + )} + + ); +}; + +export default OTPInputForm; diff --git a/resources/js/login/components/password_input_form.js b/resources/js/login/components/password_input_form.js new file mode 100644 index 00000000..d68b4258 --- /dev/null +++ b/resources/js/login/components/password_input_form.js @@ -0,0 +1,193 @@ +import React, { useRef } from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; +import TextField from "@material-ui/core/TextField"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Checkbox from "@material-ui/core/Checkbox"; +import Visibility from "@material-ui/icons/Visibility"; +import VisibilityOff from "@material-ui/icons/VisibilityOff"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import IconButton from "@material-ui/core/IconButton"; +import ExistingAccountActions from "./existing_account_actions"; +import styles from "../login.module.scss"; +import HTMLRender from "../../shared/HTMLRender"; + +const PasswordInputForm = ({ + formAction, + onAuthenticate, + disableInput, + showPassword, + passwordValue, + passwordError, + onUserPasswordChange, + handleClickShowPassword, + handleMouseDownPassword, + userNameValue, + csrfToken, + shouldShowCaptcha, + captchaPublicKey, + onChangeCaptchaProvider, + onExpireCaptchaProvider, + onErrorCaptchaProvider, + handleEmitOtpAction, + forgotPasswordAction, + loginAttempts, + maxLoginFailedAttempts, + userIsActive, + helpAction, +}) => { + const formRef = useRef(null); + const handleContinue = () => onAuthenticate(formRef.current); + const onEnterSubmit = (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + handleContinue(); + } + } + + const ErrorMessage = () => { + const attempts = parseInt(loginAttempts, 10); + const maxAttempts = parseInt(maxLoginFailedAttempts, 10); + const attemptsLeft = maxAttempts - attempts; + + if (!passwordError) return null; + + if (attempts > 0 && attempts < maxAttempts && userIsActive) { + return ( +

+ Incorrect password. You have {attemptsLeft} more attempt + {attemptsLeft !== 1 ? "s" : ""} before your account is locked. +

+ ); + } + + if (attempts > 0 && attempts === maxAttempts && userIsActive) { + return ( +

+ Incorrect password. You have reached the maximum ({maxAttempts}) + login attempts. Your account will be locked after another failed + login. +

+ ); + } + + if (attempts > 0 && attempts === maxAttempts && !userIsActive) { + return ( +

+ Your account has been locked due to multiple failed login + attempts. Please contact support to + unlock it. +

+ ); + } + + return ( + + {passwordError} + + ); + }; + + return ( +
ev.preventDefault()} + target="_self" + > + + + {showPassword ? : } + + + ), + }} + /> + + + + + + + + } + label="Remember me" + /> + + + + + + + + {shouldShowCaptcha() && captchaPublicKey && ( + + )} + + + ); +}; + +export default PasswordInputForm; diff --git a/resources/js/login/components/recovery_code_form.js b/resources/js/login/components/recovery_code_form.js new file mode 100644 index 00000000..49ace5f6 --- /dev/null +++ b/resources/js/login/components/recovery_code_form.js @@ -0,0 +1,84 @@ +import React from 'react'; +import TextField from '@material-ui/core/TextField'; +import Button from '@material-ui/core/Button'; +import Link from '@material-ui/core/Link'; +import styles from '../login.module.scss'; +import HTMLRender from '../../shared/HTMLRender'; + +const RecoveryCodeForm = ({ + recoveryCode, + recoveryError, + onRecoveryCodeChange, + onVerify, + onBackToOtp, + onCancel, + disableInput + }) => { + + const handleSubmit = (ev) => { + ev.preventDefault(); + onVerify(); + }; + + const handleBack = (ev) => { + ev.preventDefault(); + onBackToOtp(); + }; + + const handleCancel = (ev) => { + ev.preventDefault(); + onCancel(); + }; + + return ( +
+
Enter a recovery code
+

+ Enter one of the recovery codes you saved when you enabled two-step verification. +

+ + {recoveryError && ( + + {recoveryError} + + )} +
+ +
+
+
+
+ + Back to verification code + + {" · "} + + Cancel + +
+
+ + ); +} + +export default RecoveryCodeForm; diff --git a/resources/js/login/components/third_party_identity_providers.js b/resources/js/login/components/third_party_identity_providers.js new file mode 100644 index 00000000..ee37915c --- /dev/null +++ b/resources/js/login/components/third_party_identity_providers.js @@ -0,0 +1,36 @@ +import React from 'react'; +import DividerWithText from '../../components/divider_with_text'; +import Button from '@material-ui/core/Button'; +import {handleThirdPartyProvidersVerbiage} from '../../utils'; +import styles from '../login.module.scss'; +import '../third_party_identity_providers.scss'; + +const ThirdPartyIdentityProviders = ({ thirdPartyProviders, formAction, disableInput, allowNativeAuth }) => { + return ( + <> + {allowNativeAuth && or} + { + thirdPartyProviders.map((provider) => { + const verbiage = `${handleThirdPartyProvidersVerbiage(provider.name)} with ${provider.label}`; + return ( + + ); + }) + } +

If you have a login, you may still choose to use a social login with the same email address to + access your account.

+ + ); +} + +export default ThirdPartyIdentityProviders; diff --git a/resources/js/login/components/two_factor_form.js b/resources/js/login/components/two_factor_form.js new file mode 100644 index 00000000..fe29f3f6 --- /dev/null +++ b/resources/js/login/components/two_factor_form.js @@ -0,0 +1,149 @@ +import React, {useState, useEffect} from 'react'; +import Button from '@material-ui/core/Button'; +import Link from '@material-ui/core/Link'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; +import OtpInput from 'react-otp-input'; +import {formatTime} from '../../utils'; +import styles from '../login.module.scss'; +import HTMLRender from '../../shared/HTMLRender'; + +// Cooldown applied to the resend action to avoid hammering the resend endpoint +// (the backend also rate-limits server-side). +const RESEND_COOLDOWN_SECONDS = 30; + +const TwoFactorForm = ({ + otpCode, + otpError, + otpLength, + otpLifetime, + codeVersion, + onCodeChange, + onVerify, + trustDevice, + onTrustDeviceChange, + onResend, + onUseRecovery, + onCancel, + disableInput + }) => { + + const [secondsLeft, setSecondsLeft] = useState(otpLifetime || 0); + const [cooldown, setCooldown] = useState(0); + + // Reset the expiry countdown whenever a fresh code is issued (initial render or resend). + useEffect(() => { + setSecondsLeft(otpLifetime || 0); + }, [otpLifetime, codeVersion]); + + // Single 1s ticker drives both the code-expiry countdown and the resend cooldown. + useEffect(() => { + const timer = setInterval(() => { + setSecondsLeft(prev => (prev > 0 ? prev - 1 : 0)); + setCooldown(prev => (prev > 0 ? prev - 1 : 0)); + }, 1000); + return () => clearInterval(timer); + }, []); + + const expired = secondsLeft <= 0; + + const handleSubmit = (ev) => { + ev.preventDefault(); + onVerify(); + }; + + const handleResend = (ev) => { + ev.preventDefault(); + if (cooldown > 0 || disableInput) return; + setCooldown(RESEND_COOLDOWN_SECONDS); + const result = onResend(); + if (result && typeof result.then === 'function') { + result.then(() => setSecondsLeft(otpLifetime || 0)).catch(() => {}); + } + }; + + const handleRecovery = (ev) => { + ev.preventDefault(); + onUseRecovery(); + }; + + const handleCancel = (ev) => { + ev.preventDefault(); + onCancel(); + }; + + return ( +
+
Enter the single-use code sent to your email:
+
+ } + shouldAutoFocus={true} + hasErrored={!!otpError} + errorStyle={{border: '1px solid #e5424d'}} + data-testid="two_factor_code" + /> +
+ {otpError && + + {otpError} + + } +

+ {expired + ? 'Your verification code has expired. Please request a new one.' + : `Code expires in ${formatTime(secondsLeft)}.`} +

+
+ + } + label="Trust this device for 30 days" + /> +
+
+ +
+
+

+ Didn't receive it? Check your spam folder or{" "} + 0 || disableInput) ? styles.disabled_link : ''}> + {cooldown > 0 ? `resend code (${cooldown}s)` : 'resend code'} + . +

+ {/* "Use a different method" is intentionally hidden in Phase I (email_otp only). */} +
+
+ + Cancel + + + Use a recovery code instead + +
+
+
+ ); +} + +export default TwoFactorForm; diff --git a/resources/js/login/constants.js b/resources/js/login/constants.js new file mode 100644 index 00000000..7fd2cdf7 --- /dev/null +++ b/resources/js/login/constants.js @@ -0,0 +1,32 @@ +export const HTTP_CODES = { + OK: 200, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + PRECONDITION_FAILED: 412, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER_ERROR: 500, +}; + +export const MFA_METHODS = { + EMAIL_OTP: "email_otp", + TOTP: "totp", +}; + +export const FLOW = { + PASSWORD: "password", + MFA: "2fa", + RECOVERY: "recovery", + OTP: "otp", +}; + +export const OTP_LENGTH_DEFAULT = 6; +export const OTP_TTL_DEFAULT = 300; +export const MFA_METHOD_DEFAULT = MFA_METHODS.EMAIL_OTP; +export const CAPTCHA_FIELD = 'cf-turnstile-response'; + +export const MFA_ERROR_CODE = { + MFA_SESSION_EXPIRED: "mfa_session_expired", + MFA_CHALLENGE_REQUIRED: "mfa_required", +}; diff --git a/resources/js/login/login.js b/resources/js/login/login.js index ee061b9a..789898a7 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -1,937 +1,1000 @@ -import React from 'react'; +import React from "react"; import { Turnstile } from "@marsidev/react-turnstile"; -import ReactDOM from 'react-dom'; -import Avatar from '@material-ui/core/Avatar'; -import Button from '@material-ui/core/Button'; -import CssBaseline from '@material-ui/core/CssBaseline'; -import TextField from '@material-ui/core/TextField'; -import Link from '@material-ui/core/Link'; -import Typography from '@material-ui/core/Typography'; -import Paper from '@material-ui/core/Paper'; -import Container from '@material-ui/core/Container'; -import Chip from '@material-ui/core/Chip'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import Checkbox from '@material-ui/core/Checkbox'; -import {verifyAccount, emitOTP, resendVerificationEmail} from './actions'; -import {MuiThemeProvider, createTheme} from '@material-ui/core/styles'; -import DividerWithText from '../components/divider_with_text'; -import Visibility from '@material-ui/icons/Visibility'; -import VisibilityOff from '@material-ui/icons/VisibilityOff'; -import InputAdornment from '@material-ui/core/InputAdornment'; -import IconButton from '@material-ui/core/IconButton'; -import { emailValidator } from '../validator'; -import Grid from '@material-ui/core/Grid'; +import ReactDOM from "react-dom"; +import Avatar from "@material-ui/core/Avatar"; +import Button from "@material-ui/core/Button"; +import CssBaseline from "@material-ui/core/CssBaseline"; +import Typography from "@material-ui/core/Typography"; +import Container from "@material-ui/core/Container"; +import Chip from "@material-ui/core/Chip"; +import { MuiThemeProvider, createTheme } from "@material-ui/core/styles"; +import { + verifyAccount, + emitOTP, + resendVerificationEmail, + verify2FA, + resend2FA, + verifyRecoveryCode, + authenticateWithPassword, + cancelLogin, +} from "./actions"; +import { emailValidator } from "../validator"; import CustomSnackbar from "../components/custom_snackbar"; -import Banner from '../components/banner/banner'; -import OtpInput from 'react-otp-input'; -import {handleErrorResponse, handleThirdPartyProvidersVerbiage} from '../utils'; - -import styles from './login.module.scss' +import Banner from "../components/banner/banner"; +import { handleErrorResponse } from "../utils"; + +import EmailInputForm from "./components/email_input_form"; +import PasswordInputForm from "./components/password_input_form"; +import OTPInputForm from "./components/otp_input_form"; +import HelpLinks from "./components/help_links"; +import OTPHelpLinks from "./components/otp_help_links"; +import EmailErrorActions from "./components/email_error_actions"; +import ThirdPartyIdentityProviders from "./components/third_party_identity_providers"; +import TwoFactorForm from "./components/two_factor_form"; +import RecoveryCodeForm from "./components/recovery_code_form"; + +import styles from "./login.module.scss"; import "./third_party_identity_providers.scss"; +import { + FLOW, + HTTP_CODES, + MFA_ERROR_CODE, + OTP_LENGTH_DEFAULT, + OTP_TTL_DEFAULT, + MFA_METHOD_DEFAULT, +} from "./constants"; -const EmailInputForm = ({ value, onValidateEmail, onHandleUserNameChange, disableInput, emailError }) => { - - return ( - <> - - - {emailError == "" && - - } - - { emailError != "" && -

- } - - ); -} - -const PasswordInputForm = ({ - formAction, - onAuthenticate, - disableInput, - showPassword, - passwordValue, - passwordError, - onUserPasswordChange, - handleClickShowPassword, - handleMouseDownPassword, - userNameValue, - csrfToken, - shouldShowCaptcha, - captchaPublicKey, - onChangeCaptchaProvider, - onExpireCaptchaProvider, - onErrorCaptchaProvider, - handleEmitOtpAction, - forgotPasswordAction, - loginAttempts, - maxLoginFailedAttempts, - userIsActive, - helpAction - }) => { - return ( -
- - - {showPassword ? : } - - - ) - }} - /> - {(() => { - const attempts = parseInt(loginAttempts, 10); - const maxAttempts = parseInt(maxLoginFailedAttempts, 10); - const attemptsLeft = maxAttempts - attempts; - - if (!passwordError) return null; - - if (attempts > 0 && attempts < maxAttempts && userIsActive) { - return ( - <> -

- Incorrect password. You have {attemptsLeft} more attempt{attemptsLeft !== 1 ? 's' : ''} before your account is locked. -

- - ); - } - - if (attempts > 0 && attempts === maxAttempts && userIsActive) { - return ( - <> -

- Incorrect password. You have reached the maximum ({maxAttempts}) login attempts. Your account will be locked after another failed login. -

- - ); - } - - if (attempts > 0 && attempts === maxAttempts && !userIsActive) { - return ( - <> -

- Your account has been locked due to multiple failed login attempts. Please contact support to unlock it. -

- - ); - } - - return

; - })()} - - - - - - - } - label="Remember me" - /> - - - - - - - - {shouldShowCaptcha() && captchaPublicKey && - - } - - - ); -} - -const OTPInputForm = ({ - disableInput, - formAction, - onAuthenticate, - otpCode, - otpError, - otpLength, - onCodeChange, - userNameValue, - csrfToken, - shouldShowCaptcha, - captchaPublicKey, - onChangeCaptchaProvider, - onExpireCaptchaProvider, - onErrorCaptchaProvider, - onReset, - loginAttempts - }) => { - return ( - <> -
-
Enter the single-use code sent to your email:
-
- } - shouldAutoFocus={true} - hasErrored={!otpError} - errorStyle={{border: '1px solid #e5424d'}} - data-testid="otp_code" - /> -
- {otpError && -

- } -
- -
-
-

- - Sign in using a different e-mail - -

-
-
After you login you will be e-mailed a link to
-
set a password and complete your account.
-
-
- - - - - - - {shouldShowCaptcha() && captchaPublicKey && - - } - - - ); -} +class LoginPage extends React.Component { + constructor(props) { + super(props); + this.state = { + user_name: props.userName, + user_password: "", + otpCode: "", + user_pic: props.user_pic ?? null, + user_fullname: props.user_fullname ?? null, + user_verified: props.user_verified ?? false, + user_active: props.user_active ?? null, + email_verified: props.email_verified ?? null, + errors: { + email: "", + otp: props.authError ?? "", + password: props.authError ?? "", + twofactor: "", + recovery: "", + }, + notification: { + message: null, + severity: "info", + }, + captcha_value: "", + showPassword: false, + disableInput: false, + authFlow: props.flow, + allowNativeAuth: props.allowNativeAuth, + showInfoBanner: props.showInfoBanner, + infoBannerContent: props.infoBannerContent, + // Two-factor state (populated from the flash redirect when a challenge is required). + otpLength: props.otpLength ?? OTP_LENGTH_DEFAULT, + otpLifetime: props.otpLifetime ?? OTP_TTL_DEFAULT, + mfaMethod: props.mfaMethod ?? MFA_METHOD_DEFAULT, + trustDevice: false, + twoFactorCode: "", + recoveryCode: "", + codeVersion: 0, + }; -const HelpLinks = ({ - userName, - showEmitOtpAction, - forgotPasswordAction, - showForgotPasswordAction, - showVerifyEmailAction, - verifyEmailAction, - showHelpAction, - helpAction, - appName, - emitOtpAction - }) => { - if (userName) { - forgotPasswordAction = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; + if (props.authError != "" && !this.state.user_fullname) { + this.state.user_fullname = props.userName; } - return ( - <> -
- { - showEmitOtpAction && - - Get A Single-use Code emailed to you - - } - { - showForgotPasswordAction && - - Reset your password - - } - { - showVerifyEmailAction && - - Verify {appName} - - } - {showHelpAction && - - Having trouble? - - } - - ); -} - -const OTPHelpLinks = ({ emitOtpAction }) => { - return ( - <> -
-

Didn't receive it ?

-

Check your spam folder or resend email. -

- - ); -} - -const EmailErrorActions = ({ emitOtpAction, createAccountAction, onValidateEmail, disableInput }) => { - return ( - - - - - - - - - - - - - - ); -} - -const ExistingAccountActions = ({emitOtpAction, forgotPasswordAction, userName, disableInput}) => { - if (userName) { - forgotPasswordAction = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; + if ( + this.state.errors.password && + this.state.errors.password.includes("is not yet verified") + ) { + this.state.errors.password = + this.state.errors.password + + `Or have another verification email sent to you.`; } - return ( - - - - - - - Reset your password - - - + this.onHandleUserNameChange = this.onHandleUserNameChange.bind(this); + this.onValidateEmail = this.onValidateEmail.bind(this); + this.handleDelete = this.handleDelete.bind(this); + this.onAuthenticate = this.onAuthenticate.bind(this); + this.onChangeCaptchaProvider = this.onChangeCaptchaProvider.bind(this); + this.onExpireCaptchaProvider = this.onExpireCaptchaProvider.bind(this); + this.onErrorCaptchaProvider = this.onErrorCaptchaProvider.bind(this); + this.onUserPasswordChange = this.onUserPasswordChange.bind(this); + this.onOTPCodeChange = this.onOTPCodeChange.bind(this); + this.shouldShowCaptcha = this.shouldShowCaptcha.bind(this); + this.handleClickShowPassword = this.handleClickShowPassword.bind(this); + this.handleMouseDownPassword = this.handleMouseDownPassword.bind(this); + this.handleEmitOtpAction = this.handleEmitOtpAction.bind(this); + this.resendVerificationEmail = this.resendVerificationEmail.bind(this); + this.handleSnackbarClose = this.handleSnackbarClose.bind(this); + this.showAlert = this.showAlert.bind(this); + this.onTwoFactorCodeChange = this.onTwoFactorCodeChange.bind(this); + this.onRecoveryCodeChange = this.onRecoveryCodeChange.bind(this); + this.onTrustDeviceChange = this.onTrustDeviceChange.bind(this); + this.onVerify2FA = this.onVerify2FA.bind(this); + this.onResend2FA = this.onResend2FA.bind(this); + this.onVerifyRecovery = this.onVerifyRecovery.bind(this); + this.onUseRecovery = this.onUseRecovery.bind(this); + this.onBackToOtp = this.onBackToOtp.bind(this); + this.resetToPasswordFlow = this.resetToPasswordFlow.bind(this); + } + + showAlert(message, severity) { + this.setState({ + ...this.state, + notification: { + message: message, + severity: severity, + }, + }); + } + + emitOtpAction() { + let user_fullname = this.state.user_fullname + ? this.state.user_fullname + : this.state.user_name; + + emitOTP(this.state.user_name, this.props.token).then( + (payload) => { + let { response } = payload; + this.setState({ + ...this.state, + authFlow: FLOW.OTP, + errors: { + email: "", + otp: "", + password: "", + }, + user_verified: true, + user_fullname: user_fullname, + }); + }, + (error) => { + let { response, status, message } = error; + if (status == 412) { + const { message, errors } = response.body; + this.showAlert(errors[0], "error"); + return; + } + this.showAlert("Oops... Something went wrong!", "error"); + }, ); -} + return false; + } -const ThirdPartyIdentityProviders = ({ thirdPartyProviders, formAction, disableInput, allowNativeAuth }) => { + handleEmitOtpAction(ev) { + ev.preventDefault(); + return this.emitOtpAction(); + } + + shouldShowCaptcha() { return ( - <> - {allowNativeAuth && or} - { - thirdPartyProviders.map((provider) => { - const verbiage = `${handleThirdPartyProvidersVerbiage(provider.name)} with ${provider.label}`; - return ( - - ); - }) - } -

If you have a login, you may still choose to use a social login with the same email address to - access your account.

- + typeof this.props.maxLoginAttempts2ShowCaptcha !== "undefined" && + typeof this.props.loginAttempts !== "undefined" && + this.props.loginAttempts >= this.props.maxLoginAttempts2ShowCaptcha ); -} + } -const otp_flow = 'otp'; -const password_flow = 'password'; - -class LoginPage extends React.Component { + handleAuthenticateValidation() { - constructor(props) { - super(props); - this.state = { - user_name: props.userName, - user_password: '', - otpCode: '', - user_pic: props.hasOwnProperty('user_pic') ? props.user_pic : null, - user_fullname: props.hasOwnProperty('user_fullname') ? props.user_fullname : null, - 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, - errors: { - email: '', - otp: props.authError != '' ? props.authError : '', - password: props.authError != '' ? props.authError : '', - }, - notification: { - message: null, - severity: 'info' - }, - captcha_value: '', - showPassword: false, + switch (this.state.authFlow) { + case FLOW.OTP: + if (this.state.otpCode == "") { + this.setState({ + ...this.state, disableInput: false, - authFlow: props.flow, - allowNativeAuth: props.allowNativeAuth, - showInfoBanner: props.showInfoBanner, - infoBannerContent: props.infoBannerContent, - } - - if (props.authError != '' && !this.state.user_fullname) { - this.state.user_fullname = props.userName; + errors: { ...this.state.errors, otp: "Single-use code is empty" }, + }); + return false; } - - if (this.state.errors.password && this.state.errors.password.includes("is not yet verified")) { - this.state.errors.password = this.state.errors.password + `Or have another verification email sent to you.`; + break; + default: + if (this.state.user_password == "") { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, password: "Password is empty" }, + }); + return false; } - this.onHandleUserNameChange = this.onHandleUserNameChange.bind(this); - this.onValidateEmail = this.onValidateEmail.bind(this); - this.handleDelete = this.handleDelete.bind(this); - this.onAuthenticate = this.onAuthenticate.bind(this); - this.onChangeCaptchaProvider = this.onChangeCaptchaProvider.bind(this); - this.onExpireCaptchaProvider = this.onExpireCaptchaProvider.bind(this); - this.onErrorCaptchaProvider = this.onErrorCaptchaProvider.bind(this); - this.onUserPasswordChange = this.onUserPasswordChange.bind(this); - this.onOTPCodeChange = this.onOTPCodeChange.bind(this); - this.shouldShowCaptcha = this.shouldShowCaptcha.bind(this); - this.handleClickShowPassword = this.handleClickShowPassword.bind(this); - this.handleMouseDownPassword = this.handleMouseDownPassword.bind(this); - this.handleEmitOtpAction = this.handleEmitOtpAction.bind(this); - this.resendVerificationEmail = this.resendVerificationEmail.bind(this); - this.handleSnackbarClose = this.handleSnackbarClose.bind(this); - this.showAlert = this.showAlert.bind(this); - } - - showAlert(message, severity) { - this.setState({ + if (this.state.captcha_value == "" && this.shouldShowCaptcha()) { + this.setState({ ...this.state, - notification: { - message: message, - severity: severity - } - }); + disableInput: false, + errors: { ...this.state.errors, password: "you must check CAPTCHA" }, + }); + return false; + } } - emitOtpAction() { - let user_fullname = this.state.user_fullname ? this.state.user_fullname : this.state.user_name; - - emitOTP(this.state.user_name, this.props.token).then((payload) => { - let {response} = payload; - this.setState({ - ...this.state, - authFlow: otp_flow, - errors: { - email: '', - otp: '', - password: '' - }, - user_verified: true, - user_fullname: user_fullname, - }); - }, (error) => { - let {response, status, message} = error; - if(status == 412){ - const {message, errors} = response.body; - this.showAlert(errors[0], 'error'); - return; - } - this.showAlert('Oops... Something went wrong!', 'error'); - }); - return false; + return true; + } + + handleAuthenticatePasswordOk(payload) { + const { response, status, finalUrl } = payload || {}; + const { error_code, otp_length, otp_lifetime } = response || {}; + + switch (error_code) { + case MFA_ERROR_CODE.MFA_CHALLENGE_REQUIRED: + this.setState((prevState) => ({ + ...prevState, + authFlow: FLOW.MFA, + disableInput: false, + otpLength: otp_length ?? prevState.otpLength, + otpLifetime: otp_lifetime ?? prevState.otpLifetime, + })); + break; + default: + { + const redirect = finalUrl && status === HTTP_CODES.OK; + if (redirect) { + window.location.href = finalUrl; + } else { + this.showAlert("Oops... Something went wrong!", "error"); + this.setState((prevState) => ({ + ...prevState, + disableInput: false, + })); + } + } } - - handleEmitOtpAction(ev) { - ev.preventDefault(); - return this.emitOtpAction(); + } + + handleAuthenticatePasswordError(error) { + let { response, status, message } = error; + this.setState((prevState) => ({ ...prevState, disableInput: false })); + if (status === HTTP_CODES.UNAUTHORIZED) { + this.showAlert("Invalid username or password.", "error"); + } else { + this.showAlert("Oops... Something went wrong!", "error"); } + } - shouldShowCaptcha() { - return ( - this.props.hasOwnProperty('maxLoginAttempts2ShowCaptcha') && - this.props.hasOwnProperty('loginAttempts') && - this.props.loginAttempts >= this.props.maxLoginAttempts2ShowCaptcha - ) - } + handleAuthenticatePasswordFlow(form) { + const formData = new FormData(form); - onAuthenticate(ev) { - if (this.state.authFlow === otp_flow) { - if (this.state.otpCode == '') { - this.setState({...this.state, disableInput: false, errors: {...this.state.errors, otp: 'Single-use code is empty'}}); - ev.preventDefault(); - return false; - } - } else if (this.state.user_password == '') { - this.setState({...this.state, disableInput: false, errors: {...this.state.errors, password: 'Password is empty'}}); - ev.preventDefault(); - return false; - } + authenticateWithPassword(formData, this.props.token) + .then( + (payload) => { + this.handleAuthenticatePasswordOk(payload); + }, + (error) => this.handleAuthenticatePasswordError(error) + ); + } - if (this.state.captcha_value == '' && this.shouldShowCaptcha()) { - this.setState({...this.state, disableInput: false, errors: {...this.state.errors, password: 'you must check CAPTCHA'}}); - ev.preventDefault(); - return false; - } - this.setState({ ...this.state, disableInput: true }); - return true; + onAuthenticate(form) { + + if (!this.handleAuthenticateValidation()) { + return false; } - onChangeCaptchaProvider(value) { - this.setState({ ...this.state, captcha_value: value }); + this.setState({ ...this.state, disableInput: true }); + + + if (this.state.authFlow === FLOW.PASSWORD) { + this.handleAuthenticatePasswordFlow(form); + return false; } - onExpireCaptchaProvider() { - this.setState({ ...this.state, captcha_value: '' }); + return true; + } + + onChangeCaptchaProvider(value) { + this.setState({ ...this.state, captcha_value: value }); + } + + onExpireCaptchaProvider() { + this.setState({ ...this.state, captcha_value: "" }); + } + + onErrorCaptchaProvider() { + this.setState({ ...this.state, captcha_value: "" }); + } + + onHandleUserNameChange(ev) { + let { value, id } = ev.target; + this.setState({ ...this.state, user_name: value }); + } + + onUserPasswordChange(ev) { + let { errors } = this.state; + let { value, id } = ev.target; + if (value == "") + // clean error + errors[id] = ""; + this.setState({ + ...this.state, + user_password: value, + errors: { ...errors }, + }); + } + + onOTPCodeChange(value) { + this.setState({ ...this.state, otpCode: value }); + } + + onTwoFactorCodeChange(value) { + this.setState({ + ...this.state, + twoFactorCode: value, + errors: { ...this.state.errors, twofactor: "" }, + }); + } + + onRecoveryCodeChange(ev) { + let { value } = ev.target; + this.setState({ + ...this.state, + recoveryCode: value, + errors: { ...this.state.errors, recovery: "" }, + }); + } + + onTrustDeviceChange(ev) { + this.setState({ ...this.state, trustDevice: ev.target.checked }); + } + + /** + * Resets client-side MFA state and returns the user to the password screen. + */ + resetToPasswordFlow() { + this.setState({ + ...this.state, + authFlow: FLOW.PASSWORD, + disableInput: false, + twoFactorCode: "", + user_name: "", + user_password: "", + user_pic: "", + user_fullname: "", + user_verified: false, + recoveryCode: "", + trustDevice: false, + errors: { + ...this.state.errors, + twofactor: "", + recovery: "", + email: "", + otp: "", + password: "", + }, + }); + cancelLogin(this.props.token); + } + + /** + * Shared error handling for the 2FA verify / recovery AJAX calls. + * @param {*} error superagent error + * @param {string} field 'twofactor' | 'recovery' + */ + handleMfaError(error, field) { + const status = error ? error.status : undefined; + const body = error && error.response ? error.response.body : null; + const code = body ? body.error_code : null; + + if ( + status === HTTP_CODES.UNAUTHORIZED && + code === MFA_ERROR_CODE.MFA_SESSION_EXPIRED + ) { + this.resetToPasswordFlow(); + this.showAlert( + "Your verification session has expired. Please sign in again.", + "warning", + ); + return; } - onErrorCaptchaProvider() { - this.setState({ ...this.state, captcha_value: '' }); + if (status === HTTP_CODES.TOO_MANY_REQUESTS) { + const msg = + body && body.error_message + ? body.error_message + : "Too many attempts. Please try again later."; + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: msg }, + }); + return; } - onHandleUserNameChange(ev) { - let { value, id } = ev.target; - this.setState({ ...this.state, user_name: value }); + if (status === HTTP_CODES.UNAUTHORIZED) { + const msg = + field === "recovery" + ? "Invalid recovery code. Please try again." + : "Invalid or expired verification code. Please try again."; + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: msg }, + }); + return; } - onUserPasswordChange(ev) { - let {errors} = this.state; - let {value, id} = ev.target; - if (value == "") // clean error - errors[id] = ''; - this.setState({...this.state, user_password: value, errors: {...errors}}); + if (status === HTTP_CODES.PRECONDITION_FAILED) { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: "Please enter a valid code." }, + }); + return; } - onOTPCodeChange(value) { - this.setState({...this.state, otpCode: value}); + /** + * No HTTP status: the XHR likely followed a (possibly cross-origin) success redirect + * it could not read. The IDP session may already be established, so reload and let + * the server route us to the right place; a genuine network error just re-shows login. + */ + if (typeof status === "undefined" || status === 0) { + window.location.reload(); + return; } - onValidateEmail(ev) { + this.setState({ ...this.state, disableInput: false }); + this.showAlert("Oops... Something went wrong!", "error"); + } + + onVerify2FA() { + if (this.state.disableInput) return; + const { twoFactorCode, trustDevice, mfaMethod } = this.state; + if (twoFactorCode === "") { + this.setState({ + ...this.state, + errors: { + ...this.state.errors, + twofactor: "Verification code is empty", + }, + }); + return; + } + this.setState({ + ...this.state, + disableInput: true, + errors: { ...this.state.errors, twofactor: "" }, + }); + + verify2FA(twoFactorCode, mfaMethod, trustDevice, this.props.token).then( + (payload) => { + // Success: the backend redirected (302) and the XHR followed it; navigate the top + // window to the final destination to resume the normal redirect / OIDC flow. + window.location.href = payload.finalUrl || window.location.href; + }, + (error) => { + this.handleMfaError(error, "twofactor"); + }, + ); + } - ev.preventDefault(); - let {user_name} = this.state; - user_name = user_name?.trim(); + onResend2FA() { + const promise = resend2FA(this.state.mfaMethod, this.props.token); - if (user_name == '') { - return false; + promise.then( + (payload) => { + const { response } = payload; + this.setState({ + ...this.state, + otpLength: + response && response.otp_length + ? response.otp_length + : this.state.otpLength, + otpLifetime: + response && response.otp_lifetime + ? response.otp_lifetime + : this.state.otpLifetime, + codeVersion: this.state.codeVersion + 1, + errors: { ...this.state.errors, twofactor: "" }, + }); + this.showAlert( + "A new verification code has been sent to your email.", + "success", + ); + }, + (error) => { + const status = error ? error.status : undefined; + const body = error && error.response ? error.response.body : null; + const code = body ? body.error_code : null; + + if ( + status === HTTP_CODES.UNAUTHORIZED && + code === MFA_ERROR_CODE.MFA_SESSION_EXPIRED + ) { + this.resetToPasswordFlow(); + this.showAlert( + "Your verification session has expired. Please sign in again.", + "warning", + ); + return; } - if (!emailValidator(user_name)) { - return false; + if (status === HTTP_CODES.TOO_MANY_REQUESTS) { + const msg = + body && body.error_message + ? body.error_message + : "Too many attempts. Please try again later."; + this.showAlert(msg, "warning"); + return; } - this.setState({ ...this.state, disableInput: true }); - - verifyAccount(user_name, this.props.token).then((payload) => { - let { response } = payload; - - let error = ''; - if (response.is_active === false) { - error = `Your user account is currently locked. Please contact support for further assistance.`; - } else if (response.is_active === true && response.is_verified === false) { - error = 'Your email has not been verified. Please check your inbox or resend the verification email.'; - } - - this.setState({ - ...this.state, - user_pic: response.pic, - user_fullname: response.full_name, - user_verified: true, - user_active: response.is_active, - email_verified: response.is_verified, - authFlow: response.has_password_set ? password_flow : otp_flow, - errors: { - email: error, - otp: '', - password: '' - }, - disableInput: false - }, function () { - //Once the state is updated, it's now possible to trigger emitOtpAction. - //No need to wait for the component to update. - if (!response.has_password_set && response.is_verified !== false) { - this.emitOtpAction(); - } - }); - }, (error) => { - - let { response, status, message } = error; - - let newErrors = {}; - - newErrors['password'] = ''; - newErrors['email'] = " "; - - if (status == 429) { - newErrors['email'] = "Too many requests. Try it later."; - } + this.showAlert( + "Oops... Something went wrong while resending the code.", + "error", + ); + }, + ); - this.setState({ - ...this.state, - user_pic: null, - user_fullname: null, - user_verified: false, - errors: newErrors, - disableInput: false - }); - }); - return true; + // Returned so the form can reset its expiry countdown once the resend resolves. + return promise; + } + + onVerifyRecovery() { + if (this.state.disableInput) return; + const { recoveryCode } = this.state; + if (recoveryCode === "") { + this.setState({ + ...this.state, + errors: { ...this.state.errors, recovery: "Recovery code is empty" }, + }); + return; } - - resendVerificationEmail(ev) { - ev.preventDefault(); - let {user_name} = this.state; - user_name = user_name?.trim(); - - if (!user_name) { - this.showAlert( - 'Something went wrong while trying to resend the verification email. Please try again later.', - 'error'); - return; - } - - resendVerificationEmail(user_name, this.props.token).then((payload) => { - this.showAlert( - 'We\'ve sent you a verification email. Please check your inbox and click the link to verify your account.', - 'success'); - }, (error) => { - handleErrorResponse(error, (title, messageLines, type) => { - const message = (messageLines ?? []).join(', ') - this.showAlert(`${title}: ${message}`, type); - }); - }); + this.setState({ + ...this.state, + disableInput: true, + errors: { ...this.state.errors, recovery: "" }, + }); + + verifyRecoveryCode(recoveryCode, this.props.token).then( + (payload) => { + window.location.href = payload.finalUrl || window.location.href; + }, + (error) => { + this.handleMfaError(error, "recovery"); + }, + ); + } + + onUseRecovery() { + this.setState({ + ...this.state, + authFlow: FLOW.RECOVERY, + errors: { ...this.state.errors, recovery: "" }, + }); + } + + onBackToOtp() { + this.setState({ + ...this.state, + authFlow: FLOW.MFA, + errors: { ...this.state.errors, twofactor: "" }, + }); + } + + onValidateEmail(ev) { + ev.preventDefault(); + let { user_name } = this.state; + user_name = user_name?.trim(); + + if (user_name == "") { + return false; + } + if (!emailValidator(user_name)) { + return false; } + this.setState({ ...this.state, disableInput: true }); + + verifyAccount(user_name, this.props.token).then( + (payload) => { + let { response } = payload; + + let error = ""; + if (response.is_active === false) { + error = `Your user account is currently locked. Please contact support for further assistance.`; + } else if ( + response.is_active === true && + response.is_verified === false + ) { + error = + "Your email has not been verified. Please check your inbox or resend the verification email."; + } - handleDelete(ev) { - ev.preventDefault(); - this.setState({ + this.setState( + { ...this.state, - user_name: null, - user_pic: null, - user_fullname: null, - user_verified: false, - user_active: null, - email_verified: null, - authFlow: "password", + user_pic: response.pic, + user_fullname: response.full_name, + user_verified: true, + user_active: response.is_active, + email_verified: response.is_verified, + authFlow: response.has_password_set ? FLOW.PASSWORD : FLOW.OTP, errors: { - email: '', - otp: '', - password: '' + email: error, + otp: "", + password: "", + }, + disableInput: false, + }, + function () { + //Once the state is updated, it's now possible to trigger emitOtpAction. + //No need to wait for the component to update. + if (!response.has_password_set && response.is_verified !== false) { + this.emitOtpAction(); } - }); - return false; - } - - handleClickShowPassword(ev) { - ev.preventDefault(); - this.setState({ ...this.state, showPassword: !this.state.showPassword }) - } + }, + ); + }, + (error) => { + let { response, status, message } = error; - handleMouseDownPassword(ev) { - ev.preventDefault(); - } + let newErrors = {}; - existingUserCanContinue() { - const { user_active, email_verified } = this.state; - return user_active !== false && email_verified !== false; - } + newErrors["password"] = ""; + newErrors["email"] = " "; - getSignUpSignInTitle() { - const { errors, user_active } = this.state; + if (status == HTTP_CODES.TOO_MANY_REQUESTS) { + newErrors["email"] = "Too many requests. Try it later."; + } - if (errors.email && this.existingUserCanContinue()) { - return 'Create an account for:'; - } - return 'Sign in'; + this.setState({ + ...this.state, + user_pic: null, + user_fullname: null, + user_verified: false, + errors: newErrors, + disableInput: false, + }); + }, + ); + return true; + } + + resendVerificationEmail(ev) { + ev.preventDefault(); + let { user_name } = this.state; + user_name = user_name?.trim(); + + if (!user_name) { + this.showAlert( + "Something went wrong while trying to resend the verification email. Please try again later.", + "error", + ); + return; } - handleSnackbarClose() { - this.setState({ - ...this.state, - notification: { - message: null, - severity: 'info' - } + resendVerificationEmail(user_name, this.props.token).then( + (payload) => { + this.showAlert( + "We've sent you a verification email. Please check your inbox and click the link to verify your account.", + "success", + ); + }, + (error) => { + handleErrorResponse(error, (title, messageLines, type) => { + const message = (messageLines ?? []).join(", "); + this.showAlert(`${title}: ${message}`, type); }); - }; + }, + ); + } + + handleDelete(ev) { + ev.preventDefault(); + this.setState({ + ...this.state, + user_name: null, + user_pic: null, + user_fullname: null, + user_verified: false, + user_active: null, + email_verified: null, + authFlow: "password", + errors: { + email: "", + otp: "", + password: "", + }, + }); + return false; + } + + handleClickShowPassword(ev) { + ev.preventDefault(); + this.setState({ ...this.state, showPassword: !this.state.showPassword }); + } + + handleMouseDownPassword(ev) { + ev.preventDefault(); + } + + existingUserCanContinue() { + const { user_active, email_verified } = this.state; + return user_active !== false && email_verified !== false; + } + + isMfaFlow() { + return ( + this.state.authFlow === FLOW.MFA || this.state.authFlow === FLOW.RECOVERY + ); + } - componentDidUpdate(prevProps, prevState) { - if (this.state.user_verified && this.existingUserCanContinue() && prevState.authFlow !== this.state.authFlow) { - this.setState({ - ...this.state, - captcha_value: '', - }); - } + getSignUpSignInTitle() { + const { errors, user_active } = this.state; + + if (errors.email && this.existingUserCanContinue()) { + return "Create an account for:"; + } + return "Sign in"; + } + + handleSnackbarClose() { + this.setState({ + ...this.state, + notification: { + message: null, + severity: "info", + }, + }); + } + + componentDidUpdate(prevProps, prevState) { + if ( + this.state.user_verified && + this.existingUserCanContinue() && + prevState.authFlow !== this.state.authFlow + ) { + this.setState({ + ...this.state, + captcha_value: "", + }); } + } + + render() { + const showTwoFactorForm = this.state.authFlow === FLOW.MFA; + const showRecoveryForm = this.state.authFlow === FLOW.RECOVERY; + const isPasswordFlow = + !showTwoFactorForm && + !showRecoveryForm && + !this.isMfaFlow() && + this.state.user_verified && + this.existingUserCanContinue() && + this.state.authFlow === FLOW.PASSWORD; + const isOtpFlow = + !showTwoFactorForm && + !showRecoveryForm && + !this.isMfaFlow() && + this.state.user_verified && + this.existingUserCanContinue() && + this.state.authFlow === FLOW.OTP; + const showDefaultFlow = !showTwoFactorForm && !showRecoveryForm && !isPasswordFlow && !isOtpFlow; + const createAccountAction = this.props.createAccountAction + + (this.state.user_name ? `?email=${encodeURIComponent(this.state.user_name)}` : ""); - render() { - return ( - - - {this.state.showInfoBanner && } - -
- - {this.props.appName} - - - {this.getSignUpSignInTitle()} - {this.state.user_fullname && - } - variant="outlined" - className={styles.valid_user_name_chip} - label={this.state.user_name} - onDelete={this.handleDelete}/> - } - - {(!this.state.user_verified || !this.existingUserCanContinue()) && - <> - {this.state.allowNativeAuth && - - } - {this.state.errors.email === '' && - this.props.thirdPartyProviders.length > 0 && - - } - { - // we already had an interaction and got an user error... - this.state.errors.email !== '' && - <> - {this.existingUserCanContinue() && - - } - { - this.state.user_active === true && this.state.email_verified === false && - - } - - - } - - } - {this.state.user_verified && this.existingUserCanContinue() && this.state.authFlow === password_flow && - // proceed to ask for password ( 2nd step ) - <> - - - - } - {this.state.user_verified && this.existingUserCanContinue() && this.state.authFlow === otp_flow && - // proceed to ask for password ( 2nd step ) - <> - - - - } - + + {this.state.showInfoBanner && ( + + )} + +
+ + + {this.props.appName} + + + + {this.getSignUpSignInTitle()} + {this.state.user_fullname && ( + + } + variant="outlined" + className={styles.valid_user_name_chip} + label={this.state.user_name} + onDelete={this.handleDelete} + /> + )} + + {showTwoFactorForm && ( + + )} + {showRecoveryForm && ( + + )} + {isPasswordFlow && ( + // proceed to ask for password ( 2nd step ) + <> + + + + )} + {isOtpFlow && ( + // proceed to ask for password ( 2nd step ) + <> + + + + )} + {showDefaultFlow && ( + <> + {this.state.allowNativeAuth && ( + + )} + {this.state.errors.email === "" && + this.props.thirdPartyProviders.length > 0 && ( + + )} + { + // we already had an interaction and got an user error... + this.state.errors.email !== "" && ( + <> + {this.existingUserCanContinue() && ( + -
-
- - ); - } + )} + {this.state.user_active === true && + this.state.email_verified === false && ( + + )} + + + ) + } + + )} + +
+
+
+ ); + } } // Or Create your Own theme: const theme = createTheme({ - palette: { - primary: { - main: '#3fa2f7' - }, + palette: { + primary: { + main: "#3fa2f7", }, - overrides: { - MuiButton: { - containedPrimary: { - color: 'white', - textTransform: 'none' - } - } - } + }, + overrides: { + MuiButton: { + containedPrimary: { + color: "white", + textTransform: "none", + }, + }, + }, }); ReactDOM.render( - - - , - document.querySelector('#root') + + + , + document.querySelector("#root"), ); diff --git a/resources/js/login/login.module.scss b/resources/js/login/login.module.scss index fb0257d1..cbd0b254 100644 --- a/resources/js/login/login.module.scss +++ b/resources/js/login/login.module.scss @@ -88,6 +88,28 @@ p > a { margin-top: 20px; } } + + .info_message { + margin-top: 8px; + color: $text-color-dark; + } + + .countdown { + margin-top: 10px; + font-size: 0.85rem; + color: $hint-text-color; + } + + .trust_device_row { + margin-top: 10px; + margin-bottom: 10px; + text-align: left; + } + + .disabled_link { + pointer-events: none; + opacity: 0.5; + } } } @@ -133,4 +155,11 @@ p > a { .otp_p { margin: 0; padding: 0; +} + +.box { + display: flex; + justify-content: space-between; + margin-bottom: 10px; + flex-direction: row; } \ No newline at end of file diff --git a/resources/js/shared/HTMLRender.jsx b/resources/js/shared/HTMLRender.jsx new file mode 100644 index 00000000..98062c02 --- /dev/null +++ b/resources/js/shared/HTMLRender.jsx @@ -0,0 +1,27 @@ +/* eslint-disable react/no-danger */ +import PropTypes from "prop-types"; +import DOMPurify from "dompurify"; + +const HTMLRender = ({ children, className, style, component = "div" }) => { + const html = DOMPurify.sanitize(children || ""); + const Component = component; + + return ( + + ); +}; + +HTMLRender.propTypes = { + children: PropTypes.string, + className: PropTypes.string, + style: PropTypes.shape({ + [PropTypes.string]: PropTypes.string + }), + component: PropTypes.elementType +}; + +export default HTMLRender; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index d2ca52ee..4c8e617d 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -34,6 +34,11 @@ accountVerifyAction : '{{URL::action("UserController@getAccount")}}', emitOtpAction : '{{URL::action("UserController@emitOTP")}}', resendVerificationEmailAction: '{{ URL::action("UserController@resendVerificationEmail") }}', + verify2faAction: '{{ URL::action("UserController@verify2FA") }}', + resend2faAction: '{{ URL::action("UserController@resend2FA") }}', + cancelLogin: '{{ URL::action("UserController@cancelLogin") }}', + recovery2faAction: '{{ URL::action("UserController@verify2FARecovery") }}', + mfaMethod: '{{ Session::has("mfa_method") ? Session::get("mfa_method") : "email_otp" }}', authError: authError, captchaPublicKey: '{{ Config::get("services.turnstile.key") }}', flow: 'password', @@ -84,9 +89,21 @@ config.flow = '{{Session::get('flow')}}'; @endif + @if(Session::has('otp_length')) + config.otpLength = {{Session::get("otp_length")}}; + @endif + @if(Session::has('otp_lifetime')) + config.otpLifetime = {{Session::get("otp_lifetime")}}; + @endif + window.VERIFY_ACCOUNT_ENDPOINT = config.accountVerifyAction; window.EMIT_OTP_ENDPOINT = config.emitOtpAction; window.RESEND_VERIFICATION_EMAIL_ENDPOINT = config.resendVerificationEmailAction; + window.VERIFY_2FA_ENDPOINT = config.verify2faAction; + window.RESEND_2FA_ENDPOINT = config.resend2faAction; + window.CANCEL_LOGIN_ENDPOINT = config.cancelLogin; + window.RECOVERY_2FA_ENDPOINT = config.recovery2faAction; + window.FORM_ACTION_ENDPOINT = config.formAction; {!! script_to('assets/login.js') !!} @append \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 542300dc..e3e0ae0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2047,6 +2047,11 @@ dependencies: "@types/estree" "*" +"@types/trusted-types@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/ws@^8.5.5": version "8.18.1" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" @@ -3645,6 +3650,13 @@ domexception@^2.0.1: dependencies: webidl-conversions "^5.0.0" +dompurify@^3.4.11: + version "3.4.11" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.11.tgz#29c8ba496475f279ef4015784068452fb14a0680" + integrity sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw== + optionalDependencies: + "@types/trusted-types" "^2.0.7" + dotenv-defaults@^1.0.2: version "1.1.1" resolved "https://registry.yarnpkg.com/dotenv-defaults/-/dotenv-defaults-1.1.1.tgz#032c024f4b5906d9990eb06d722dc74cc60ec1bd" @@ -6558,7 +6570,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== From cc97f0f1648854f44397ae08d72802226dfe527a Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 23 Jul 2026 14:35:55 -0300 Subject: [PATCH 02/21] fix: rename HTMLRender.jsx to .js so webpack can resolve it webpack.common.js has no .jsx resolve extension configured, so the bare '../../shared/HTMLRender' import used by every login form component failed to resolve, breaking the build for this whole tree. --- resources/js/shared/{HTMLRender.jsx => HTMLRender.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename resources/js/shared/{HTMLRender.jsx => HTMLRender.js} (100%) diff --git a/resources/js/shared/HTMLRender.jsx b/resources/js/shared/HTMLRender.js similarity index 100% rename from resources/js/shared/HTMLRender.jsx rename to resources/js/shared/HTMLRender.js From 0eca371cb867fda6acf21b6c9af35ff514580de4 Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 23 Jul 2026 14:36:11 -0300 Subject: [PATCH 03/21] fix: revert password submit to native form POST The backend login strategies (DefaultLoginStrategy, DisplayResponseUserAgentStrategy) answer wrong-password and mfa_required with a 302 redirect plus flashed/persisted session state, meant to be consumed by a native top-level form submit - the same mechanism already used by the OTP and MFA screens. Converting the password step to AJAX (postRawRequestFull) broke that contract: the hidden XHR redirect-follow GET consumed the one-shot flash before the SPA could show it, silently dropping the wrong-password message, resetting login_attempts (disabling the server-side captcha escalation), and losing native password-manager save/update prompts. Reverts PasswordInputForm to the same native-submit adapter OTPInputForm already uses, and removes the now-dead AJAX path: handleAuthenticatePasswordFlow/Ok/Error, authenticateWithPassword, window.FORM_ACTION_ENDPOINT, and the MFA_CHALLENGE_REQUIRED constant (confirmed unused end-to-end - the server never emits mfa_required as JSON to browser clients either, only via session state under the 'flow' key). Also removes disabled={disableInput} from the password TextField and the 'remember' FormControlLabel. Under native submission, React's synchronous setState(disableInput: true) inside the same onSubmit handler commits the disabled attribute to the DOM before the browser constructs the form's data set - and the HTML spec excludes disabled controls from that set. The result was a silently dropped password field ('The password field is required.', confirmed live against the backend). OTPInputForm was never affected because it only disables its submit Button, never the field carrying the actual submitted value - the fix here matches that pattern. --- resources/js/login/actions.js | 5 -- .../login/components/password_input_form.js | 24 +++---- resources/js/login/constants.js | 1 - resources/js/login/login.js | 66 ++----------------- resources/views/auth/login.blade.php | 1 - 5 files changed, 16 insertions(+), 81 deletions(-) diff --git a/resources/js/login/actions.js b/resources/js/login/actions.js index aaea626c..76c6d2bc 100644 --- a/resources/js/login/actions.js +++ b/resources/js/login/actions.js @@ -56,11 +56,6 @@ export const verifyRecoveryCode = (recoveryCode, token) => { return postRawRequestFull(window.RECOVERY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); } -export const authenticateWithPassword = (formData, token) => { - const params = Object.fromEntries(formData.entries()); - return postRawRequestFull(window.FORM_ACTION_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); -} - export const cancelLogin = (token) => { return postRawRequest(window.CANCEL_LOGIN_ENDPOINT)({}, {'X-CSRF-TOKEN': token}); } diff --git a/resources/js/login/components/password_input_form.js b/resources/js/login/components/password_input_form.js index d68b4258..f89786b9 100644 --- a/resources/js/login/components/password_input_form.js +++ b/resources/js/login/components/password_input_form.js @@ -1,4 +1,4 @@ -import React, { useRef } from "react"; +import React from "react"; import { Turnstile } from "@marsidev/react-turnstile"; import TextField from "@material-ui/core/TextField"; import Button from "@material-ui/core/Button"; @@ -37,14 +37,15 @@ const PasswordInputForm = ({ userIsActive, helpAction, }) => { - const formRef = useRef(null); - const handleContinue = () => onAuthenticate(formRef.current); - const onEnterSubmit = (ev) => { - if (ev.key === "Enter") { + // Native form submission (same adapter as OTPInputForm): password managers key + // their save/update prompt off the browser's real submit event, and the backend + // login strategies respond with a redirect + flashed session state that only a + // top-level navigation consumes correctly. + const handleSubmit = (ev) => { + if (!onAuthenticate(ev.target)) { ev.preventDefault(); - handleContinue(); } - } + }; const ErrorMessage = () => { const attempts = parseInt(loginAttempts, 10); @@ -93,8 +94,7 @@ const PasswordInputForm = ({
ev.preventDefault()} + onSubmit={handleSubmit} target="_self" > @@ -133,8 +131,7 @@ const PasswordInputForm = ({