diff --git a/backend/middlewares/validators/userValidators.ts b/backend/middlewares/validators/userValidators.ts index 079c321c..82626954 100644 --- a/backend/middlewares/validators/userValidators.ts +++ b/backend/middlewares/validators/userValidators.ts @@ -72,9 +72,6 @@ export const updateUserDtoValidator = async ( if (!validatePrimitive(req.body.lastName, "string")) { return res.status(400).send(getApiValidationError("lastName", "string")); } - if (!validatePrimitive(req.body.email, "string")) { - return res.status(400).send(getApiValidationError("email", "string")); - } if (!validatePrimitive(req.body.role, "string")) { return res.status(400).send(getApiValidationError("role", "string")); } diff --git a/backend/rest/authRoutes.ts b/backend/rest/authRoutes.ts index beb55def..46d4fabc 100644 --- a/backend/rest/authRoutes.ts +++ b/backend/rest/authRoutes.ts @@ -267,6 +267,25 @@ authRouter.post( }, ); +authRouter.put( + "/changePassword", + updateTemporaryPasswordRequestValidator, + async (req, res) => { + try { + const accessToken = getAccessToken(req)!; + const newAccessToken = await authService.changeUserPassword( + accessToken, + req.body.newPassword, + ); + res.status(200).json({ + accessToken: newAccessToken, + }); + } catch (error: unknown) { + res.status(500).json({ error: getErrorMessage(error) }); + } + }, +); + authRouter.post( "/updateUserStatus", updateUserStatusRequestValidator, diff --git a/backend/rest/userRoutes.ts b/backend/rest/userRoutes.ts index db229541..12a1d1be 100644 --- a/backend/rest/userRoutes.ts +++ b/backend/rest/userRoutes.ts @@ -164,7 +164,6 @@ userRouter.put( const updatedUser = await userService.updateUserById(req.params.userId, { firstName: req.body.firstName, lastName: req.body.lastName, - email: req.body.email, role: req.body.role, status: "Active", }); @@ -321,7 +320,6 @@ userRouter.put( const updateLearnerPayload: UpdateUserDTO = { firstName: req.body.firstName, lastName: req.body.lastName, - email: req.body.email, role: req.body.role, status: "Active", }; @@ -338,4 +336,23 @@ userRouter.put( }, ); +userRouter.put( + "/updateMyAccount/:userId", + isAuthorizedByRole(new Set(["Administrator", "Facilitator", "Learner"])), + updateUserDtoValidator, + async (req, res) => { + try { + const updatedUser = await userService.updateUserById(req.params.userId, { + firstName: req.body.firstName, + lastName: req.body.lastName, + role: req.body.role, + status: "Active", + }); + res.status(200).json(updatedUser); + } catch (error: unknown) { + res.status(500).send(getErrorMessage(error)); + } + }, +); + export default userRouter; diff --git a/backend/services/implementations/userService.ts b/backend/services/implementations/userService.ts index 32be6ff0..6d7a008d 100644 --- a/backend/services/implementations/userService.ts +++ b/backend/services/implementations/userService.ts @@ -210,43 +210,20 @@ class UserService implements IUserService { try { // must explicitly specify runValidators when updating through findByIdAndUpdate - oldUser = await MgUser.findByIdAndUpdate(userId, user, { - runValidators: true, - }); + oldUser = await MgUser.findByIdAndUpdate( + userId, + { + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + status: user.status, + }, + { runValidators: true }, + ); if (!oldUser) { throw new Error(`userId ${userId} not found.`); } - - try { - await firebaseAdmin - .auth() - .updateUser(oldUser.authId, { email: user.email }); - } catch (error) { - // rollback MongoDB user updates - try { - await MgUser.findByIdAndUpdate( - userId, - { - firstName: oldUser.firstName, - lastName: oldUser.lastName, - role: oldUser.role, - status: oldUser.status, - }, - { runValidators: true }, - ); - } catch (mongoDbError: unknown) { - const errorMessage = [ - "Failed to rollback MongoDB user update after Firebase user update failure. Reason =", - getErrorMessage(mongoDbError), - "MongoDB user id with possibly inconsistent data =", - oldUser.id, - ]; - Logger.error(errorMessage.join(" ")); - } - - throw error; - } } catch (error: unknown) { Logger.error(`Failed to update user. Reason = ${getErrorMessage(error)}`); throw error; @@ -256,7 +233,6 @@ class UserService implements IUserService { ...oldUser.toObject(), firstName: user.firstName, lastName: user.lastName, - email: user.email, role: user.role, status: user.status, }; diff --git a/backend/types/userTypes.ts b/backend/types/userTypes.ts index 03a9b27b..f3538e94 100644 --- a/backend/types/userTypes.ts +++ b/backend/types/userTypes.ts @@ -20,7 +20,7 @@ export type UserDTO = { export type CreateUserDTO = Omit & { password: string }; -export type UpdateUserDTO = Omit; +export type UpdateUserDTO = Omit; export type SignupUserDTO = Omit; diff --git a/frontend/src/APIClients/AuthAPIClient.ts b/frontend/src/APIClients/AuthAPIClient.ts index a3e38add..2680f7e2 100644 --- a/frontend/src/APIClients/AuthAPIClient.ts +++ b/frontend/src/APIClients/AuthAPIClient.ts @@ -105,6 +105,36 @@ const updateTemporaryPassword = async ( } }; +const changePassword = async ( + email: string, + newPassword: string, + role: Role, +): Promise => { + const bearerToken = `Bearer ${getLocalStorageObjProperty( + AUTHENTICATED_USER_KEY, + "accessToken", + )}`; + try { + await baseAPIClient.put( + `/auth/changePassword`, + { newPassword }, + { headers: { Authorization: bearerToken } }, + ); + const newAuthenticatedUser = await login(email, newPassword, role); + if (!newAuthenticatedUser) { + throw new Error("Unable to authenticate user after logging in."); + } + setLocalStorageObjProperty( + AUTHENTICATED_USER_KEY, + "accessToken", + newAuthenticatedUser.accessToken, + ); + return true; + } catch (error) { + return false; + } +}; + const updateUserStatus = async (newStatus: Status): Promise => { const bearerToken = `Bearer ${getLocalStorageObjProperty( AUTHENTICATED_USER_KEY, @@ -165,6 +195,7 @@ export default { signup, resetPassword, updateTemporaryPassword, + changePassword, updateUserStatus, refresh, isUserVerified, diff --git a/frontend/src/APIClients/UserAPIClient.ts b/frontend/src/APIClients/UserAPIClient.ts index 6286953c..37b0ccc0 100644 --- a/frontend/src/APIClients/UserAPIClient.ts +++ b/frontend/src/APIClients/UserAPIClient.ts @@ -1,7 +1,10 @@ import AUTHENTICATED_USER_KEY from "../constants/AuthConstants"; import { Role } from "../types/AuthTypes"; import { User } from "../types/UserTypes"; -import { getLocalStorageObjProperty } from "../utils/LocalStorageUtils"; +import { + getLocalStorageObjProperty, + setLocalStorageObjProperty, +} from "../utils/LocalStorageUtils"; import baseAPIClient from "./BaseAPIClient"; const getUsersByRole = async (role: Role): Promise => { @@ -19,6 +22,39 @@ const getUsersByRole = async (role: Role): Promise => { } }; +const updateUserDetails = async ( + userId: string, + firstName: string, + lastName: string, + role: string, + status: string, +): Promise => { + const bearerToken = `Bearer ${getLocalStorageObjProperty( + AUTHENTICATED_USER_KEY, + "accessToken", + )}`; + try { + const response = await baseAPIClient.put( + `/users/updateMyAccount/${userId}`, + { + firstName, + lastName, + role, + status, + }, + { + headers: { Authorization: bearerToken }, + }, + ); + setLocalStorageObjProperty(AUTHENTICATED_USER_KEY, "firstName", firstName); + setLocalStorageObjProperty(AUTHENTICATED_USER_KEY, "lastName", lastName); + return response.data; + } catch (error) { + throw new Error("Failed to update user details"); + } +}; + export default { getUsersByRole, + updateUserDetails, }; diff --git a/frontend/src/components/common/MainPageButton.tsx b/frontend/src/components/common/MainPageButton.tsx index 62d3596d..b9711283 100644 --- a/frontend/src/components/common/MainPageButton.tsx +++ b/frontend/src/components/common/MainPageButton.tsx @@ -1,21 +1,48 @@ import React from "react"; +import { Typography, Button, useTheme } from "@mui/material"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import { useHistory } from "react-router-dom"; import { HOME_PAGE } from "../../constants/Routes"; +import { useUser } from "../../hooks/useUser"; const MainPageButton = (): React.ReactElement => { const history = useHistory(); + const theme = useTheme(); + const user = useUser(); + const navigateTo = () => history.push(HOME_PAGE); return ( -
- -
+ + Back + + + ); }; diff --git a/frontend/src/components/learners/LearnersList.tsx b/frontend/src/components/learners/LearnersList.tsx new file mode 100644 index 00000000..e3a42c1a --- /dev/null +++ b/frontend/src/components/learners/LearnersList.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Typography } from "@mui/material"; +import { + isAuthenticatedFacilitator, + isAuthenticatedLearner, +} from "../../types/AuthTypes"; +import { useUser } from "../../hooks/useUser"; + +const LearnerList = (): React.ReactElement => { + const user = useUser(); + + return ( + <> + {isAuthenticatedFacilitator(user) && ( + Learners: {user.learners} + )} + {isAuthenticatedLearner(user) && ( + Facilitator: {user.facilitator} + )} + + ); +}; + +export default LearnerList; diff --git a/frontend/src/components/pages/MyAccountPage.tsx b/frontend/src/components/pages/MyAccountPage.tsx index 0ba493b8..3ff35afc 100644 --- a/frontend/src/components/pages/MyAccountPage.tsx +++ b/frontend/src/components/pages/MyAccountPage.tsx @@ -1,29 +1,319 @@ -import React from "react"; +import React, { useState, useContext } from "react"; +import { Container, Typography, Button, useTheme } from "@mui/material"; +import PasswordIcon from "@mui/icons-material/Password"; +import ModeEditOutlineOutlinedIcon from "@mui/icons-material/ModeEditOutlineOutlined"; import MainPageButton from "../common/MainPageButton"; -import { - isAuthenticatedFacilitator, - isAuthenticatedLearner, -} from "../../types/AuthTypes"; -import { useUser } from "../../hooks/useUser"; +import ProfilePicture from "../profile/ProfilePicture"; +import EditDetailsModal from "../profile/EditDetailsModal"; +import ChangePasswordModal from "../profile/ChangePasswordModal"; +import AuthContext from "../../contexts/AuthContext"; +import userAPIClient from "../../APIClients/UserAPIClient"; +import authAPIClient from "../../APIClients/AuthAPIClient"; const MyAccount = (): React.ReactElement => { - const authenticatedUser = useUser(); + const theme = useTheme(); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = + useState(false); + + const { setAuthenticatedUser, authenticatedUser } = useContext(AuthContext); + + if (!authenticatedUser) { + return Please log in to view your account.; + } + + const handleSaveDetails = async (firstName: string, lastName: string) => { + try { + if (!authenticatedUser || !authenticatedUser.id) { + throw new Error("User information is not available."); + } + + const updatedUser = await userAPIClient.updateUserDetails( + authenticatedUser.id, + firstName, + lastName, + authenticatedUser.role, + authenticatedUser.status, + ); + + setAuthenticatedUser({ + ...authenticatedUser, + firstName: updatedUser.firstName, + lastName: updatedUser.lastName, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error updating user details:", error); + } + }; + + const handleSavePassword = async (newPassword: string) => { + try { + if (!authenticatedUser || !authenticatedUser.email) { + throw new Error("User information is not available."); + } + const success = await authAPIClient.changePassword( + authenticatedUser.email, + newPassword, + authenticatedUser.role, + ); + + if (!success) { + throw new Error("Failed to update password"); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error updating password:", error); + } + }; + return ( -
-

My Account

-
First Name: {authenticatedUser.firstName}
-
Last Name: {authenticatedUser.lastName}
-
- Email: {authenticatedUser.email} -
- {isAuthenticatedFacilitator(authenticatedUser) && ( -
Learners : {authenticatedUser.learners}
- )} - {isAuthenticatedLearner(authenticatedUser) && ( -
Facilitator : {authenticatedUser.facilitator}
- )} - -
+ <> + + + + + Your Account + + View and edit your details + + + + + + + Details + + + + First Name + + + {authenticatedUser.firstName} + + + + + Last Name + + + {authenticatedUser.lastName} + + + + + Email + + + {authenticatedUser.email} + + + + + + + + + + + + setIsEditModalOpen(false)} + firstName={authenticatedUser.firstName} + lastName={authenticatedUser.lastName} + onSave={handleSaveDetails} + /> + setIsChangePasswordModalOpen(false)} + onSave={handleSavePassword} + /> + ); }; diff --git a/frontend/src/components/profile/ChangePasswordModal.tsx b/frontend/src/components/profile/ChangePasswordModal.tsx new file mode 100644 index 00000000..a7d77e18 --- /dev/null +++ b/frontend/src/components/profile/ChangePasswordModal.tsx @@ -0,0 +1,185 @@ +import React, { useState } from "react"; +import { + Dialog, + IconButton, + Typography, + Button, + useTheme, + Box, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { useUser } from "../../hooks/useUser"; +import PasswordCheck from "../auth/PasswordCheck"; + +interface ChangePasswordModalProps { + open: boolean; + onClose: () => void; + onSave: (newPassword: string) => void; +} + +const ChangePasswordModal: React.FC = ({ + open, + onClose, + onSave, +}) => { + const theme = useTheme(); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isFormValid, setIsFormValid] = useState(false); + + const handleSave = () => { + if (!isFormValid) { + // eslint-disable-next-line no-alert + alert("Please ensure the passwords match and meet the requirements."); + return; + } + onSave(newPassword); + onClose(); + }; + + const user = useUser(); + + return ( + + + + + Edit Password + + + + + + + + + + + + + ); +}; + +export default ChangePasswordModal; diff --git a/frontend/src/components/profile/EditDetailsModal.tsx b/frontend/src/components/profile/EditDetailsModal.tsx new file mode 100644 index 00000000..e2beca7d --- /dev/null +++ b/frontend/src/components/profile/EditDetailsModal.tsx @@ -0,0 +1,198 @@ +import React, { useState } from "react"; +import { + Dialog, + IconButton, + Typography, + Button, + TextField, + useTheme, + Box, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { useUser } from "../../hooks/useUser"; + +interface EditDetailsModalProps { + open: boolean; + onClose: () => void; + firstName: string; + lastName: string; + onSave: (firstName: string, lastName: string) => void; +} + +const EditDetailsModal: React.FC = ({ + open, + onClose, + firstName, + lastName, + onSave, +}) => { + const theme = useTheme(); + const [editedFirstName, setEditedFirstName] = useState(firstName); + const [editedLastName, setEditedLastName] = useState(lastName); + const user = useUser(); + + // Reset form when modal opens + React.useEffect(() => { + if (open) { + setEditedFirstName(firstName); + setEditedLastName(lastName); + } + }, [open, firstName, lastName]); + + const handleSave = () => { + if (!editedFirstName.trim() || !editedLastName.trim()) { + return; + } + onSave(editedFirstName, editedLastName); + }; + + return ( + + + + Edit Details + + + + + + + + setEditedFirstName(e.target.value)} + fullWidth + InputLabelProps={{ + sx: theme.typography.bodySmall, + }} + InputProps={{ + sx: theme.typography.bodyMedium, + }} + /> + + setEditedLastName(e.target.value)} + fullWidth + InputLabelProps={{ + sx: theme.typography.bodySmall, + }} + InputProps={{ + sx: theme.typography.bodyMedium, + }} + /> + + + + + + + + ); +}; + +export default EditDetailsModal; diff --git a/frontend/src/components/profile/ProfilePicture.tsx b/frontend/src/components/profile/ProfilePicture.tsx new file mode 100644 index 00000000..8f13dd90 --- /dev/null +++ b/frontend/src/components/profile/ProfilePicture.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Box, Typography, Avatar } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { useUser } from "../../hooks/useUser"; + +interface ProfilePictureProps { + firstName?: string; + lastName?: string; +} + +const ProfilePicture = ({ + firstName = "", + lastName = "", +}: ProfilePictureProps): React.ReactElement => { + const theme = useTheme(); + const user = useUser(); + + return ( + + + + {`${firstName?.charAt(0) || ""}${lastName?.charAt(0) || ""}`} + + + + ); +}; + +export default ProfilePicture; diff --git a/frontend/src/types/UserTypes.ts b/frontend/src/types/UserTypes.ts index a47b883d..f3a4fa78 100644 --- a/frontend/src/types/UserTypes.ts +++ b/frontend/src/types/UserTypes.ts @@ -1,11 +1,14 @@ import { Role } from "./AuthTypes"; +export type Status = "Invited" | "Active"; + export type BaseUser = { id: string; firstName: string; lastName: string; email: string; role: Role; + status: Status; }; export type Administrator = BaseUser;