Merge pull request #2999 from bluewave-labs/feat-allow-admin-change-passwords

Add superadmin password reset functionality for team members
This commit is contained in:
Alexander Holliday
2025-10-06 12:52:46 -07:00
committed by GitHub
9 changed files with 238 additions and 13 deletions
+23 -3
View File
@@ -7,7 +7,6 @@ export const useGetUser = (userId) => {
const [user, setUser] = useState(undefined);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const fetchUser = useCallback(async () => {
try {
setIsLoading(true);
@@ -54,8 +53,29 @@ export const useEditUser = (userId) => {
setIsLoading(false);
}
},
[userId]
[userId, t]
);
return [editUser, isLoading, error];
const changePassword = useCallback(
async (passwordForm) => {
try {
setIsLoading(true);
await networkService.changePasswordByAdmin({ userId, passwordForm });
createToast({
body: t("teamPanel.changeTeamPassword.success"),
});
} catch (error) {
createToast({
body: error.message,
});
setError(error);
} finally {
setIsLoading(false);
}
},
[userId, t]
);
return [editUser, isLoading, error, changePassword];
};
@@ -0,0 +1,149 @@
import { useState } from "react";
import { Button, Stack } from "@mui/material";
import { GenericDialog } from "@/Components/v1/Dialog/genericDialog";
import TextInput from "@/Components/v1/Inputs/TextInput";
import PasswordTooltip from "@/Pages/v1/Auth/components/PasswordTooltip";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { createToast } from "../../../../Utils/toastUtils";
import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments";
import usePasswordFeedback from "@/Pages/v1/Auth/hooks/usePasswordFeedback";
import PropTypes from "prop-types";
const ChangePasswordModal = ({ isSaving, isLoading, changePassword }) => {
const INITIAL_FORM_STATE = {
password: "",
confirm: "",
};
const theme = useTheme();
const { t } = useTranslation();
const { feedback, handlePasswordFeedback } = usePasswordFeedback();
const [form, setForm] = useState(INITIAL_FORM_STATE);
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
const [errors, setErrors] = useState({});
const [isLoadingSubmit, setIsLoadingSubmit] = useState(false);
const closeChangePasswordModal = () => {
setIsChangePasswordOpen(false);
setForm(INITIAL_FORM_STATE);
};
const onChange = (e) => {
let { name, value } = e.target;
const updatedForm = { ...form, [name]: value };
setForm(updatedForm);
handlePasswordFeedback(updatedForm, name, value, form, errors, setErrors);
};
const isFormValid =
form.password.length > 1 &&
form.confirm.length > 1 &&
!errors.password &&
!errors.confirm;
const onsubmitChangePassword = async (event) => {
event.preventDefault();
if (!isFormValid) return;
const newPasswordForm = {
password: form.password,
};
try {
setIsLoadingSubmit(true);
await changePassword(newPasswordForm);
closeChangePasswordModal();
} catch (error) {
const errorMsg = error.response?.data?.msg || error.message || "unknownError";
createToast({
type: "error",
body: t(errorMsg),
});
} finally {
setIsLoadingSubmit(false);
}
};
return (
<>
<Button
variant="contained"
color="error"
onClick={() => setIsChangePasswordOpen(true)}
disabled={isLoading || isSaving}
>
{t("teamPanel.changeTeamPassword.changePasswordMenu")}
</Button>
<GenericDialog
title={t("teamPanel.changeTeamPassword.title")}
description={t("teamPanel.changeTeamPassword.description")}
open={isChangePasswordOpen}
onClose={closeChangePasswordModal}
theme={theme}
width={{ sm: "55%", md: "50%", lg: "40%", xl: "30%" }}
>
<PasswordTooltip
feedback={feedback}
form={form}
>
<TextInput
type="password"
id="register-password-input"
name="password"
label={t("auth.common.inputs.password.label")}
isRequired={true}
placeholder="••••••••••"
value={form.password}
onChange={onChange}
error={errors.password && errors.password[0] ? true : false}
endAdornment={<PasswordEndAdornment />}
sx={{ mb: theme.spacing(5) }}
/>
</PasswordTooltip>
<TextInput
type="password"
id="register-confirm-input"
name="confirm"
label={t("auth.common.inputs.passwordConfirm.label")}
gap={theme.spacing(4)}
isRequired={true}
placeholder={t("auth.common.inputs.passwordConfirm.placeholder")}
autoComplete="current-password"
value={form.confirm}
onChange={onChange}
error={errors.confirm && errors.confirm[0] ? true : false}
endAdornment={<PasswordEndAdornment />}
sx={{ mb: theme.spacing(5) }}
/>
<Stack
direction="row"
spacing={theme.spacing(10)}
mt={theme.spacing(8)}
justifyContent="flex-end"
>
<Button
variant="contained"
color="error"
onClick={closeChangePasswordModal}
disabled={isLoadingSubmit}
>
{t("teamPanel.cancel")}
</Button>
<Button
variant="contained"
color="accent"
onClick={onsubmitChangePassword}
disabled={isLoadingSubmit || !isFormValid}
>
{t("save")}
</Button>
</Stack>
</GenericDialog>
</>
);
};
ChangePasswordModal.propTypes = {
isSaving: PropTypes.bool.isRequired,
isLoading: PropTypes.bool.isRequired,
changePassword: PropTypes.func.isRequired,
};
export default ChangePasswordModal;
+21 -7
View File
@@ -1,13 +1,12 @@
// Components
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
import Search from "@/Components/v1/Inputs/Search/index.jsx";
import Button from "@mui/material/Button";
import RoleTable from "../components/RoleTable/index.jsx";
import ChangePasswordModal from "@/Pages/Account/components/ChangePasswordModal/index.jsx";
// Utils
import { useParams } from "react-router-dom";
import { useTheme } from "@emotion/react";
@@ -15,9 +14,12 @@ import { useTranslation } from "react-i18next";
import { useGetUser, useEditUser } from "../../../../Hooks/v1/userHooks.js";
import { EDITABLE_ROLES, ROLES } from "../../../../Utils/roleUtils.js";
import { useEditUserForm, useValidateEditUserForm } from "./hooks/editUser.js";
import { useSelector } from "react-redux";
const EditUser = () => {
const { user } = useSelector((state) => state.auth);
const { userId } = useParams();
const isSameUser = user?._id === userId;
const theme = useTheme();
const { t } = useTranslation();
const BREADCRUMBS = [
@@ -25,8 +27,8 @@ const EditUser = () => {
{ name: t("editUserPage.title"), path: "" },
];
const [user, isLoading, error] = useGetUser(userId);
const [editUser, isSaving, saveError] = useEditUser(userId);
const [userToEdit, isLoading, error] = useGetUser(userId);
const [editUser, isSaving, saveError, changePassword] = useEditUser(userId);
const [
form,
setForm,
@@ -34,7 +36,7 @@ const EditUser = () => {
handleDeleteRole,
searchInput,
handleSearchInput,
] = useEditUserForm(user);
] = useEditUserForm(userToEdit);
const [errors, validateForm, validateField] = useValidateEditUserForm();
const onChange = (e) => {
@@ -104,7 +106,12 @@ const EditUser = () => {
roles={form?.role}
handleDeleteRole={handleDeleteRole}
/>
<Box>
<Stack
direction="row"
spacing={theme.spacing(10)}
mt={theme.spacing(8)}
//justifyContent="flex-end"
>
<Button
type="submit"
variant="contained"
@@ -113,7 +120,14 @@ const EditUser = () => {
>
{t("editUserPage.form.save")}
</Button>
</Box>
{!isSameUser && (
<ChangePasswordModal
isSaving={isSaving}
isLoading={isLoading}
changePassword={changePassword}
/>
)}
</Stack>
</Stack>
</Stack>
);
+9
View File
@@ -1136,6 +1136,15 @@ class NetworkService {
},
});
}
async changePasswordByAdmin(config) {
const { userId, passwordForm } = config;
return this.axiosInstance.put(`auth/users/${userId}/password`, passwordForm, {
headers: {
"Content-Type": "application/json",
},
});
}
}
export default NetworkService;
+7 -1
View File
@@ -425,7 +425,13 @@
}
}
},
"role": "Role"
"role": "Role",
"changeTeamPassword": {
"changePasswordMenu": "Reset Password",
"title": "Reset team member password",
"description": "Create a new password for this team member. You will need to share the password with them securely.",
"success": "Password successfully reset. Make sure to provide the credentials to the member in a secure way."
}
},
"monitorState": {
"paused": "Paused",
+19 -2
View File
@@ -10,10 +10,10 @@ import {
editUserByIdParamValidation,
editUserByIdBodyValidation,
editSuperadminUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
} from "../../validation/joi.js";
const SERVICE_NAME = "authController";
/**
* Authentication Controller
*
@@ -436,7 +436,7 @@ class AuthController extends BaseController {
async (req, res) => {
const roles = req?.user?.role;
if (!roles.includes("superadmin")) {
throw createError("Unauthorized", 403);
throw this.errorService.createError("Unauthorized", 403);
}
const userId = req.params.userId;
@@ -456,6 +456,23 @@ class AuthController extends BaseController {
SERVICE_NAME,
"editUserById"
);
editUserPasswordById = this.asyncHandler(
async (req, res) => {
const roles = req?.user?.role;
if (!roles.includes("superadmin")) {
throw this.errorService.createError("Unauthorized", 403);
}
const userId = req.params.userId;
await editUserByIdParamValidation.validateAsync(req.params);
await editUserPasswordByIdBodyValidation.validateAsync(req.body);
const updatedPassword = req.body.password;
await this.userService.setPasswordByUserId(userId, updatedPassword);
return res.success({ msg: "Password reset successfully" });
},
SERVICE_NAME,
"editUserPasswordById"
);
}
export default AuthController;
+1
View File
@@ -25,6 +25,7 @@ class AuthRoutes {
this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers);
this.router.get("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.getUserById);
this.router.put("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById);
this.router.put("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
this.router.put("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser);
this.router.delete("/user", verifyJWT, this.authController.deleteUser);
@@ -211,5 +211,9 @@ class UserService {
editUserById = async (userId, user) => {
await this.db.userModule.editUserById(userId, user);
};
setPasswordByUserId = async (userId, password) => {
const updatedUser = await this.db.userModule.updateUser({ userId: userId, user: { password: password }, file: null });
return updatedUser;
};
}
export default UserService;
+5
View File
@@ -670,6 +670,10 @@ const editSuperadminUserByIdBodyValidation = joi.object({
.required(),
});
const editUserPasswordByIdBodyValidation = joi.object({
password: joi.string().min(8).required().pattern(passwordPattern),
});
export {
roleValidatior,
loginValidation,
@@ -739,4 +743,5 @@ export {
editUserByIdParamValidation,
editUserByIdBodyValidation,
editSuperadminUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
};