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..e66cc5f6 --- /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 ( + + + + + + + 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..e656f94a --- /dev/null +++ b/resources/js/login/components/help_links.js @@ -0,0 +1,77 @@ +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) { + forgotPasswordActionHref = `${forgotPasswordAction}?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..28faf991 --- /dev/null +++ b/resources/js/login/components/otp_input_form.js @@ -0,0 +1,113 @@ +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 = useMemo(() => shouldShowCaptcha(), [shouldShowCaptcha]); + + return ( + <> +
onAuthenticate(ev.target)} + target="_self" + className={styles.otp_form} + > +
+ 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..447c388b --- /dev/null +++ b/resources/js/login/components/recovery_code_form.js @@ -0,0 +1,80 @@ +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} + + )} +
+ +
+
+
+

+ + 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..90152a49 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -1,937 +1,993 @@ -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 && - - } - - - ); -} +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 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 && - - } - - - ); -} + if (props.authError != "" && !this.state.user_fullname) { + this.state.user_fullname = props.userName; + } -const HelpLinks = ({ - userName, - showEmitOtpAction, - forgotPasswordAction, - showForgotPasswordAction, - showVerifyEmailAction, - verifyEmailAction, - showHelpAction, - helpAction, - appName, - emitOtpAction - }) => { - 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 ( - <> -
- { - showEmitOtpAction && - - Get A Single-use Code emailed to you - - } - { - showForgotPasswordAction && - - Reset your password - - } - { - showVerifyEmailAction && - - Verify {appName} - - } - {showHelpAction && - - Having trouble? - - } - + 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 OTPHelpLinks = ({ emitOtpAction }) => { - return ( - <> -
-

Didn't receive it ?

-

Check your spam folder or resend email. -

- - ); -} + handleEmitOtpAction(ev) { + ev.preventDefault(); + return this.emitOtpAction(); + } -const EmailErrorActions = ({ emitOtpAction, createAccountAction, onValidateEmail, disableInput }) => { + shouldShowCaptcha() { return ( - - - - - - - - - - - - - + typeof this.props.maxLoginAttempts2ShowCaptcha !== "undefined" && + typeof this.props.loginAttempts !== "undefined" && + this.props.loginAttempts >= this.props.maxLoginAttempts2ShowCaptcha ); -} + } -const ExistingAccountActions = ({emitOtpAction, forgotPasswordAction, userName, disableInput}) => { - if (userName) { - forgotPasswordAction = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; + handleAuthenticateValidation() { + if (this.state.authFlow === FLOW.OTP) { + if (this.state.otpCode == "") { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, otp: "Single-use code is empty" }, + }); + return false; + } } - return ( - - - - - - - Reset your password - - - - ); -} - -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.

- - ); -} - -const otp_flow = 'otp'; -const password_flow = 'password'; + if (this.state.user_password == "") { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, password: "Password is empty" }, + }); + return false; + } -class LoginPage extends React.Component { + if (this.state.captcha_value == "" && this.shouldShowCaptcha()) { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, password: "you must check CAPTCHA" }, + }); + return false; + } - 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, + 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, - authFlow: props.flow, - allowNativeAuth: props.allowNativeAuth, - showInfoBanner: props.showInfoBanner, - infoBannerContent: props.infoBannerContent, + })); } + } + } + + 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"); + } + } - if (props.authError != '' && !this.state.user_fullname) { - this.state.user_fullname = props.userName; - } + handleAuthenticatePasswordFlow(form) { + const formData = new FormData(form); - 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.`; - } + authenticateWithPassword(formData, this.props.token) + .then( + (payload) => { + this.handleAuthenticatePasswordOk(payload); + }, + (error) => this.handleAuthenticatePasswordError(error) + ); + } - 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({ - ...this.state, - notification: { - message: message, - severity: severity - } - }); - } + onAuthenticate(form) { - 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; + if (!this.handleAuthenticateValidation()) { + return false; } - handleEmitOtpAction(ev) { - ev.preventDefault(); - return this.emitOtpAction(); - } - - shouldShowCaptcha() { - return ( - this.props.hasOwnProperty('maxLoginAttempts2ShowCaptcha') && - this.props.hasOwnProperty('loginAttempts') && - this.props.loginAttempts >= this.props.maxLoginAttempts2ShowCaptcha - ) - } + this.setState({ ...this.state, disableInput: true }); - 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; - } - 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; + if (this.state.authFlow === FLOW.PASSWORD) { + this.handleAuthenticatePasswordFlow(form); + return false; } - onChangeCaptchaProvider(value) { - this.setState({ ...this.state, captcha_value: 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; } - onExpireCaptchaProvider() { - 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; } - onErrorCaptchaProvider() { - this.setState({ ...this.state, captcha_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; } - onHandleUserNameChange(ev) { - let { value, id } = ev.target; - this.setState({ ...this.state, user_name: value }); + if (status === HTTP_CODES.PRECONDITION_FAILED) { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: "Please enter a valid code." }, + }); + 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}}); + /** + * 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; } - onOTPCodeChange(value) { - this.setState({...this.state, otpCode: value}); + this.setState({ ...this.state, disableInput: false }); + this.showAlert("Oops... Something went wrong!", "error"); + } + + onVerify2FA() { + 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"); + }, + ); + } - onValidateEmail(ev) { - - 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() { + 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==