diff --git a/client/src/Pages/Auth/Register/Register.jsx b/client/src/Pages/Auth/Register/Register.jsx deleted file mode 100644 index 390aa6e36..000000000 --- a/client/src/Pages/Auth/Register/Register.jsx +++ /dev/null @@ -1,413 +0,0 @@ -import { useState, useEffect } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { useDispatch } from "react-redux"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Box, Button, Stack, Typography } from "@mui/material"; -import { StepOne } from "./StepOne"; -import { StepTwo } from "./StepTwo"; -import { StepThree } from "./StepThree"; -import { networkService } from "../../../main"; -import { newOrChangedCredentials } from "../../../Validation/validation"; -import { createToast } from "../../../Utils/toastUtils"; -import { register } from "../../../Features/Auth/authSlice"; -import Background from "../../../assets/Images/background-grid.svg?react"; -import Logo from "../../../assets/icons/checkmate-icon.svg?react"; -import Mail from "../../../assets/icons/mail.svg?react"; -import "../index.css"; -import { useTranslation, Trans } from "react-i18next"; -/** - * Displays the initial landing page. - * - * @param {Object} props - * @param {boolean} props.isSuperAdmin - Whether the user is creating and admin account - * @param {Function} props.onContinue - Callback function to handle "Continue with Email" button click. - * @returns {JSX.Element} - */ -const LandingPage = ({ isSuperAdmin, onSignup }) => { - const theme = useTheme(); - const { t } = useTranslation(); - return ( - <> - - - - {isSuperAdmin - ? t("auth.registration.heading.superAdmin") - : t("auth.registration.heading.user")} - - - {isSuperAdmin - ? t("auth.registration.description.superAdmin") - : t("auth.registration.description.user")} - - - - - - - - { - window.open( - "https://bluewavelabs.ca/terms-of-service-open-source", - "_blank", - "noreferrer" - ); - }} - /> - ), - a2: ( - { - window.open( - "https://bluewavelabs.ca/privacy-policy-open-source", - "_blank", - "noreferrer" - ); - }} - /> - ), - }} - /> - - - - - ); -}; - -LandingPage.propTypes = { - isSuperAdmin: PropTypes.bool, - onSignup: PropTypes.func, -}; - -const Register = ({ isSuperAdmin }) => { - const dispatch = useDispatch(); - const navigate = useNavigate(); - const { token } = useParams(); - const theme = useTheme(); - const { t } = useTranslation(); - // TODO If possible, change the IDs of these fields to match the backend - const idMap = { - "register-firstname-input": "firstName", - "register-lastname-input": "lastName", - "register-email-input": "email", - "register-password-input": "password", - "register-confirm-input": "confirm", - }; - - const [form, setForm] = useState({ - firstName: "", - lastName: "", - email: "", - password: "", - confirm: "", - role: [], - teamId: "", - }); - const [errors, setErrors] = useState({}); - const [step, setStep] = useState(0); - - useEffect(() => { - const fetchInvite = async () => { - if (token !== undefined) { - try { - const res = await networkService.verifyInvitationToken(token); - const invite = res.data.data; - const { email } = invite; - setForm({ ...form, email }); - } catch (error) { - navigate("/register", { replace: true }); - } - } - }; - fetchInvite(); - }, []); - - /** - * Validates the form data against the validation schema. - * - * @param {Object} data - The form data to validate. - * @param {Object} [options] - Optional settings for validation. - * @returns {Object | undefined} - Returns the validation error object if there are validation errors; otherwise, `undefined`. - */ - const validateForm = (data, options = {}) => { - const { error } = newOrChangedCredentials.validate(data, { - abortEarly: false, - ...options, - }); - return error; - }; - - /** - * Handles validation errors by setting the state with error messages and displaying a toast notification. - * - * @param {Object} error - The validation error object returned from the validation schema. - */ - const handleError = (error) => { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - setErrors(newErrors); - // Localization keys are in validation.js - createToast({ body: t(error.details[0].message || "auth.common.errors.validation") }); - }; - - const handleStepOne = async (e) => { - e.preventDefault(); - let error = validateForm({ - firstName: form.firstName, - lastName: form.lastName, - }); - - if (error) { - handleError(error); - return; - } - - setStep(2); - }; - - const handleStepTwo = async (e) => { - e.preventDefault(); - - let error; - error = validateForm({ email: form.email }); - if (error) { - handleError(error); - return; - } - - setStep(3); - }; - - // Final step - // Attempts account registration - const handleStepThree = async (e) => { - e.preventDefault(); - const { password, confirm } = e.target.elements; - let registerForm = { - ...form, - password: password.value, - confirm: confirm.value, - role: isSuperAdmin ? ["superadmin"] : form.role, - inviteToken: token ? token : "", // Add the token to the request for verification - }; - let error = validateForm(registerForm, { - context: { password: registerForm.password }, - }); - if (error) { - handleError(error); - return; - } - - delete registerForm.confirm; - const action = await dispatch(register(registerForm)); - if (action.payload.success) { - const authToken = action.payload.data; - navigate("/uptime"); - createToast({ - body: t("auth.registration.toasts.success"), - }); - } else { - if (action.payload) { - createToast({ - body: action.payload.msg, - }); - } else { - createToast({ - body: t("common.toasts.unknownError"), - }); - } - } - }; - - const handleChange = (event) => { - const { value, id } = event.target; - const name = idMap[id]; - const lowerCasedValue = - name === idMap["register-email-input"] ? value?.toLowerCase() || value : value; - setForm((prev) => ({ - ...prev, - [name]: lowerCasedValue, - })); - - const { error } = newOrChangedCredentials.validate( - { [name]: lowerCasedValue }, - { abortEarly: false, context: { password: form.password } } - ); - - setErrors((prev) => { - const prevErrors = { ...prev }; - if (error) prevErrors[name] = error.details[0].message; - else delete prevErrors[name]; - return prevErrors; - }); - }; - - return ( - - - - - - - {t("common.appName")} - - .MuiStack-root": { - border: 1, - borderRadius: theme.spacing(5), - borderColor: theme.palette.primary.lowContrast, - backgroundColor: theme.palette.primary.main, - padding: { - xs: theme.spacing(12), - sm: theme.spacing(20), - }, - }, - }} - > - {step === 0 ? ( - setStep(1)} - /> - ) : step === 1 ? ( - setStep(0)} - /> - ) : step === 2 ? ( - setStep(1)} - /> - ) : step === 3 ? ( - setStep(2)} - /> - ) : ( - "" - )} - - - - { - navigate("/login"); - }} - sx={{ userSelect: "none", color: theme.palette.accent.main }} - /> - ), - }} - /> - - - - ); -}; -Register.propTypes = { - isSuperAdmin: PropTypes.bool, -}; -export default Register; diff --git a/client/src/Pages/Auth/Register/StepOne/index.jsx b/client/src/Pages/Auth/Register/StepOne/index.jsx deleted file mode 100644 index 575a9132f..000000000 --- a/client/src/Pages/Auth/Register/StepOne/index.jsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useEffect, useRef } from "react"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Box, Button, Stack, Typography } from "@mui/material"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import TextInput from "../../../../Components/Inputs/TextInput"; -import { useTranslation } from "react-i18next"; - -StepOne.propTypes = { - isSuperAdmin: PropTypes.bool, - form: PropTypes.object, - errors: PropTypes.object, - onSubmit: PropTypes.func, - onChange: PropTypes.func, - onBack: PropTypes.func, -}; - -/** - * Renders the first step of the sign up process. - * - * @param {Object} props - * @param {boolean} props.isSuperAdmin - Whether the user is creating and admin account - * @param {Object} props.form - Form state object. - * @param {Object} props.errors - Object containing form validation errors. - * @param {Function} props.onSubmit - Callback function to handle form submission. - * @param {Function} props.onChange - Callback function to handle form input changes. - * @param {Function} props.onBack - Callback function to handle "Back" button click. - * @returns {JSX.Element} - */ - -function StepOne({ isSuperAdmin, form, errors, onSubmit, onChange, onBack }) { - const theme = useTheme(); - const inputRef = useRef(null); - const { t } = useTranslation(); - - useEffect(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, []); - - return ( - <> - {/* TODO this stack should be a component */} - - - - {isSuperAdmin - ? t("auth.registration.heading.superAdmin") - : t("auth.registration.heading.user")} - - {t("auth.registration.subheadings.stepOne")} - - - - - - - - - {/* TODO buttons should be a component should be a component */} - - - - - - - ); -} - -export { StepOne }; diff --git a/client/src/Pages/Auth/Register/StepThree/index.jsx b/client/src/Pages/Auth/Register/StepThree/index.jsx deleted file mode 100644 index c81ae08a7..000000000 --- a/client/src/Pages/Auth/Register/StepThree/index.jsx +++ /dev/null @@ -1,185 +0,0 @@ -import { useEffect, useRef } from "react"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Box, Button, Stack, Typography } from "@mui/material"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import TextInput from "../../../../Components/Inputs/TextInput"; -import Check from "../../../../Components/Check/Check"; -import { useValidatePassword } from "../../hooks/useValidatePassword"; -import { useTranslation } from "react-i18next"; -StepThree.propTypes = { - isSuperAdmin: PropTypes.bool, - onSubmit: PropTypes.func, - onBack: PropTypes.func, -}; - -/** - * Renders the third step of the sign up process. - * - * @param {Object} props - * @param {boolean} props.isSuperAdmin - Whether the user is creating and admin account - * @param {Function} props.onSubmit - Callback function to handle form submission. - * @param {Function} props.onBack - Callback function to handle "Back" button click. - * @returns {JSX.Element} - */ -function StepThree({ isSuperAdmin, onSubmit, onBack }) { - const theme = useTheme(); - const inputRef = useRef(null); - const { t } = useTranslation(); - - useEffect(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, []); - - const { handleChange, feedbacks, form, errors } = useValidatePassword(); - return ( - <> - - - - {isSuperAdmin - ? t("auth.registration.heading.superAdmin") - : t("auth.registration.heading.user")} - - {t("auth.registration.subheadings.stepThree")} - - - - - - - - - - - - - - - - - - - - - - ); -} - -export { StepThree }; diff --git a/client/src/Pages/Auth/Register/StepTwo/index.jsx b/client/src/Pages/Auth/Register/StepTwo/index.jsx deleted file mode 100644 index 306a2b750..000000000 --- a/client/src/Pages/Auth/Register/StepTwo/index.jsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useEffect, useRef } from "react"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Box, Button, Stack, Typography } from "@mui/material"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import TextInput from "../../../../Components/Inputs/TextInput"; -import { useTranslation } from "react-i18next"; - -StepTwo.propTypes = { - isSuperAdmin: PropTypes.bool, - form: PropTypes.object, - errors: PropTypes.object, - onSubmit: PropTypes.func, - onChange: PropTypes.func, - onBack: PropTypes.func, -}; - -/** - * Renders the second step of the sign up process. - * - * @param {Object} props - * @param {boolean} props.isSuperAdmin - Whether the user is creating and admin account - * @param {Object} props.form - Form state object. - * @param {Object} props.errors - Object containing form validation errors. - * @param {Function} props.onSubmit - Callback function to handle form submission. - * @param {Function} props.onChange - Callback function to handle form input changes. - * @param {Function} props.onBack - Callback function to handle "Back" button click. - * @returns {JSX.Element} - */ -function StepTwo({ isSuperAdmin, form, errors, onSubmit, onChange, onBack }) { - const theme = useTheme(); - const inputRef = useRef(null); - const { t } = useTranslation(); - - useEffect(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, []); - - return ( - <> - - - - {isSuperAdmin - ? t("auth.registration.heading.superAdmin") - : t("auth.registration.heading.user")} - - {t("auth.registration.subheadings.stepTwo")} - - - - (e.target.value = e.target.value.toLowerCase())} - onChange={onChange} - error={errors.email ? true : false} - helperText={t(errors.email)} // Localization keys are in validation.js - ref={inputRef} - /> - - - - - - - - ); -} - -export { StepTwo }; diff --git a/client/src/Pages/Auth/Register/index.jsx b/client/src/Pages/Auth/Register/index.jsx new file mode 100644 index 000000000..2dd0951dd --- /dev/null +++ b/client/src/Pages/Auth/Register/index.jsx @@ -0,0 +1,347 @@ +// Components +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import AuthHeader from "../components/AuthHeader"; +import TextInput from "../../../Components/Inputs/TextInput"; +import Check from "../../../Components/Check/Check"; +import Button from "@mui/material/Button"; + +// Utils +import { useTheme } from "@emotion/react"; +import { useTranslation } from "react-i18next"; +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { useParams } from "react-router-dom"; +import { networkService } from "../../../main"; +import { newOrChangedCredentials } from "../../../Validation/validation"; +import { register } from "../../../Features/Auth/authSlice"; +import { createToast } from "../../../Utils/toastUtils"; +import PropTypes from "prop-types"; + +const getFeedbackStatus = (form, errors, field, criteria) => { + const fieldErrors = errors?.[field]; + const isFieldEmpty = form?.[field]?.length === 0; + const hasError = fieldErrors?.includes(criteria) || fieldErrors?.includes("empty"); + const isCorrect = !isFieldEmpty && !hasError; + + if (isCorrect) { + return "success"; + } else if (hasError) { + return "error"; + } else { + return "info"; + } +}; + +const Register = ({ superAdminExists }) => { + // Local state + const [form, setForm] = useState({ + firstName: "", + lastName: "", + email: "", + password: "", + confirm: "", + role: [], + teamId: "", + }); + + const [errors, setErrors] = useState({}); + const [feedback, setFeedback] = useState({}); + + // Hooks + const theme = useTheme(); + const { t } = useTranslation(); + const { token } = useParams(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + + // Effects + useEffect(() => { + const fetchInvite = async () => { + if (token !== undefined) { + try { + const res = await networkService.verifyInvitationToken(token); + const invite = res.data.data; + const { email } = invite; + setForm((prevForm) => { + if (!prevForm.email) { + return { ...prevForm, email }; + } + return prevForm; + }); + } catch (error) { + navigate("/register", { replace: true }); + } + } + }; + fetchInvite(); + }, [form, token, navigate]); + + // Handlers + const onChange = (e) => { + let { name, value } = e.target; + if (name === "email") value = value.toLowerCase(); + const updatedForm = { ...form, [name]: value }; + + const { error } = newOrChangedCredentials.validate( + { [name]: value }, + { abortEarly: false, context: { password: form.password } } + ); + + setErrors((prev) => ({ + ...prev, + [name]: error?.details?.[0]?.message || "", + })); + + setForm(updatedForm); + }; + + const onPasswordChange = (e) => { + const { name, value } = e.target; + const updatedForm = { ...form, [name]: value }; + setForm(updatedForm); + const validateValue = { [name]: value }; + const validateOptions = { abortEarly: false, context: { password: form.password } }; + if (name === "password" && form.confirm.length > 0) { + validateValue.confirm = form.confirm; + validateOptions.context = { password: value }; + } else if (name === "confirm") { + validateValue.password = form.password; + } + const { error } = newOrChangedCredentials.validate(validateValue, validateOptions); + + const pwdErrors = error?.details.map((error) => ({ + path: error.path[0], + type: error.type, + })); + + const errorsByPath = + pwdErrors && + pwdErrors.reduce((acc, { path, type }) => { + if (!acc[path]) { + acc[path] = []; + } + acc[path].push(type); + return acc; + }, {}); + + const oldErrors = { ...errors }; + if (name === "password") { + oldErrors.password = undefined; + } else if (name === "confirm") { + oldErrors.confirm = undefined; + } + const newErrors = { ...oldErrors, ...errorsByPath }; + + setErrors(newErrors); + + const newFeedback = { + length: getFeedbackStatus(updatedForm, errorsByPath, "password", "string.min"), + special: getFeedbackStatus(updatedForm, errorsByPath, "password", "special"), + number: getFeedbackStatus(updatedForm, errorsByPath, "password", "number"), + uppercase: getFeedbackStatus(updatedForm, errorsByPath, "password", "uppercase"), + lowercase: getFeedbackStatus(updatedForm, errorsByPath, "password", "lowercase"), + confirm: getFeedbackStatus(updatedForm, errorsByPath, "confirm", "different"), + }; + + setFeedback(newFeedback); + }; + + const onSubmit = async (e) => { + e.preventDefault(); + const toSubmit = { + ...form, + role: superAdminExists ? form.role : ["superadmin"], + inviteToken: token ? token : "", + }; + + const { error } = newOrChangedCredentials.validate(toSubmit, { + abortEarly: false, + context: { password: form.password }, + }); + + if (error) { + const formErrors = {}; + for (const err of error.details) { + formErrors[err.path[0]] = err.message; + } + setErrors(formErrors); + return; + } + + delete toSubmit.confirm; + + const action = await dispatch(register(toSubmit)); + if (action.payload.success) { + navigate("/uptime"); + createToast({ + body: t("auth.registration.toasts.success"), + }); + } else { + if (action.payload) { + createToast({ + body: action.payload.msg, + }); + } else { + createToast({ + body: t("common.toasts.unknownError"), + }); + } + } + }; + + return ( + + + + + + {superAdminExists + ? t("auth.registration.heading.user") + : t("auth.registration.heading.superAdmin")} + + + {superAdminExists + ? t("auth.registration.description.user") + : t("auth.registration.description.superAdmin")} + + (e.target.value = e.target.value.toLowerCase())} + onChange={onChange} + error={errors.email ? true : false} + helperText={errors.email ? t(errors.email) : ""} // Localization keys are in validation.js + /> + + + + + + + + + + + + + + + + + ); +}; + +Register.propTypes = { + superAdminExists: PropTypes.bool, +}; + +export default Register;