diff --git a/backend-dummy/README.md b/backend-dummy/README.md index a2e579a..4236bfc 100644 --- a/backend-dummy/README.md +++ b/backend-dummy/README.md @@ -44,3 +44,24 @@ This backend does not use a real database. However, there is a file named users. - **200 OK:** Returns the authenticated user's information. - **401 Unauthorized:** The user is not authenticated or lacks permission to perform this action. + +* [POST] users/forgotPassword + + **Description** + + This endpoint initiates the process of resetting a user's password. The user must provide their email address in the request body. If the email is associated with an account, a token will be created. + + **Responses:** + +- **200 OK:** If the email provided exists in our database we print the token in the console. + +* [POST] users/setPassword + + **Description** + + Receives a token and updates the associated user's password. + + **Responses:** + +- **200 OK:** The user's password has been successfully updated. +- **400 Bad Request:** Missing required fields in the request body or invalid token. diff --git a/backend-dummy/routes/users.js b/backend-dummy/routes/users.js index 3f21e99..354747e 100644 --- a/backend-dummy/routes/users.js +++ b/backend-dummy/routes/users.js @@ -86,4 +86,47 @@ router.get("/me", function (req, res, next) { return res.json({ status: "success", name: name, email: email }); }); +router.post("/forgotPassword", function (req, res, next) { + const { email } = req.body; + if (!email) { + return res + .status(400) + .json({ status: "error", message: "Invalid form submission", code: 400 }); + } + const user = users.find((user) => user.email === email); + if (user) { + const token = randomUUID(); + user["token"] = token; + fs.writeFileSync("users.json", `{"users":${JSON.stringify(users)}}`); + console.log("Token to do the reset password:", token); + } + return res.json({ + status: "success", + message: "Token sent", + }); +}); + +router.post("/setPassword", function (req, res, next) { + const { token, password } = req.body; + if (!token || !password) { + return res.status(400).json({ + status: "error", + message: "Invalid form submission or token", + code: 400, + }); + } + const user = users.find((user) => user.token === token); + if (!user) { + return res.status(400).json({ + status: "error", + message: "Invalid form submission or token", + code: 400, + }); + } + user["password"] = password; + delete user["token"]; + fs.writeFileSync("users.json", `{"users":${JSON.stringify(users)}}`); + return res.json({ status: "success", message: "Password updated" }); +}); + module.exports = router; diff --git a/src/networking/api-routes.ts b/src/networking/api-routes.ts index 457e774..73839fa 100644 --- a/src/networking/api-routes.ts +++ b/src/networking/api-routes.ts @@ -7,6 +7,8 @@ const API_ROUTES = { LOGIN: "users/login", SIGN_UP: "users/signUp", ME: "users/me", + FORGOT_PASSWORD: "users/forgotPassword", + SET_PASSWORD: "users/setPassword", }; export { API_ROUTES }; diff --git a/src/networking/controllers/users.ts b/src/networking/controllers/users.ts index f26c39a..940ae19 100644 --- a/src/networking/controllers/users.ts +++ b/src/networking/controllers/users.ts @@ -28,4 +28,18 @@ const me = async () => { return info; }; -export { login, signUp, me }; +const setNewPassword = async (token: string, password: string) => { + const response = await ApiService.post(API_ROUTES.SET_PASSWORD, { + body: JSON.stringify({ token, password }), + }); + return response; +}; + +const forgotPassword = async (email: string) => { + const response = await ApiService.post(API_ROUTES.FORGOT_PASSWORD, { + body: JSON.stringify({ email }), + }); + return response; +}; + +export { login, signUp, me, setNewPassword, forgotPassword }; diff --git a/src/pages/forgot-password/forgot-password.module.scss b/src/pages/forgot-password/forgot-password.module.scss new file mode 100644 index 0000000..1c74a7f --- /dev/null +++ b/src/pages/forgot-password/forgot-password.module.scss @@ -0,0 +1,32 @@ +@use "../../assets/stylesheets/text-styles.scss"; +@import "../../assets/stylesheets/colors"; + +.container { + display: flex; + justify-content: center; + align-items: center; + width: 100vw; + height: 100vh; + padding: 0 30px; +} + +.form { + max-width: 400px; + width: 100%; + padding: 30px; + border: 1px solid $text-neutral-20; + border-radius: 5px; +} + +.field { + margin-bottom: 20px; +} + +.submitButton { + width: 150px; + margin: 0 auto; +} + +.message { + color: $primary-color-40; +} diff --git a/src/pages/forgot-password/forgot-password.tsx b/src/pages/forgot-password/forgot-password.tsx new file mode 100644 index 0000000..0f9b409 --- /dev/null +++ b/src/pages/forgot-password/forgot-password.tsx @@ -0,0 +1,54 @@ +import { TextField } from "common/text-field"; +import { useState } from "react"; +import { Button } from "common/button"; +import { forgotPassword } from "networking/controllers/users"; +import styles from "./forgot-password.module.scss"; + +const ForgotPassword = () => { + const [error, setError] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + const [email, setEmail] = useState(""); + + const handleSendMail = async () => { + try { + await forgotPassword(email); + setShowSuccess(true); + } catch (e) { + setError(true); + } + }; + + const doSendMail = () => { + setError(false); + setShowSuccess(false); + handleSendMail().catch(() => { + setError(true); + }); + }; + + return ( +
+
+ { + setEmail(e.target.value); + }} + /> + {error && ( +

+ Something went wrong. Please try again. +

+ )} + {showSuccess &&

Email sent!

} + + +
+ ); +}; + +export { ForgotPassword }; diff --git a/src/pages/forgot-password/index.ts b/src/pages/forgot-password/index.ts new file mode 100644 index 0000000..4e7a551 --- /dev/null +++ b/src/pages/forgot-password/index.ts @@ -0,0 +1,3 @@ +import { ForgotPassword } from "./forgot-password"; + +export { ForgotPassword }; diff --git a/src/pages/login/login.module.scss b/src/pages/login/login.module.scss index 727d288..44002e5 100644 --- a/src/pages/login/login.module.scss +++ b/src/pages/login/login.module.scss @@ -30,3 +30,11 @@ .error { color: $primary-color-40; } + +.buttonContainer { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; +} diff --git a/src/pages/login/login.tsx b/src/pages/login/login.tsx index 575fd99..63b4dc2 100644 --- a/src/pages/login/login.tsx +++ b/src/pages/login/login.tsx @@ -4,7 +4,8 @@ import { Button } from "common/button"; import { TextField } from "common/text-field"; import styles from "./login.module.scss"; import { login } from "networking/controllers/users"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; +import { RouteName } from "routes"; export const Login = () => { const navigate = useNavigate(); @@ -48,14 +49,19 @@ export const Login = () => { setPassword(e.target.value); }} /> - {error &&

Incorrect email or password.

} - +
+ {error && ( +

Incorrect email or password.

+ )} + + Forgot password? +
); diff --git a/src/pages/reset-password/index.ts b/src/pages/reset-password/index.ts new file mode 100644 index 0000000..2c17c59 --- /dev/null +++ b/src/pages/reset-password/index.ts @@ -0,0 +1,3 @@ +import { ResetPassword } from "./reset-password"; + +export { ResetPassword }; diff --git a/src/pages/reset-password/reset-password.module.scss b/src/pages/reset-password/reset-password.module.scss new file mode 100644 index 0000000..d2b12cf --- /dev/null +++ b/src/pages/reset-password/reset-password.module.scss @@ -0,0 +1,33 @@ +@use "../../assets/stylesheets/text-styles.scss"; +@import "../../assets/stylesheets/colors"; + +.container { + display: flex; + justify-content: center; + align-items: center; + width: 100vw; + height: 100vh; + padding: 0 30px; +} + +.form { + max-width: 400px; + width: 100%; + padding: 30px; + border: 1px solid $text-neutral-20; + border-radius: 5px; +} + +.field { + margin-bottom: 20px; +} + +.submitButton { + width: 150px; + margin: 0 auto; +} + +.message { + color: $primary-color-40; + margin-bottom: 20px; +} diff --git a/src/pages/reset-password/reset-password.tsx b/src/pages/reset-password/reset-password.tsx new file mode 100644 index 0000000..aaf45e4 --- /dev/null +++ b/src/pages/reset-password/reset-password.tsx @@ -0,0 +1,102 @@ +import { TextField } from "common/text-field"; +import styles from "./reset-password.module.scss"; +import { useEffect, useState } from "react"; +import { Button } from "common/button"; +import { setNewPassword } from "networking/controllers/users"; +import { useGoToPage } from "hooks/use-go-to-page"; +import { RouteName } from "routes"; + +export const ResetPassword = () => { + const goToPage = useGoToPage(); + const [password, setPassword] = useState(""); + const [token, setToken] = useState(""); + const [repeatPassword, setRepeatPassword] = useState(""); + const [passwordError, setPasswordError] = useState(false); + const [error, setError] = useState(false); + const [success, setSuccess] = useState(false); + const formValid = !passwordError && password && repeatPassword; + + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const tokenParameter = urlParams.get("token"); + if (tokenParameter) { + setToken(tokenParameter); + } else { + goToPage(RouteName.Login); + } + }, [goToPage]); + + const handleReset = async () => { + try { + await setNewPassword(token, password); + setSuccess(true); + } catch (e) { + setError(true); + } + }; + + const doReset = () => { + handleReset().catch(() => { + setError(true); + }); + }; + return ( +
+
+ { + setPassword(e.target.value); + }} + onBlur={() => { + if (repeatPassword && password !== repeatPassword) { + setPasswordError(true); + } else { + setPasswordError(false); + } + }} + /> + { + setRepeatPassword(e.target.value); + }} + onBlur={() => { + if (password !== repeatPassword) { + setPasswordError(true); + } else { + setPasswordError(false); + } + }} + /> + {passwordError && ( +
The passwords do not match.
+ )} + + {error && ( +
+ Something went wrong. Please try again. +
+ )} + {success && ( +
+ Your password has been changed successfully. +
+ )} + + +
+ ); +}; diff --git a/src/routes/route-component.tsx b/src/routes/route-component.tsx index cc6bbe7..e2fe3d5 100644 --- a/src/routes/route-component.tsx +++ b/src/routes/route-component.tsx @@ -4,6 +4,8 @@ import { About } from "pages/about"; import { NotFound } from "pages/not-found"; import { SignUp } from "pages/sign-up/sign-up"; import { RouteName } from "./routes"; +import { ResetPassword } from "pages/reset-password"; +import { ForgotPassword } from "pages/forgot-password"; // NOTE: this object is needed to avoid circular dependencies. // Without it, importing the AppLink component in a page will create @@ -14,6 +16,8 @@ const RouteComponent = { [RouteName.Login]: Login, [RouteName.NotFound]: NotFound, [RouteName.SignUp]: SignUp, + [RouteName.ResetPassword]: ResetPassword, + [RouteName.ForgotPassword]: ForgotPassword, }; export { RouteComponent }; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index bd5242e..9ae3655 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -6,6 +6,8 @@ export enum RouteName { Login = "login", NotFound = "notFound", SignUp = "signUp", + ResetPassword = "resetPassword", + ForgotPassword = "forgotPassword", } export interface Route { @@ -67,6 +69,16 @@ const ROUTES = [ path: "/signUp", exact: true, }, + { + name: RouteName.ResetPassword, + path: "/resetPassword", + exact: true, + }, + { + name: RouteName.ForgotPassword, + path: "/forgotPassword", + exact: true, + }, { name: RouteName.NotFound, path: "*",