diff --git a/client/src/Components/v2/design-elements/BasePage.tsx b/client/src/Components/v2/design-elements/BasePage.tsx index 65e74b553..ec429d5f6 100644 --- a/client/src/Components/v2/design-elements/BasePage.tsx +++ b/client/src/Components/v2/design-elements/BasePage.tsx @@ -247,7 +247,7 @@ export const BaseAuthPage = ({ > {title} - {subtitle} + {subtitle} { + const defaults = { + email: "", + }; + + return { + schema: recoverySchema, + defaults, + }; +}; diff --git a/client/src/Pages/Auth/ForgotPassword.jsx b/client/src/Pages/Auth/ForgotPassword.jsx deleted file mode 100644 index 1dee3b125..000000000 --- a/client/src/Pages/Auth/ForgotPassword.jsx +++ /dev/null @@ -1,243 +0,0 @@ -import { Box, Stack, Typography, Button } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { createToast } from "../../Utils/toastUtils.jsx"; -import { useDispatch, useSelector } from "react-redux"; -import { forgotPassword } from "../../Features/Auth/authSlice.js"; -import { useEffect, useState } from "react"; -import { newOrChangedCredentials } from "../../Validation/validation.js"; -import { useNavigate, useLocation } from "react-router-dom"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Logo from "@/assets/icons/checkmate-icon.svg?react"; -import Background from "@/assets/Images/background-grid.svg?react"; -import IconBox from "@/Components/v1/IconBox/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import { Trans, useTranslation } from "react-i18next"; -import "./index.css"; - -const ForgotPassword = () => { - const navigate = useNavigate(); - const dispatch = useDispatch(); - const theme = useTheme(); - const location = useLocation(); - - const { isLoading } = useSelector((state) => state.auth); - const [errors, setErrors] = useState({}); - const [form, setForm] = useState({ - email: "", - }); - - useEffect(() => { - const email = sessionStorage.getItem("email"); - email && setForm({ email: sessionStorage.getItem("email") }); - }, []); - - const { t } = useTranslation(); - - const handleSubmit = async (event) => { - event.preventDefault(); - - const { error } = newOrChangedCredentials.validate(form, { abortEarly: false }); - - if (error) { - // validation errors - const err = - error.details && error.details.length > 0 - ? error.details[0].message // FIXME: Possibly untranslated string - : t("auth.common.errors.validation"); - setErrors({ email: err }); - createToast({ - body: err, - }); - } else { - const action = await dispatch(forgotPassword(form)); - if (action.payload.success) { - sessionStorage.setItem("email", form.email); - navigate("/check-email", { - state: location.state || { from: "/login" }, // Preserve original from, fallback to login - }); - createToast({ - body: t("auth.forgotPassword.toasts.sent").replace("", form.email), - }); - } else { - if (action.payload) { - // dispatch errors - createToast({ - body: action.payload.msg, // FIXME: Potentially untranslated string - }); - } else { - // unknown errors - createToast({ - body: t("common.toasts.unknownError"), - }); - } - } - } - }; - - const handleChange = (event) => { - const { value } = event.target; - setForm({ email: value }); - - const { error } = newOrChangedCredentials.validate( - { email: value }, - { abortEarly: false } - ); - - if (error) setErrors({ email: error.details[0].message }); - else delete errors.email; - }; - - const handleNavigate = () => { - sessionStorage.removeItem("email"); - location.state?.from - ? navigate(location.state.from, { replace: true }) - : navigate("/login"); - }; - - 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), - }, - }, - }} - > - - - - - - - - {t("auth.forgotPassword.heading")} - {t("auth.forgotPassword.subheadings.stepOne")} - - - - - - - - - - Account Password" - : "auth.forgotPassword.links.login" - } - components={{ - a: ( - - ), - }} - /> - - - - ); -}; - -export default ForgotPassword; diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx index 1e4b48aaf..8611499d9 100644 --- a/client/src/Pages/Auth/Login/index.tsx +++ b/client/src/Pages/Auth/Login/index.tsx @@ -1,4 +1,4 @@ -import { BaseAuthPage } from "@/Components/v2/design-elements"; +import { BaseAuthPage, TextLink } from "@/Components/v2/design-elements"; import { Button, TextField } from "@/Components/v2/inputs"; import { useTranslation } from "react-i18next"; @@ -76,6 +76,18 @@ const LoginPage = () => { > {t("pages.auth.login.submit")} + + ); }; diff --git a/client/src/Pages/Auth/Recovery/index.tsx b/client/src/Pages/Auth/Recovery/index.tsx new file mode 100644 index 000000000..5add53077 --- /dev/null +++ b/client/src/Pages/Auth/Recovery/index.tsx @@ -0,0 +1,69 @@ +import { Button, TextField } from "@/Components/v2/inputs"; +import { BaseAuthPage, TextLink } from "@/Components/v2/design-elements"; + +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; +import { useRecoveryForm } from "@/Hooks/useRecoveryForm"; +import type { RecoveryFormData } from "@/Validation/recovery"; +import { usePost } from "@/Hooks/UseApi"; +import { useTranslation } from "react-i18next"; + +const ForgotPasswordPage = () => { + const { t } = useTranslation(); + const { post, loading } = usePost(); + + const { schema, defaults } = useRecoveryForm(); + + const { control, handleSubmit } = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const onSubmit = async (data: RecoveryFormData) => { + if (loading) return; + + const result = await post("/auth/recovery/request", data); + + if (result?.success) { + // Navigate to Check email page + } + }; + + return ( + + ( + + )} + /> + + + + ); +}; + +export default ForgotPasswordPage; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index b281b359e..acc9ad0c1 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -6,10 +6,11 @@ import { useSelector } from "react-redux"; import { Navigate, Route, Routes as LibRoutes } from "react-router"; import HomeLayout from "@/Components/v1/Layouts/HomeLayout"; import NotFound from "../Pages/NotFound/index.jsx"; + // Auth import AuthLogin from "../Pages/Auth/Login"; import AuthRegister from "../Pages/Auth/Register/index.jsx"; -import AuthForgotPassword from "../Pages/Auth/ForgotPassword.jsx"; +import AuthForgotPassword from "@/Pages/Auth/Recovery"; import AuthCheckEmail from "../Pages/Auth/CheckEmail.jsx"; import AuthSetNewPassword from "../Pages/Auth/SetNewPassword.jsx"; import AuthNewPasswordConfirmed from "../Pages/Auth/NewPasswordConfirmed.jsx"; @@ -382,7 +383,13 @@ const Routes = () => { } + element={ + <> + + + + + } /> val.toLowerCase().trim()), +}); + +export type RecoveryFormData = z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 514ac3db4..90fe56997 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -527,6 +527,17 @@ "linkText": "Register here" } } + }, + "forgotPassword": { + "title": "Forgot your password?", + "subtitle": "No worries, we'll send you reset instructions.", + "submit": "Request recovery", + "links": { + "login": { + "text": "Go back to", + "linkText": "login" + } + } } }, "checks": { diff --git a/server/src/routes/settingsRoute.ts b/server/src/routes/settingsRoute.ts index c7db1ed3b..c03c17524 100755 --- a/server/src/routes/settingsRoute.ts +++ b/server/src/routes/settingsRoute.ts @@ -13,7 +13,7 @@ class SettingsRoutes { initRoutes() { this.router.get("/", this.settingsController.getAppSettings); - this.router.put("/", isAllowed(["admin", "superadmin"]), this.settingsController.updateAppSettings); + this.router.patch("/", isAllowed(["admin", "superadmin"]), this.settingsController.updateAppSettings); this.router.post("/test-email", isAllowed(["admin", "superadmin"]), this.settingsController.sendTestEmail); } diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 8598f112f..1f46a080a 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -29,18 +29,18 @@ class EmailService { this.mjml2html = mjml2html; this.nodemailer = nodemailer; this.logger = logger; - this.init(); this.templateLookup = {}; this.loadTemplate = () => { return () => {}; }; + this.init(); } get serviceName() { return EmailService.SERVICE_NAME; } - init = async () => { + init = () => { this.loadTemplate = (templateName) => { try { const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);