Merge pull request #2661 from bluewave-labs/feat/edit-users

feat: edit users, resolves #2659
This commit is contained in:
Alexander Holliday authored and GitHub committed 2025-07-23 09:43:27 -07:00
commit e157ee7c53
21 files changed
+619 -103

No files matched your search

@@ -77,6 +77,7 @@ const Search = ({
labelVariant,
labelSx = {},
unit = "option",
maxWidth = "100%",
}) => {
const theme = useTheme();
const { t } = useTranslation();
+1
View File
@@ -16,6 +16,7 @@ const CustomTabList = ({ value, onChange, children, ...props }) => {
return (
<Box
sx={{
marginBottom: theme.spacing(12),
borderBottom: `1px solid ${theme.palette.primary.lowContrast}`,
"& .MuiTabs-root": { height: "fit-content", minHeight: "0" },
}}
+7 -1
View File
@@ -8,4 +8,10 @@ const useIsAdmin = () => {
return isAdmin;
};
export { useIsAdmin };
const useIsSuperAdmin = () => {
const { user } = useSelector((state) => state.auth);
const isSuperAdmin = user?.role?.includes("superadmin");
return isSuperAdmin;
};
export { useIsAdmin, useIsSuperAdmin };
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState, useCallback } from "react";
import { networkService } from "../main";
import { createToast } from "../Utils/toastUtils";
import { useTranslation } from "react-i18next";
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);
const response = await networkService.getUserById({ userId });
setUser(response?.data?.data);
} catch (error) {
createToast({
body: error.message,
});
setError(error);
} finally {
setIsLoading(false);
}
}, [userId]);
useEffect(() => {
if (userId) {
fetchUser();
}
}, [userId, fetchUser]);
return [user, isLoading, error];
};
export const useEditUser = (userId) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const { t } = useTranslation();
const editUser = useCallback(
async (user) => {
try {
setIsLoading(true);
await networkService.editUser({ userId, user });
createToast({
body: t("editUserPage.toast.successUserUpdate"),
});
} catch (error) {
createToast({
body: error.message,
});
setError(error);
} finally {
setIsLoading(false);
}
},
[userId]
);
return [editUser, isLoading, error];
};
@@ -0,0 +1,102 @@
import { useState, useEffect } from "react";
import { ROLES } from "../../../../Utils/roleUtils";
import { editUserValidation } from "../../../../Validation/validation";
import Joi from "joi";
import { createToast } from "../../../../Utils/toastUtils";
import { useTranslation } from "react-i18next";
export const useEditUserForm = (user) => {
const [searchInput, setSearchInput] = useState("");
const [form, setForm] = useState({
firstName: "",
lastName: "",
email: "",
role: [],
});
// Effect to set user
useEffect(() => {
if (user) {
setForm({
firstName: user?.firstName,
lastName: user?.lastName,
email: user?.email,
role: user?.role,
});
}
}, [user]);
const handleRoleChange = (value) => {
const hasSuperAdmin = form.role.includes(ROLES.SUPERADMIN);
const newRoles = value.map((item) => item.role);
if (hasSuperAdmin && !newRoles.includes(ROLES.SUPERADMIN)) {
newRoles.push(ROLES.SUPERADMIN);
}
setForm({
...form,
role: newRoles,
});
};
const handleDeleteRole = (role) => {
if (role === ROLES.SUPERADMIN) return;
setForm({ ...form, role: form?.role?.filter((r) => r !== role) });
};
const handleSearchInput = (value) => {
setSearchInput(value);
};
return [
form,
setForm,
handleRoleChange,
handleDeleteRole,
searchInput,
handleSearchInput,
];
};
export const useValidateEditUserForm = () => {
const [errors, setErrors] = useState({});
const { t } = useTranslation();
const validateForm = (form) => {
const { error } = editUserValidation.validate(form, {
abortEarly: false,
});
const errs = error?.details;
const errsObj = errs?.reduce((acc, curr) => {
acc[curr.path[0]] = curr.message;
return acc;
}, {});
setErrors(errsObj);
if (errs?.length > 0) {
createToast({
variant: "warning",
hasIcon: true,
title: t("editUserPage.toast.validationErrors"),
body: errs.map((err) => t(err.message)),
});
return false;
}
return true;
};
const validateField = (name, value) => {
const fieldSchema = Joi.object({
[name]: editUserValidation.extract(name),
});
const { error } = fieldSchema.validate({ [name]: value });
setErrors((prev) => {
const prevErrors = { ...prev };
if (error) prevErrors[name] = error.details[0].message;
else delete prevErrors[name];
return prevErrors;
});
};
return [errors, validateForm, validateField];
};
+122
View File
@@ -0,0 +1,122 @@
// Components
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import TextInput from "../../../Components/Inputs/TextInput";
import Search from "../../../Components/Inputs/Search";
import Button from "@mui/material/Button";
import RoleTable from "../components/RoleTable";
// Utils
import { useParams } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { useGetUser, useEditUser } from "../../../Hooks/userHooks";
import { EDITABLE_ROLES, ROLES } from "../../../Utils/roleUtils";
import { useEditUserForm, useValidateEditUserForm } from "./hooks/editUser";
const EditUser = () => {
const { userId } = useParams();
const theme = useTheme();
const { t } = useTranslation();
const BREADCRUMBS = [
{ name: t("menu.team"), path: "/account/team" },
{ name: t("editUserPage.title"), path: "" },
];
const [user, isLoading, error] = useGetUser(userId);
const [editUser, isSaving, saveError] = useEditUser(userId);
const [
form,
setForm,
handleRoleChange,
handleDeleteRole,
searchInput,
handleSearchInput,
] = useEditUserForm(user);
const [errors, validateForm, validateField] = useValidateEditUserForm();
const onChange = (e) => {
const name = e.target.name;
const value = e.target.value;
validateField(name, value);
setForm({ ...form, [name]: value });
};
const handleSubmit = (e) => {
e.preventDefault();
const valid = validateForm(form);
if (valid) {
editUser(form);
}
};
return (
<Stack gap={theme.spacing(20)}>
<Breadcrumbs list={BREADCRUMBS} />
<Typography variant="h2">{t("editUserPage.title")}</Typography>
<Stack
component="form"
onSubmit={handleSubmit}
gap={theme.spacing(12)}
maxWidth="50%"
>
<TextInput
name="firstName"
label={t("editUserPage.form.firstName")}
value={form?.firstName}
onChange={onChange}
error={errors?.firstName ? true : false}
helperText={t(errors?.firstName)}
/>
<TextInput
name="lastName"
label={t("editUserPage.form.lastName")}
value={form?.lastName}
onChange={onChange}
error={errors?.lastName ? true : false}
helperText={t(errors?.lastName)}
/>
<TextInput
name="email"
label={t("editUserPage.form.email")}
value={form?.email}
disabled={true}
error={errors?.email ? true : false}
helperText={t(errors?.email)}
/>
<Search
label={t("editUserPage.form.role")}
filteredBy="role"
inputValue={searchInput}
handleInputChange={handleSearchInput}
value={
form?.role
?.filter((role) => role !== ROLES.SUPERADMIN)
.map((role) => ({ role, _id: role })) || []
}
options={EDITABLE_ROLES}
multiple={true}
handleChange={handleRoleChange}
/>
<RoleTable
roles={form?.role}
handleDeleteRole={handleDeleteRole}
/>
<Box>
<Button
type="submit"
variant="contained"
color="accent"
loading={isLoading || isSaving}
>
{t("editUserPage.form.save")}
</Button>
</Box>
</Stack>
</Stack>
);
};
export default EditUser;
@@ -2,10 +2,10 @@ import TabPanel from "@mui/lab/TabPanel";
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography, Button } from "@mui/material";
import { PasswordEndAdornment } from "../../Inputs/TextInput/Adornments";
import TextInput from "../../Inputs/TextInput";
import { PasswordEndAdornment } from "../../../Components/Inputs/TextInput/Adornments";
import TextInput from "../../../Components/Inputs/TextInput";
import { newOrChangedCredentials } from "../../../Validation/validation";
import Alert from "../../Alert";
import Alert from "../../../Components/Alert";
import { update } from "../../../Features/Auth/authSlice";
import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../../Utils/toastUtils";
@@ -2,16 +2,16 @@ import { useTheme } from "@emotion/react";
import { useState } from "react";
import TabPanel from "@mui/lab/TabPanel";
import { Box, Button, Divider, Stack, Typography } from "@mui/material";
import Avatar from "../../Avatar";
import TextInput from "../../Inputs/TextInput";
import ImageUpload from "../../Inputs/ImageUpload";
import Avatar from "../../../Components/Avatar";
import TextInput from "../../../Components/Inputs/TextInput";
import ImageUpload from "../../../Components/Inputs/ImageUpload";
import { newOrChangedCredentials } from "../../../Validation/validation";
import { useDispatch, useSelector } from "react-redux";
import { clearAuthState, deleteUser, update } from "../../../Features/Auth/authSlice";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { GenericDialog } from "../../Dialog/genericDialog";
import Dialog from "../../Dialog";
import { GenericDialog } from "../../../Components/Dialog/genericDialog";
import Dialog from "../../../Components/Dialog";
import { useTranslation } from "react-i18next";
/**
@@ -191,7 +191,6 @@ const ProfilePanel = () => {
>
<Stack
component="form"
className="edit-profile-form"
noValidate
spellCheck="false"
gap={SPACING_GAP}
@@ -0,0 +1,42 @@
import Typography from "@mui/material/Typography";
import DataTable from "../../../../Components/Table";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import { useTranslation } from "react-i18next";
import { ROLES } from "../../../../Utils/roleUtils";
const RoleTable = ({ roles, handleDeleteRole }) => {
const { t } = useTranslation();
const HEADERS = [
{
id: "name",
content: <Typography>{t("editUserPage.table.roleHeader")}</Typography>,
render: (row) => {
return row;
},
},
{
id: "delete",
content: <Typography>{t("editUserPage.table.actionHeader")}</Typography>,
render: (row) => {
if (row === ROLES.SUPERADMIN) return null;
return (
<DeleteOutlineRoundedIcon
onClick={() => {
handleDeleteRole(row);
}}
sx={{ cursor: "pointer" }}
/>
);
},
},
];
return (
<DataTable
headers={HEADERS}
data={roles}
/>
);
};
export default RoleTable;
@@ -3,14 +3,16 @@ import TabPanel from "@mui/lab/TabPanel";
import { Button, ButtonGroup, Stack, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import TextInput from "../../Inputs/TextInput";
import TextInput from "../../../Components/Inputs/TextInput";
import { newOrChangedCredentials } from "../../../Validation/validation";
import { networkService } from "../../../main";
import { createToast } from "../../../Utils/toastUtils";
import Select from "../../Inputs/Select";
import { GenericDialog } from "../../Dialog/genericDialog";
import DataTable from "../../Table/";
import Select from "../../../Components/Inputs/Select";
import { GenericDialog } from "../../../Components/Dialog/genericDialog";
import DataTable from "../../../Components/Table";
import { useGetInviteToken } from "../../../Hooks/inviteHooks";
import { useNavigate } from "react-router-dom";
import { useIsSuperAdmin } from "../../../Hooks/useIsAdmin";
/**
* TeamPanel component manages the organization and team members,
* providing functionalities like renaming the organization, managing team members,
@@ -34,8 +36,9 @@ const TeamPanel = () => {
const [isDisabled, setIsDisabled] = useState(true);
const [errors, setErrors] = useState({});
const [isSendingInvite, setIsSendingInvite] = useState(false);
const navigate = useNavigate();
const [getInviteToken, clearToken, isLoading, error, token] = useGetInviteToken();
const isSuperAdmin = useIsSuperAdmin();
const headers = [
{
@@ -184,7 +187,6 @@ const TeamPanel = () => {
return (
<TabPanel
className="team-panel table-container"
value="team"
sx={{
"& h1": {
@@ -249,7 +251,17 @@ const TeamPanel = () => {
<DataTable
headers={headers}
data={data}
config={{ emptyView: t("teamPanel.noMembers") }}
config={{
emptyView: t("teamPanel.noMembers"),
rowSX: {
cursor: isSuperAdmin ? "pointer" : "default",
},
onRowClick: (row) => {
if (isSuperAdmin) {
navigate(`/account/team/${row.id}`);
}
},
}}
/>
</Stack>
-38
View File
@@ -1,38 +0,0 @@
.account h1.MuiTypography-root,
.account p.MuiTypography-root,
.account input,
.account button,
.account td,
.account .MuiSelect-select {
font-size: var(--env-var-font-size-medium);
}
.account h1.MuiTypography-root {
font-weight: 600;
}
.account .MuiTabPanel-root {
padding: 0;
margin-top: 50px;
}
.account button:not(.MuiIconButton-root) {
min-height: 34px;
}
.account .field {
flex: 1;
}
#modal-delete-account,
#modal-edit-org-name,
#modal-invite-member {
font-size: var(--env-var-font-size-large);
}
.account .MuiStack-root:has(span.MuiTypography-root.input-error) {
position: relative;
}
.account:not(:has(#modal-invite-member)) span.MuiTypography-root.input-error {
position: absolute;
top: 100%;
}
.account .MuiTableBody-root .MuiTableCell-root {
padding: var(--env-var-spacing-1-plus) var(--env-var-spacing-2);
}
+5 -5
View File
@@ -5,11 +5,10 @@ import { useSelector } from "react-redux";
import { Box, Tab, useTheme } from "@mui/material";
import CustomTabList from "../../Components/Tab";
import TabContext from "@mui/lab/TabContext";
import ProfilePanel from "../../Components/TabPanels/Account/ProfilePanel";
import PasswordPanel from "../../Components/TabPanels/Account/PasswordPanel";
import TeamPanel from "../../Components/TabPanels/Account/TeamPanel";
import ProfilePanel from "./components/ProfilePanel";
import PasswordPanel from "./components/PasswordPanel";
import TeamPanel from "./components/TeamPanel";
import { useTranslation } from "react-i18next";
import "./index.css";
/**
* Account component renders a settings page with tabs for Profile, Password, and Team settings.
@@ -33,7 +32,9 @@ const Account = ({ open = "profile" }) => {
{ name: t("menu.password"), value: "password" },
{ name: t("menu.team"), value: "team" },
];
const hideTeams = !requiredRoles.some((role) => user.role.includes(role));
if (hideTeams) {
tabList = [
{ name: t("menu.profile"), value: "profile" },
@@ -63,7 +64,6 @@ const Account = ({ open = "profile" }) => {
return (
<Box
className="account"
px={theme.spacing(20)}
py={theme.spacing(12)}
>
+9
View File
@@ -42,6 +42,7 @@ import CreateNotifications from "../Pages/Notifications/create";
// Settings
import Account from "../Pages/Account";
import EditUser from "../Pages/Account/EditUser";
import Settings from "../Pages/Settings";
import Maintenance from "../Pages/Maintenance";
@@ -187,6 +188,14 @@ const Routes = () => {
path="account/team"
element={<Account open={"team"} />}
/>
<Route
path="account/team/:userId"
element={
<RoleProtectedRoute roles={["superadmin"]}>
<EditUser />
</RoleProtectedRoute>
}
/>
<Route
path="logs"
+18
View File
@@ -1099,6 +1099,24 @@ class NetworkService {
},
});
}
async getUserById(config) {
const userId = config?.userId;
return this.axiosInstance.get(`auth/users/${userId}`, {
headers: {
"Content-Type": "application/json",
},
});
}
async editUser(config) {
const { userId, user } = config;
return this.axiosInstance.put(`auth/users/${userId}`, user, {
headers: {
"Content-Type": "application/json",
},
});
}
}
export default NetworkService;
+13
View File
@@ -0,0 +1,13 @@
export const ROLES = {
SUPERADMIN: "superadmin",
ADMIN: "admin",
USER: "user",
DEMO: "demo",
};
export const VALID_ROLES = [ROLES.ADMIN, ROLES.USER, ROLES.DEMO];
export const EDITABLE_ROLES = [
{ role: ROLES.ADMIN, _id: ROLES.ADMIN },
{ role: ROLES.USER, _id: ROLES.USER },
];
+20
View File
@@ -1,5 +1,6 @@
import joi from "joi";
import dayjs from "dayjs";
import { ROLES } from "../Utils/roleUtils";
const THRESHOLD_COMMON_BASE_MSG = "Threshold must be a number.";
@@ -429,6 +430,24 @@ const notificationValidation = joi.object({
}),
});
const editUserValidation = joi.object({
firstName: nameSchema,
lastName: lastnameSchema,
role: joi
.array()
.items(joi.string().valid(...Object.values(ROLES)))
.min(1)
.messages({
"array.min": "auth.common.fields.role.errors.min",
}),
email: joi
.string()
.required()
.trim()
.email({ tlds: { allow: false } })
.lowercase(),
});
export {
newOrChangedCredentials,
loginCredentials,
@@ -441,4 +460,5 @@ export {
statusPageValidation,
logoImageValidation,
notificationValidation,
editUserValidation,
};
+73 -42
View File
@@ -32,6 +32,11 @@
"errors": {
"incorrect": "The password you provided does not match our records"
}
},
"role": {
"errors": {
"min": "At least one role is required"
}
}
},
"inputs": {
@@ -212,8 +217,8 @@
"checkFormError": "Please check the form for errors.",
"checkFrequency": "Check frequency",
"checkHooks": {
"failureResolveOne": "Failed to resolve incident.",
"failureResolveAll": "Failed to resolve all incidents."
"failureResolveAll": "Failed to resolve all incidents.",
"failureResolveOne": "Failed to resolve incident."
},
"checkingEvery": "Checking every",
"city": "CITY",
@@ -229,9 +234,6 @@
"unknownError": "Unknown error"
}
},
"general": {
"noOptionsFound": "No {{unit}} found"
},
"commonSave": "Save",
"commonSaving": "Saving...",
"companyName": "Company name",
@@ -251,6 +253,8 @@
"createMonitor": "Create monitor",
"createNew": "Create new",
"createNotifications": {
"dialogDeleteConfirm": "Delete",
"dialogDeleteTitle": "Are you sure you want to delete this notification?",
"discordSettings": {
"description": "Configure your Discord webhook here",
"title": "Discord",
@@ -281,6 +285,7 @@
"webhookLabel": "Slack webhook URL",
"webhookPlaceholder": "https://hooks.slack.com/services/..."
},
"testNotification": "Test notification",
"title": "Create notification channel",
"typeSettings": {
"description": "Select the type of notification channel you want to create.",
@@ -292,10 +297,7 @@
"title": "Webhook",
"webhookLabel": "Webhook URL",
"webhookPlaceholder": "https://your-server.com/webhook"
},
"testNotification": "Test notification",
"dialogDeleteTitle": "Are you sure you want to delete this notification?",
"dialogDeleteConfirm": "Delete"
}
},
"createYour": "Create your",
"date&Time": "Date & Time",
@@ -305,6 +307,27 @@
"deleteStatusPage": "Do you want to delete this status page?",
"deleteStatusPageConfirm": "Yes, delete status page",
"deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.",
"diagnosticsPage": {
"diagnosticDescription": "System diagnostics",
"gauges": {
"heapAllocationSubtitle": "% of available memory",
"heapAllocationTitle": "Heap allocation",
"heapUsageSubtitle": "% of available memory",
"heapUsageTitle": "Heap usage",
"heapUtilizationSubtitle": "% of allocated",
"heapUtilizationTitle": "Heap utilization",
"instantCpuUsageSubtitle": "% of 1s used by CPU",
"instantCpuUsageTitle": "Instant CPU usage"
},
"stats": {
"eventLoopDelayTitle": "Event loop delay",
"osMemoryLimitTitle": "OS Memory Limit",
"totalHeapSizeTitle": "Total heap size",
"uptimeTitle": "Uptime",
"usedHeapSizeTitle": "Used heap size"
},
"statsDescription": "System statistics"
},
"disk": "Disk",
"diskUsage": "Disk Usage",
"displayName": "Display name",
@@ -355,6 +378,24 @@
"duration": "Duration",
"edit": "Edit",
"editMaintenance": "Edit maintenance",
"editUserPage": {
"form": {
"email": "Email",
"firstName": "First name",
"lastName": "Last name",
"role": "Roles",
"save": "Save"
},
"table": {
"actionHeader": "Action",
"roleHeader": "Role"
},
"title": "Edit user",
"toast": {
"successUserUpdate": "User updated successfully",
"validationErrors": "Validation errors"
}
},
"editing": "Editing...",
"emailSent": "Email sent successfully",
"errorInvalidFieldId": "Invalid field ID provided",
@@ -385,6 +426,9 @@
"friendlyNameInput": "Friendly name",
"friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes",
"gb": "GB",
"general": {
"noOptionsFound": "No {{unit}} found"
},
"greeting": {
"append": "The afternoon is your playground—let's make it epic!",
"overview": "Here's an overview of your {{type}} monitors.",
@@ -399,24 +443,24 @@
"incidentsOptionsHeader": "Incidents for:",
"incidentsOptionsHeaderFilterAll": "All",
"incidentsOptionsHeaderFilterBy": "Filter by:",
"incidentsOptionsHeaderTotalIncidents": "Total Incidents",
"incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve",
"incidentsTableResolvedAt": "Resolved at",
"incidentsOptionsHeaderFilterDown": "Down",
"incidentsTableActionResolve": "Resolve",
"incidentsPageActionResolve": "Resolve all incidents",
"incidentsOptionsHeaderFilterResolved": "Resolved",
"incidentsOptionsHeaderLastDay": "Last day",
"incidentsOptionsHeaderLastHour": "Last hour",
"incidentsOptionsHeaderLastWeek": "Last week",
"incidentsOptionsHeaderShow": "Show:",
"incidentsOptionsHeaderTotalIncidents": "Total Incidents",
"incidentsOptionsPlaceholderAllServers": "All servers",
"incidentsPageActionResolve": "Resolve all incidents",
"incidentsPageTitle": "Incidents",
"incidentsTableActionResolve": "Resolve",
"incidentsTableDateTime": "Date & Time",
"incidentsTableMessage": "Message",
"incidentsTableMonitorName": "Monitor Name",
"incidentsTableNoIncidents": "No incidents recorded",
"incidentsTablePaginationLabel": "incidents",
"incidentsTableResolvedAt": "Resolved at",
"incidentsTableStatus": "Status",
"incidentsTableStatusCode": "Status Code",
"infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.",
@@ -469,36 +513,15 @@
}
},
"tabs": {
"diagnostics": "Diagnostics",
"logs": "Server logs",
"queue": "Job queue",
"diagnostics": "Diagnostics"
"queue": "Job queue"
},
"title": "Logs",
"toast": {
"fetchLogsSuccess": "Logs fetched successfully"
}
},
"diagnosticsPage": {
"diagnosticDescription": "System diagnostics",
"statsDescription": "System statistics",
"gauges": {
"heapAllocationTitle": "Heap allocation",
"heapAllocationSubtitle": "% of available memory",
"heapUsageTitle": "Heap usage",
"heapUsageSubtitle": "% of available memory",
"heapUtilizationTitle": "Heap utilization",
"heapUtilizationSubtitle": "% of allocated",
"instantCpuUsageTitle": "Instant CPU usage",
"instantCpuUsageSubtitle": "% of 1s used by CPU"
},
"stats": {
"eventLoopDelayTitle": "Event loop delay",
"uptimeTitle": "Uptime",
"usedHeapSizeTitle": "Used heap size",
"totalHeapSizeTitle": "Total heap size",
"osMemoryLimitTitle": "OS Memory Limit"
}
},
"low": "low",
"maintenance": "maintenance",
"maintenanceRepeat": "Maintenance Repeat",
@@ -545,8 +568,8 @@
"message": "Message",
"monitor": "monitor",
"monitorActions": {
"deleteSuccess": "Monitor deleted successfully",
"deleteFailed": "Failed to delete monitor",
"deleteSuccess": "Monitor deleted successfully",
"details": "Details",
"export": "Export Monitors",
"import": "Import Monitors",
@@ -856,6 +879,14 @@
"pending": "Pending...",
"up": "Your site is up."
},
"statusPage": {
"contents": "Contents",
"createSuccess": "Status page created successfully",
"deleteFailed": "Failed to delete status page",
"deleteSuccess": "Status page deleted successfully",
"generalSettings": "General settings",
"updateSuccess": "Status page updated successfully"
},
"statusPageCreate": {
"buttonSave": "Save"
},
@@ -919,13 +950,13 @@
"testLocale": "testLocale",
"testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button",
"time": {
"threeMinutes": "3 minutes",
"fiveMinutes": "5 minutes",
"tenMinutes": "10 minutes",
"twentyMinutes": "20 minutes",
"oneHour": "1 hour",
"oneDay": "1 day",
"oneWeek": "1 week"
"oneHour": "1 hour",
"oneWeek": "1 week",
"tenMinutes": "10 minutes",
"threeMinutes": "3 minutes",
"twentyMinutes": "20 minutes"
},
"timeZoneInfo": "All dates and times are in GMT+0 time zone.",
"timezone": "Timezone",
+51
View File
@@ -5,6 +5,10 @@ import {
recoveryValidation,
recoveryTokenValidation,
newPasswordValidation,
getUserByIdParamValidation,
editUserByIdParamValidation,
editUserByIdBodyValidation,
editSuperadminUserByIdBodyValidation,
} from "../validation/joi.js";
import jwt from "jsonwebtoken";
import { getTokenFromHeaders } from "../utils/utils.js";
@@ -393,6 +397,53 @@ class AuthController {
SERVICE_NAME,
"getAllUsers"
);
getUserById = asyncHandler(
async (req, res, next) => {
await getUserByIdParamValidation.validateAsync(req.params);
const userId = req?.params?.userId;
const roles = req?.user?.role;
if (!userId) {
throw new Error("No user ID in request");
}
if (!roles || roles.length === 0) {
throw new Error("No roles in request");
}
const user = await this.db.getUserById(roles, userId);
return res.success({ msg: "ok", data: user });
},
SERVICE_NAME,
"getUserById"
);
editUserById = asyncHandler(
async (req, res, next) => {
const roles = req?.user?.role;
if (!roles.includes("superadmin")) {
throw createError("Unauthorized", 403);
}
const userId = req.params.userId;
const user = { ...req.body };
await editUserByIdParamValidation.validateAsync(req.params);
// If this is superadmin self edit, allow "superadmin" role
if (userId === req.user._id) {
await editSuperadminUserByIdBodyValidation.validateAsync(req.body);
} else {
await editUserByIdBodyValidation.validateAsync(req.body);
}
await this.db.editUserById(userId, user);
return res.success({ msg: "ok" });
},
SERVICE_NAME,
"editUserById"
);
}
export default AuthController;
+30 -1
View File
@@ -209,4 +209,33 @@ const logoutUser = async (userId) => {
}
};
export { insertUser, getUserByEmail, updateUser, deleteUser, deleteTeam, deleteAllOtherUsers, getAllUsers, logoutUser };
const getUserById = async (roles, userId) => {
try {
if (!roles.includes("superadmin")) {
throw new Error("User is not a superadmin");
}
const user = await UserModel.findById(userId).select("-password").select("-profileImage");
if (!user) {
throw new Error("User not found");
}
return user;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getUserById";
throw error;
}
};
const editUserById = async (userId, user) => {
try {
await UserModel.findByIdAndUpdate(userId, user, { new: true }).select("-password").select("-profileImage");
} catch (error) {
error.service = SERVICE_NAME;
error.method = "editUserById";
throw error;
}
};
export { insertUser, getUserByEmail, updateUser, deleteUser, deleteTeam, deleteAllOtherUsers, getAllUsers, logoutUser, getUserById, editUserById };
+2
View File
@@ -25,6 +25,8 @@ class AuthRoutes {
this.router.get("/users/superadmin", this.authController.checkSuperadminExists);
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("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser);
this.router.delete("/user", verifyJWT, this.authController.deleteUser);
+35
View File
@@ -1,4 +1,5 @@
import joi from "joi";
import { ROLES, VALID_ROLES } from "../../client/src/Utils/roleUtils.js";
//****************************************
// Custom Validators
@@ -652,6 +653,36 @@ const sendTestEmailBodyValidation = joi.object({
systemEmailTLSServername: joi.string().allow("").optional(),
});
const getUserByIdParamValidation = joi.object({
userId: joi.string().required(),
});
const editUserByIdParamValidation = joi.object({
userId: joi.string().required(),
});
const editUserByIdBodyValidation = joi.object({
firstName: nameValidation.required(),
lastName: nameValidation.required(),
email: joi.string().email().required(),
role: joi
.array()
.items(joi.string().valid(...VALID_ROLES))
.min(1)
.required(),
});
const editSuperadminUserByIdBodyValidation = joi.object({
firstName: nameValidation.required(),
lastName: nameValidation.required(),
email: joi.string().email().required(),
role: joi
.array()
.items(joi.string().valid(...VALID_ROLES, ROLES.SUPERADMIN))
.min(1)
.required(),
});
export {
roleValidatior,
loginValidation,
@@ -719,4 +750,8 @@ export {
webhookConfigValidation,
createAnnouncementValidation,
sendTestEmailBodyValidation,
getUserByIdParamValidation,
editUserByIdParamValidation,
editUserByIdBodyValidation,
editSuperadminUserByIdBodyValidation,
};