diff --git a/Client/src/Components/Avatar/index.jsx b/Client/src/Components/Avatar/index.jsx index d881de7bb..c7805aac9 100644 --- a/Client/src/Components/Avatar/index.jsx +++ b/Client/src/Components/Avatar/index.jsx @@ -8,13 +8,14 @@ import PropTypes from "prop-types"; * @param {string} props.firstName - The users first name * @param {string} props.lastName - The users last name * @param {boolean} props.small - Specifies if avatar should be large + * @param {Object} [props.sx] - Additional styles to apply to the button. * @returns {JSX.Element} * @example * // Render a red label * */ -const Avatar = ({ src, firstName, lastName, small }) => { +const Avatar = ({ src, firstName, lastName, small, sx }) => { const borderColor = "#F0F2F4"; const smallStyle = small ? { width: 32, height: 32 } : {}; return ( @@ -25,6 +26,7 @@ const Avatar = ({ src, firstName, lastName, small }) => { display: "inline-flex", border: `0.2rem solid ${borderColor}`, ...smallStyle, + ...sx }} > {src === undefined ? `${firstName[0]}${lastName[0]}` : null} @@ -37,6 +39,7 @@ Avatar.propTypes = { firstName: PropTypes.string.isRequired, lastName: PropTypes.string.isRequired, small: PropTypes.bool, + sx: PropTypes.object, }; export default Avatar; diff --git a/Client/src/Components/TabPanels/ProfileSettings/PasswordPanel.jsx b/Client/src/Components/TabPanels/ProfileSettings/PasswordPanel.jsx index 3297c8e86..56eb9a12a 100644 --- a/Client/src/Components/TabPanels/ProfileSettings/PasswordPanel.jsx +++ b/Client/src/Components/TabPanels/ProfileSettings/PasswordPanel.jsx @@ -2,21 +2,12 @@ import TabPanel from "@mui/lab/TabPanel"; import React, { useState } from "react"; import AnnouncementsDualButtonWithIcon from "../../Announcements/AnnouncementsDualButtonWithIcon/AnnouncementsDualButtonWithIcon"; import { useTheme } from "@emotion/react"; -import { - Box, - Divider, - FormControl, - IconButton, - InputAdornment, - OutlinedInput, - Stack, - Typography, -} from "@mui/material"; -import VisibilityOff from "@mui/icons-material/VisibilityOff"; -import Visibility from "@mui/icons-material/Visibility"; +import { Box, Divider, Stack, Typography } from "@mui/material"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import WarningAmberOutlinedIcon from "@mui/icons-material/WarningAmberOutlined"; import ButtonSpinner from "../../ButtonSpinner"; +import PasswordTextField from "../../TextFields/Password/PasswordTextField"; +import { editPasswordValidation } from "../../../Validation/validation"; /** * PasswordPanel component manages the form for editing password. @@ -31,20 +22,85 @@ const PasswordPanel = () => { const [isLoading, setIsLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); - //TODO - implement save password function - const handleSavePassword = () => { + //for testing, will tweak when I implement redux slice + const idToName = { + "edit-current-password": "password", + //TBD - form field naming + "edit-new-password": "newpassword", + "edit-confirm-password": "confirm", + }; + const [localData, setLocalData] = useState({ + password: "", + newpassword: "", + confirm: "", + }); + const [errors, setErrors] = useState({}); + + const handleChange = (event) => { + const { value, id } = event.target; + const name = idToName[id]; + setLocalData((prev) => ({ + ...prev, + [name]: value, + })); + + const validation = editPasswordValidation.validate( + { [name]: value }, + { abortEarly: false, context: { newpassword: localData.newpassword } } + ); + + setErrors((prev) => { + const updatedErrors = { ...prev }; + + if (validation.error) { + updatedErrors[name] = validation.error.details[0].message; + } else { + delete updatedErrors[name]; + } + return updatedErrors; + }); + }; + + const handleSubmit = (event) => { + event.preventDefault(); setIsLoading(true); + + const { error } = editPasswordValidation.validate(localData, { + abortEarly: false, + context: { newpassword: localData.newpassword }, + }); + + if (error) { + const newErrors = {}; + error.details.forEach((err) => { + newErrors[err.path[0]] = err.message; + }); + setErrors(newErrors); + setIsLoading(false); + } else { + //TODO - submit logic + } + setTimeout(() => { setIsLoading(false); - setIsOpen(false); }, 2000); }; + return ( -
+
} + icon={ + + } subject="SSO login" body="Since you logged in via SSO, you cannot reset or modify your password." /> @@ -52,64 +108,54 @@ const PasswordPanel = () => {
- Current Password + Current password - - + - setShowPassword((show) => !show)} - sx={{ - width: "30px", - height: "30px", - "&:focus": { - outline: "none", - }, - }} - > - {!showPassword ? : } - - - } - > - + label={null} + placeholder="Enter your current password" + autoComplete="current-password" + visibility={showPassword} + setVisibility={setShowPassword} + onChange={handleChange} + error={errors[idToName["edit-current-password"]] ? true : false} + /> + {errors[idToName["edit-current-password"]] ? ( + + {errors[idToName["edit-current-password"]]} + + ) : ( + "" + )} +
- Password + New password - - - setShowPassword((show) => !show)} - sx={{ - width: "30px", - height: "30px", - "&:focus": { - outline: "none", - }, - }} - > - {!showPassword ? : } - - - } - > - + + + {errors[idToName["edit-new-password"]] ? ( + + {errors[idToName["edit-new-password"]]} + + ) : ( + "" + )} +
@@ -117,6 +163,28 @@ const PasswordPanel = () => { Confirm new password + + + {errors[idToName["edit-confirm-password"]] ? ( + + {errors[idToName["edit-confirm-password"]]} + + ) : ( + "" + )} + +
+
+ } @@ -130,9 +198,10 @@ const PasswordPanel = () => { { //TODO - use redux loading state //!! - currently all loading buttons are tied to the same state const [isLoading, setIsLoading] = useState(false); + + //for testing, will tweak when I implement redux slice + const idToName = { + "edit-first-name": "firstname", + "edit-last-name": "lastname", + "edit-email": "email", + }; + const [localData, setLocalData] = useState({ + firstname: "", + lastname: "", + email: "", + }); + const [errors, setErrors] = useState({}); + + const handleChange = (event) => { + const { value, id } = event.target; + const name = idToName[id]; + setLocalData((prev) => ({ + ...prev, + [name]: value, + })); + + const validation = editProfileValidation.validate( + { [name]: value }, + { abortEarly: false } + ); + + setErrors((prev) => { + const updatedErrors = { ...prev }; + + if (validation.error) { + updatedErrors[name] = validation.error.details[0].message; + } else { + delete updatedErrors[name]; + } + return updatedErrors; + }); + }; + //TODO - implement delete profile picture function const handleDeletePicture = () => { setIsLoading(true); @@ -62,14 +97,23 @@ const ProfilePanel = () => { First Name - {/* TODO - use existing textfield components */} - + + + {errors[idToName["edit-first-name"]] ? ( + + {errors[idToName["edit-first-name"]]} + + ) : ( + "" + )} +
@@ -77,14 +121,23 @@ const ProfilePanel = () => { Last Name - {/* TODO - use existing textfield components */} - + + + {errors[idToName["edit-last-name"]] ? ( + + {errors[idToName["edit-last-name"]]} + + ) : ( + "" + )} +
@@ -95,14 +148,23 @@ const ProfilePanel = () => { After updating, you'll receive a confirmation email. - {/* TODO - use existing textfield components */} - + + + {errors[idToName["edit-email"]] ? ( + + {errors[idToName["edit-email"]]} + + ) : ( + "" + )} +
@@ -114,12 +176,17 @@ const ProfilePanel = () => { - {/* TODO - Use Avatar component instead of @mui */} + {/* TODO - Update placeholder values with redux data */} { onClick={handleSaveProfile} isLoading={isLoading} loadingText="Saving..." + disabled={Object.keys(errors).length !== 0 && true} sx={{ paddingX: "40px", }} diff --git a/Client/src/Components/TextFields/Password/PasswordTextField.jsx b/Client/src/Components/TextFields/Password/PasswordTextField.jsx index b0c18de8d..5a6fc959d 100644 --- a/Client/src/Components/TextFields/Password/PasswordTextField.jsx +++ b/Client/src/Components/TextFields/Password/PasswordTextField.jsx @@ -1,9 +1,11 @@ import "./passwordTextField.css"; import React, { useState } from "react"; -import { InputAdornment, TextField } from "@mui/material"; +import { IconButton, InputAdornment, TextField } from "@mui/material"; import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; import { useTheme } from "@mui/material"; +import VisibilityOff from "@mui/icons-material/VisibilityOff"; +import Visibility from "@mui/icons-material/Visibility"; /** * @component @@ -16,6 +18,8 @@ import { useTheme } from "@mui/material"; * @param {"error" | "help"} [props.icon] - The type of icon to display (error or help) (optional) * @param {string} props.helperText - The helper text displayed below the text field (optional) * @param {boolean} props.error - A flag indicating if the text field has an error (required) + * @param {boolean} props.visibility - A flag indicating if the password visibility is toggled (optional) + * @param {function} props.setVisibility - Function to toggle password visibility (optional) * @returns {JSX.Element} - Renders the password text field component with dynamic icon display */ const PasswordTextField = ({ @@ -28,6 +32,8 @@ const PasswordTextField = ({ icon, helperText, error, + visibility, + setVisibility, }) => { const theme = useTheme(); @@ -48,7 +54,7 @@ const PasswordTextField = ({
-
- {error && showIcon && ( - - )} - {error && !showIcon && ( - - )} - {!error && showIcon && ( - - )} -
+ {visibility !== undefined ? ( + setVisibility((show) => !show)} + tabIndex={-1} + sx={{ + width: "30px", + height: "30px", + "&:focus": { + outline: "none", + }, + }} + > + {!visibility ? : } + + ) : ( +
+ {error && showIcon && ( + + )} + {error && !showIcon && ( + + )} + {!error && showIcon && ( + + )} +
+ )} ), }} diff --git a/Client/src/Pages/Settings/index.css b/Client/src/Pages/Settings/index.css index c966a9031..4a0854cb2 100644 --- a/Client/src/Pages/Settings/index.css +++ b/Client/src/Pages/Settings/index.css @@ -64,8 +64,9 @@ margin-top: var(--env-var-spacing-4); } .edit-team-form__wrapper.compact { - margin-top: 30px; - color: var(--env-var-color-29); + margin-top: var(--env-var-spacing-3); + justify-content: flex-start; + align-items: center; } .edit-profile-form__wrapper input, .edit-password-form__wrapper input, @@ -81,7 +82,8 @@ .announcement-content-subject { margin-bottom: 2px; } -#edit-password + div > button > svg, +#edit-new-password + div > button > svg, +#edit-confirm-password + div > button > svg, #edit-current-password + div > button > svg { color: var(--env-var-color-29); } @@ -95,10 +97,12 @@ border-color: var(--env-var-color-27); background-color: var(--lighter-env-var-color-27); } -.MuiBox-root > .announcement-tile .announcement-content-body, -.MuiBox-root > .announcement-tile .announcement-close svg { +.MuiBox-root > .announcement-tile .announcement-content-body { color: var(--env-var-color-28); - fill: var(--env-var-color-28) !important; +} +.MuiBox-root > .announcement-tile .announcement-close svg, +.edit-password-form__wrapper > .announcement-tile .announcement-close svg { + display: none; } #org-name-flex-container .Mui-disabled { color: var(--env-var-color-2) !important; @@ -116,8 +120,7 @@ padding-left: 20px; } .edit-team-form__wrapper td { - font-size: (--env-var-font-size-medium); - color: var(--env-var-color-29); + color: var(--env-var-color-25); } #select-actions, @@ -155,3 +158,39 @@ min-height: 22px; border-radius: 50%; } + +/* for testing, will remove later */ +@media only screen and (max-width: 1600px) { + .settings { + max-width: calc(90vw - 250px) !important; + } + .edit-profile-form__wrapper .MuiStack-root:not(.row-stack), + .edit-password-form__wrapper .MuiStack-root:not(.row-stack), + .edit-organization-form__wrapper .MuiStack-root:not(.row-stack) { + margin-right: 30px; + } +} + +.settings .MuiStack-root:has(p.MuiTypography-root.input-error){ + position: relative; + margin-bottom: 8px; +} +.settings p.MuiTypography-root.input-error{ + color: var(--env-var-color-24); + opacity: 0.8; + position: absolute; + top: calc(100% + 5px); +} +.settings .email-text-field, +.settings .password-text-field { + margin: 0; +} + +.edit-profile-form__wrapper .MuiFormControl-root, +.edit-password-form__wrapper .MuiFormControl-root{ + width: 100%; +} +.edit-profile-form__wrapper .MuiStack-root:has(.MuiFormControl-root), +.edit-password-form__wrapper .MuiStack-root:has(.MuiFormControl-root){ + margin: 0; +} \ No newline at end of file diff --git a/Client/src/Validation/validation.js b/Client/src/Validation/validation.js index 74233a029..c2249fc93 100644 --- a/Client/src/Validation/validation.js +++ b/Client/src/Validation/validation.js @@ -63,9 +63,82 @@ const newPasswordValidation = joi.object({ }), }); +const editProfileValidation = joi.object({ + firstname: joi.string().trim().pattern(new RegExp("^[A-Za-z]+$")).messages({ + "string.empty": "*First name is required.", + "string.pattern.base": "*First name must contain only letters.", + }), + lastname: joi.string().trim().pattern(new RegExp("^[A-Za-z]+$")).messages({ + "string.empty": "*Last name is required.", + "string.pattern.base": "*Last name must contain only letters.", + }), + email: joi + .string() + .trim() + .email({ tlds: { allow: false } }) + .messages({ + "string.empty": "*Email is required.", + "string.email": "*Invalid email address.", + }), +}); + +const editPasswordValidation = joi.object({ + // TBD - validation for current password ? + password : joi + .string().trim() + .messages({ + "string.empty": "*Current password is required.", + }), + newpassword: joi + .string() + .trim() + .min(8) + .messages({ + "string.empty": "*Password is required.", + "string.min": "*Password must be at least 8 characters long.", + }) + .custom((value, helpers) => { + if (!/[A-Z]/.test(value)) { + return helpers.message( + "*Password must contain at least one uppercase letter." + ); + } + if (!/[a-z]/.test(value)) { + return helpers.message( + "*Password must contain at least one lowercase letter." + ); + } + if (!/\d/.test(value)) { + return helpers.message("*Password must contain at least one number."); + } + if (!/[!@#$%^&*]/.test(value)) { + return helpers.message( + "*Password must contain at least one special character." + ); + } + + return value; + }), + confirm: joi + .string() + .trim() + .messages({ + "string.empty": "*Password confirmation is required.", + }) + .custom((value, helpers) => { + const { password } = helpers.prefs.context; + if (value !== password) { + return helpers.message("*Passwords do not match."); + } + return value; + }), +}); + export { registerValidation, loginValidation, recoveryValidation, newPasswordValidation, + editPasswordValidation, + editProfileValidation, }; diff --git a/Client/src/index.css b/Client/src/index.css index e682b2a09..dbace21e4 100644 --- a/Client/src/index.css +++ b/Client/src/index.css @@ -3,9 +3,9 @@ line-height: 1.5; font-weight: 400; - color-scheme: light dark; + /* color-scheme: light dark; color: rgba(255, 255, 255, 0.87); - background-color: #242424; + background-color: #242424; */ font-synthesis: none; text-rendering: optimizeLegibility; @@ -45,7 +45,7 @@ --env-var-color-27: #fecf60; --lighter-env-var-color-27: rgba(254, 207, 96, 0.1); --env-var-color-28: #f79009; - --env-var-color-29: #667085; + --env-var-color-29: #D0D5DD; --env-var-radius-1: 4px; --env-var-radius-2: 8px;