Merge latest changes

This commit is contained in:
Daniel Cojocea committed 2024-08-28 00:20:14 -04:00
commit b34a17004b
57 files changed
+1242 -844

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@
"@mui/x-data-grid": "7.3.2",
"@mui/x-date-pickers": "7.3.2",
"@reduxjs/toolkit": "2.2.5",
"axios": "1.7.4",
"axios": "^1.7.4",
"chart.js": "^4.4.3",
"dayjs": "1.11.11",
"joi": "17.13.1",
+1 -1
View File
@@ -20,7 +20,7 @@
"@mui/x-data-grid": "7.3.2",
"@mui/x-date-pickers": "7.3.2",
"@reduxjs/toolkit": "2.2.5",
"axios": "1.7.4",
"axios": "^1.7.4",
"chart.js": "^4.4.3",
"dayjs": "1.11.11",
"joi": "17.13.1",
+9 -5
View File
@@ -7,7 +7,7 @@ import Login from "./Pages/Auth/Login";
import Register from "./Pages/Auth/Register/Register";
import HomeLayout from "./Layouts/HomeLayout";
import Account from "./Pages/Account";
import Monitors from "./Pages/Monitors";
import Monitors from "./Pages/Monitors/Home";
import CreateMonitor from "./Pages/Monitors/CreateMonitor";
import Incidents from "./Pages/Incidents";
import Status from "./Pages/Status";
@@ -21,6 +21,7 @@ import ProtectedRoute from "./Components/ProtectedRoute";
import Details from "./Pages/Monitors/Details";
import Maintenance from "./Pages/Maintenance";
import withAdminCheck from "./HOC/withAdminCheck";
import withAdminProp from "./HOC/withAdminProp";
import Configure from "./Pages/Monitors/Configure";
import PageSpeed from "./Pages/PageSpeed";
import CreatePageSpeed from "./Pages/PageSpeed/CreatePageSpeed";
@@ -30,6 +31,9 @@ import PageSpeedConfigure from "./Pages/PageSpeed/Configure";
function App() {
const AdminCheckedRegister = withAdminCheck(Register);
const MonitorsWithAdminProp = withAdminProp(Monitors);
const DetailsWithAdminProp = withAdminProp(Details);
const PageSpeedWithAdminProp = withAdminProp(PageSpeed);
return (
<>
<Routes>
@@ -37,11 +41,11 @@ function App() {
<Route
exact
path="/"
element={<ProtectedRoute Component={Monitors} />}
element={<ProtectedRoute Component={MonitorsWithAdminProp} />}
/>
<Route
path="/monitors"
element={<ProtectedRoute Component={Monitors} />}
element={<ProtectedRoute Component={MonitorsWithAdminProp} />}
/>
<Route
path="/monitors/create"
@@ -49,7 +53,7 @@ function App() {
/>
<Route
path="/monitors/:monitorId/"
element={<ProtectedRoute Component={Details} />}
element={<ProtectedRoute Component={DetailsWithAdminProp} />}
/>
<Route
path="/monitors/configure/:monitorId/"
@@ -94,7 +98,7 @@ function App() {
/>
<Route
path="pagespeed"
element={<ProtectedRoute Component={PageSpeed} />}
element={<ProtectedRoute Component={PageSpeedWithAdminProp} />}
/>
<Route
path="pagespeed/create"
+11 -8
View File
@@ -19,7 +19,7 @@ import "./index.css";
* @returns {JSX.Element} The rendered fallback UI.
*/
const Fallback = ({ title, checks, link = "/" }) => {
const Fallback = ({ title, checks, link = "/", isAdmin }) => {
const theme = useTheme();
const navigate = useNavigate();
@@ -40,7 +40,7 @@ const Fallback = ({ title, checks, link = "/" }) => {
marginY={theme.spacing(6)}
color={theme.palette.text.secondary}
>
A {title} is used to:
A {title} monitor is used to:
</Typography>
{checks.map((check, index) => (
<Check
@@ -50,12 +50,14 @@ const Fallback = ({ title, checks, link = "/" }) => {
/>
))}
</Stack>
<Button
level="primary"
label={`Let's create your ${title}`}
sx={{ alignSelf: "center" }}
onClick={() => navigate(link)}
/>
{isAdmin && (
<Button
level="primary"
label={`Let's create your ${title}`}
sx={{ alignSelf: "center" }}
onClick={() => navigate(link)}
/>
)}
</Stack>
);
};
@@ -64,6 +66,7 @@ Fallback.propTypes = {
title: PropTypes.string.isRequired,
checks: PropTypes.arrayOf(PropTypes.string).isRequired,
link: PropTypes.string,
isAdmin: PropTypes.bool,
};
export default Fallback;
+8 -1
View File
@@ -199,7 +199,14 @@ const Field = forwardRef(
Field.displayName = "Field";
Field.propTypes = {
type: PropTypes.oneOf(["text", "password", "url", "email", "description"]),
type: PropTypes.oneOf([
"text",
"password",
"url",
"email",
"description",
"number",
]),
id: PropTypes.string.isRequired,
label: PropTypes.string,
https: PropTypes.bool,
+2 -2
View File
@@ -339,7 +339,7 @@ function Sidebar() {
if (
child.name === "Team" &&
authState.user?.role &&
!authState.user.role.includes("admin")
!authState.user.role.includes("superadmin")
) {
return null;
}
@@ -399,7 +399,7 @@ function Sidebar() {
if (
child.name === "Team" &&
authState.user?.role &&
!authState.user.role.includes("admin")
!authState.user.role.includes("superadmin")
) {
return null;
}
@@ -32,6 +32,12 @@ import Select from "../../Inputs/Select";
*/
const TeamPanel = () => {
const roleMap = {
superadmin: "Super admin",
admin: "Admin",
user: "Team member",
};
const theme = useTheme();
const { authToken, user } = useSelector((state) => state.auth);
@@ -78,6 +84,7 @@ const TeamPanel = () => {
// { id: 4, name: "ACTION" },
],
rows: team?.map((member, idx) => {
const roles = member.role.map((role) => roleMap[role]).join(",");
return {
id: member._id,
data: [
@@ -100,7 +107,7 @@ const TeamPanel = () => {
{
// TODO - Add select dropdown
id: idx + 2,
data: member.role.includes("admin") ? "Administrator" : "Member",
data: roles,
},
// FEATURE STILL TO BE IMPLEMENTED
// {
@@ -28,12 +28,12 @@ export const createPageSpeed = createAsyncThunk(
}
);
export const getPageSpeedByUserId = createAsyncThunk(
"pageSpeedMonitors/getPageSpeedByUserId",
export const getPageSpeedByTeamId = createAsyncThunk(
"pageSpeedMonitors/getPageSpeedByTeamId",
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await networkService.getMonitorsByUserId(
const res = await networkService.getMonitorsByTeamId(
token,
user._id,
25,
@@ -127,15 +127,15 @@ const pageSpeedMonitorSlice = createSlice({
// Monitors by userId
// *****************************************************
.addCase(getPageSpeedByUserId.pending, (state) => {
.addCase(getPageSpeedByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(getPageSpeedByUserId.fulfilled, (state, action) => {
.addCase(getPageSpeedByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
state.monitors = action.payload.data;
})
.addCase(getPageSpeedByUserId.rejected, (state, action) => {
.addCase(getPageSpeedByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
@@ -28,14 +28,14 @@ export const createUptimeMonitor = createAsyncThunk(
}
);
export const getUptimeMonitorsByUserId = createAsyncThunk(
"montiors/getMonitorsByUserId",
export const getUptimeMonitorsByTeamId = createAsyncThunk(
"montiors/getMonitorsByTeamId",
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await networkService.getMonitorsByUserId(
const res = await networkService.getMonitorsByTeamId(
token,
user._id,
user.teamId,
25,
["http", "ping"],
null,
@@ -126,15 +126,15 @@ const uptimeMonitorsSlice = createSlice({
// Monitors by userId
// *****************************************************
.addCase(getUptimeMonitorsByUserId.pending, (state) => {
.addCase(getUptimeMonitorsByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(getUptimeMonitorsByUserId.fulfilled, (state, action) => {
.addCase(getUptimeMonitorsByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
state.monitors = action.payload.data;
})
.addCase(getUptimeMonitorsByUserId.rejected, (state, action) => {
.addCase(getUptimeMonitorsByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
+2 -2
View File
@@ -10,7 +10,7 @@ const withAdminCheck = (WrappedComponent) => {
useEffect(() => {
networkService
.doesAdminExist()
.doesSuperAdminExist()
.then((response) => {
if (response.data.data === true) {
navigate("/login");
@@ -20,7 +20,7 @@ const withAdminCheck = (WrappedComponent) => {
logger.error(error);
});
}, [navigate]);
return <WrappedComponent {...props} isAdmin={true} />;
return <WrappedComponent {...props} isSuperAdmin={true} />;
};
const wrappedComponentName =
WrappedComponent.displayName || WrappedComponent.name || "Component";
+20
View File
@@ -0,0 +1,20 @@
import { useSelector } from "react-redux";
const withAdminProp = (WrappedComponent) => {
const WithAdminProp = (props) => {
const { user } = useSelector((state) => state.auth);
const isAdmin =
(user?.role?.includes("admin") ?? false) ||
(user?.role?.includes("superadmin") ?? false);
return <WrappedComponent {...props} isAdmin={isAdmin} />;
};
const wrappedComponentName =
WrappedComponent.displayName || WrappedComponent.name || "Component";
WithAdminProp.displayName = `WithAdminProp(${wrappedComponentName})`;
return WithAdminProp;
};
export default withAdminProp;
+6 -2
View File
@@ -25,7 +25,11 @@ const Account = ({ open = "profile" }) => {
let tabList = ["Profile", "Password", "Team"];
const { user } = useSelector((state) => state.auth);
if (!user.role.includes("admin")) tabList = ["Profile", "Password"];
const requiredRoles = ["superadmin", "admin"];
const hideTeams = !requiredRoles.some((role) => user.role.includes(role));
if (hideTeams) {
tabList = ["Profile", "Password"];
}
return (
<Box
@@ -71,7 +75,7 @@ const Account = ({ open = "profile" }) => {
</Box>
<ProfilePanel />
<PasswordPanel />
{user.role.includes("admin") && <TeamPanel />}
{!hideTeams && <TeamPanel />}
</TabContext>
</Box>
);
+1 -1
View File
@@ -321,7 +321,7 @@ const Login = () => {
return;
}
networkService
.doesAdminExist()
.doesSuperAdminExist()
.then((response) => {
if (response.data.data === false) {
navigate("/register");
+21 -13
View File
@@ -23,11 +23,11 @@ import { logger } from "../../../Utils/Logger";
* Displays the initial landing page.
*
* @param {Object} props
* @param {boolean} props.isAdmin - Whether the user is creating and admin account
* @param {boolean} props.isSuperAdmin - Whether the user is creating and admin account
* @param {Function} props.onContinue - Callback function to handle "Continue with Email" button click.
* @returns {JSX.Element}
*/
const LandingPage = ({ isAdmin, onSignup }) => {
const LandingPage = ({ isSuperAdmin, onSignup }) => {
const theme = useTheme();
return (
@@ -40,7 +40,8 @@ const LandingPage = ({ isAdmin, onSignup }) => {
<Box>
<Typography component="h1">Sign Up</Typography>
<Typography>
Create your {isAdmin ? "admin " : ""}account to get started.
Create your {isSuperAdmin ? "Super admin " : ""}account to get
started.
</Typography>
</Box>
<Box width="100%">
@@ -103,7 +104,7 @@ const LandingPage = ({ isAdmin, onSignup }) => {
};
LandingPage.propTypes = {
isAdmin: PropTypes.bool,
isSuperAdmin: PropTypes.bool,
onSignup: PropTypes.func,
};
@@ -476,7 +477,7 @@ StepThree.propTypes = {
onBack: PropTypes.func,
};
const Register = ({ isAdmin }) => {
const Register = ({ isSuperAdmin }) => {
const dispatch = useDispatch();
const navigate = useNavigate();
const { token } = useParams();
@@ -497,6 +498,7 @@ const Register = ({ isAdmin }) => {
password: "",
confirm: "",
role: [],
teamId: "",
});
const [errors, setErrors] = useState({});
const [step, setStep] = useState(0);
@@ -506,15 +508,16 @@ const Register = ({ isAdmin }) => {
if (token !== undefined) {
try {
const res = await networkService.verifyInvitationToken(token);
const { role, email } = res.data.data;
setForm({ ...form, email, role });
const invite = res.data.data;
const { role, email, teamId } = invite;
setForm({ ...form, email, role, teamId });
} catch (error) {
logger.error(error);
navigate("/register", { replace: true });
}
}
};
fetchInvite();
}, [token, form]);
}, []);
/**
* Validates the form data against the validation schema.
@@ -547,7 +550,6 @@ const Register = ({ isAdmin }) => {
const handleStepOne = async (e) => {
e.preventDefault();
let error = validateForm({
firstName: form.firstName,
lastName: form.lastName,
@@ -579,7 +581,10 @@ const Register = ({ isAdmin }) => {
const handleStepThree = async (e) => {
e.preventDefault();
let registerForm = { ...form, role: isAdmin ? ["admin"] : form.role };
let registerForm = {
...form,
role: isSuperAdmin ? ["superadmin"] : form.role,
};
let error = validateForm(registerForm, {
context: { password: form.password },
});
@@ -684,7 +689,10 @@ const Register = ({ isAdmin }) => {
}}
>
{step === 0 ? (
<LandingPage isAdmin={isAdmin} onSignup={() => setStep(1)} />
<LandingPage
isSuperAdmin={isSuperAdmin}
onSignup={() => setStep(1)}
/>
) : step === 1 ? (
<StepOne
form={form}
@@ -732,6 +740,6 @@ const Register = ({ isAdmin }) => {
);
};
Register.propTypes = {
isAdmin: PropTypes.bool,
isSuperAdmin: PropTypes.bool,
};
export default Register;
@@ -46,9 +46,9 @@ const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
try {
let res;
if (selectedMonitor === "0") {
res = await networkService.getChecksByUser(
res = await networkService.getChecksByTeam(
authToken,
user._id,
user.teamId,
"desc",
null,
null,
+2 -3
View File
@@ -48,16 +48,15 @@ const Incidents = () => {
useEffect(() => {
const fetchMonitors = async () => {
setLoading(true);
const res = await networkService.getMonitorsByUserId(
const res = await networkService.getMonitorsByTeamId(
authState.authToken,
authState.user._id,
authState.user.teamId,
1,
null,
null,
null,
null
);
// Reduce to a lookup object for 0(1) lookup
if (res.data && res.data.data.length > 0) {
const monitorLookup = res.data.data.reduce((acc, monitor) => {
@@ -23,7 +23,7 @@
}
.duration-config .field-text .MuiTextField-root,
.duration-config .field {
.maintenance-options .duration-config .field {
width: 70px;
min-width: 70px;
max-width: 70px;
@@ -10,6 +10,9 @@ import dayjs from "dayjs";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker";
import Field from "../../../Components/Inputs/Field";
import { maintenanceWindowValidation } from "../../../Validation/validation";
import { logger } from "../../../Utils/Logger";
import { createToast } from "../../../Utils/toastUtils";
const directory = {
title: "Create a maintenance window",
@@ -59,9 +62,10 @@ const CreateNewMaintenanceWindow = () => {
startTime: dayjs(),
duration: "60",
unit: "minutes",
friendlyName: "",
displayName: "",
AddMonitors: "",
});
const [errors, setErrors] = useState({});
const handleChange = (event, name) => {
const { value } = event.target;
@@ -71,6 +75,41 @@ const CreateNewMaintenanceWindow = () => {
}));
};
const handleSubmit = async () => {
const data = {
repeat: values.repeat,
date: values.date.format("YYYY-MM-DD"),
startTime: values.startTime.format("HH:mm"),
duration: values.duration,
unit: values.unit,
displayName: values.displayName,
addMonitors: values.AddMonitors,
};
const { error } = maintenanceWindowValidation.validate(data, {
abortEarly: false,
});
logger.log("error: ", error);
if (!error || error.details.length === 0) {
setErrors({});
} else {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({
body:
error.details && error.details.length > 0
? error.details[0].message
: "Error validating data",
});
logger.error("Validation errors:", error.details);
}
logger.log("Submitting data: ", data);
};
const configOptions = [
{
title: "Repeat",
@@ -126,6 +165,8 @@ const CreateNewMaintenanceWindow = () => {
placeholder="60"
onChange={(e) => handleChange(e, "duration")}
value={values.duration}
error={errors.duration}
type="number"
/>
<Select
onChange={(e) => handleChange(e, "unit")}
@@ -137,13 +178,14 @@ const CreateNewMaintenanceWindow = () => {
),
},
{
title: "Friendly name",
title: "Display name",
component: (
<Field
id="friendly-name"
id="display-name"
placeholder="Maintanence at __ : __ for ___ minutes"
value={values.friendlyName}
onChange={(e) => handleChange(e, "friendlyName")}
value={values.displayName}
onChange={(e) => handleChange(e, "displayName")}
error={errors.displayName}
/>
),
},
@@ -160,6 +202,7 @@ const CreateNewMaintenanceWindow = () => {
placeholder="Start typing to search for current monitors"
value={values.AddMonitors}
onChange={(e) => handleChange(e, "AddMonitors")}
error={errors.addMonitors}
/>
<Typography
sx={{
@@ -246,6 +289,7 @@ const CreateNewMaintenanceWindow = () => {
}}
level="primary"
label="Create"
onClick={handleSubmit}
/>
</Stack>
</Stack>
@@ -8,7 +8,7 @@ import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import {
updateUptimeMonitor,
getUptimeMonitorsByUserId,
getUptimeMonitorsByTeamId,
deleteUptimeMonitor,
} from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import Button from "../../../Components/Button";
@@ -177,7 +177,7 @@ const Configure = () => {
);
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor updated successfully!" });
dispatch(getUptimeMonitorsByUserId(authToken));
dispatch(getUptimeMonitorsByTeamId(authToken));
} else {
createToast({ body: "Failed to update monitor." });
}
@@ -314,7 +314,7 @@ const Configure = () => {
<Field
type="text"
id="monitor-name"
label="Friendly name"
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor?.name || ""}
@@ -118,6 +118,7 @@ const CreateMonitor = () => {
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
notifications: monitor.notifications,
};
@@ -193,7 +194,7 @@ const CreateMonitor = () => {
<Field
type="text"
id="monitor-name"
label="Friendly name"
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor.name}
+29 -24
View File
@@ -110,7 +110,7 @@ const SkeletonLayout = () => {
* Details page component displaying monitor details and related information.
* @component
*/
const DetailsPage = () => {
const DetailsPage = ({ isAdmin }) => {
const [monitor, setMonitor] = useState({});
const { monitorId } = useParams();
const { authToken } = useSelector((state) => state.auth);
@@ -196,29 +196,31 @@ const DetailsPage = () => {
Checking every {formatDurationRounded(monitor?.interval)}.
</Typography>
</Box>
<Button
level="tertiary"
label="Configure"
animate="rotate90"
img={
<SettingsIcon
style={{
minWidth: theme.gap.mlplus,
minHeight: theme.gap.mlplus,
}}
/>
}
onClick={() => navigate(`/monitors/configure/${monitorId}`)}
sx={{
ml: "auto",
alignSelf: "flex-end",
backgroundColor: "#f4f4f4",
px: theme.gap.medium,
"& svg": {
mr: "6px",
},
}}
/>
{isAdmin && (
<Button
level="tertiary"
label="Configure"
animate="rotate90"
img={
<SettingsIcon
style={{
minWidth: theme.gap.mlplus,
minHeight: theme.gap.mlplus,
}}
/>
}
onClick={() => navigate(`/monitors/configure/${monitorId}`)}
sx={{
ml: "auto",
alignSelf: "flex-end",
backgroundColor: "#f4f4f4",
px: theme.gap.medium,
"& svg": {
mr: "6px",
},
}}
/>
)}
</Stack>
<Stack
direction="row"
@@ -339,4 +341,7 @@ const DetailsPage = () => {
);
};
DetailsPage.propTypes = {
isAdmin: PropTypes.bool,
};
export default DetailsPage;
@@ -0,0 +1,105 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import Arrow from "../../../assets/icons/top-right-arrow.svg?react";
import background from "../../../assets/Images/background-grid.svg";
import ClockSnooze from "../../../assets/icons/clock-snooze.svg?react";
const StatusBox = ({ title, value }) => {
const theme = useTheme();
let sharedStyles = { position: "absolute", right: 8, opacity: 0.5 };
let color;
let icon;
if (title === "up") {
color = theme.pie.green.stroke;
icon = (
<Box sx={{ ...sharedStyles, top: 8 }}>
<Arrow />
</Box>
);
} else if (title === "down") {
color = theme.pie.red.stroke;
icon = (
<Box sx={{ ...sharedStyles, transform: "rotate(180deg)", top: 5 }}>
<Arrow />
</Box>
);
} else if (title === "paused") {
color = theme.pie.yellow.stroke;
icon = (
<Box sx={{ ...sharedStyles, top: 12, right: 12 }}>
<ClockSnooze />
</Box>
);
}
return (
<Box
position="relative"
flex={1}
border={1}
borderColor={theme.palette.otherColors.graishWhite}
borderRadius={`${theme.shape.borderRadius}px`}
backgroundColor={theme.palette.otherColors.white}
px={theme.gap.large}
py={theme.gap.ml}
overflow="hidden"
sx={{
"&:hover": {
backgroundColor: "#f9fafb",
},
"&:after": {
position: "absolute",
content: `""`,
backgroundImage: `url(${background})`,
width: "400px",
height: "200px",
top: "-10%",
left: "5%",
zIndex: 10000,
pointerEvents: "none",
},
}}
>
<Box
textTransform="uppercase"
fontSize={15}
letterSpacing={0.5}
color={theme.palette.otherColors.bluishGray}
mb={theme.gap.ml}
sx={{ opacity: 0.6 }}
>
{title}
</Box>
{icon}
<Stack
direction="row"
alignItems="flex-start"
fontSize={36}
fontWeight={600}
color={color}
gap="2px"
>
{value}
<Typography
component="span"
fontSize={20}
fontWeight={300}
color={theme.palette.otherColors.bluishGray}
sx={{ opacity: 0.3 }}
>
#
</Typography>
</Stack>
</Box>
);
};
StatusBox.propTypes = {
title: PropTypes.oneOf(["up", "down", "paused"]).isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
};
export default StatusBox;
@@ -0,0 +1,199 @@
import { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { createToast } from "../../../Utils/toastUtils";
import {
IconButton,
Menu,
MenuItem,
Modal,
Stack,
Typography,
} from "@mui/material";
import {
deleteUptimeMonitor,
getUptimeMonitorsByTeamId,
} from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import Settings from "../../../assets/icons/settings-bold.svg?react";
import { useTheme } from "@emotion/react";
import Button from "../../../Components/Button";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
const ActionsMenu = ({ monitor, isAdmin }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [actions, setActions] = useState({});
const [isOpen, setIsOpen] = useState(false);
const dispatch = useDispatch();
const theme = useTheme();
const authState = useSelector((state) => state.auth);
const handleRemove = async (event) => {
event.preventDefault();
event.stopPropagation();
let monitor = { _id: actions.id };
const action = await dispatch(
deleteUptimeMonitor({ authToken: authState.authToken, monitor })
);
if (action.meta.requestStatus === "fulfilled") {
setIsOpen(false); // close modal
dispatch(getUptimeMonitorsByTeamId(authState.authToken));
createToast({ body: "Monitor deleted successfully." });
} else {
createToast({ body: "Failed to delete monitor." });
}
};
const openMenu = (event, id, url) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
setActions({ id: id, url: url });
};
const openRemove = (e) => {
closeMenu(e);
setIsOpen(true);
};
const closeMenu = (e) => {
e.stopPropagation();
setAnchorEl(null);
};
const navigate = useNavigate();
return (
<>
<IconButton
aria-label="monitor actions"
onClick={(event) => {
event.stopPropagation();
openMenu(
event,
monitor._id,
monitor.type === "ping" ? null : monitor.url
);
}}
sx={{
"&:focus": {
outline: "none",
},
}}
>
<Settings />
</IconButton>
<Menu
className="actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={(e) => closeMenu(e)}
>
{actions.url !== null ? (
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
window.open(actions.url, "_blank", "noreferrer");
}}
>
Open site
</MenuItem>
) : (
""
)}
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/${actions.id}`);
}}
>
Details
</MenuItem>
{/* TODO - pass monitor id to Incidents page */}
<MenuItem disabled>Incidents</MenuItem>
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/configure/${actions.id}`);
}}
>
Configure
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
openRemove(e);
}}
>
Remove
</MenuItem>
)}
</Menu>
<Modal
aria-labelledby="modal-delete-monitor"
aria-describedby="delete-monitor-confirmation"
open={isOpen}
onClose={() => setIsOpen(false)}
disablePortal
>
<Stack
gap={theme.gap.xs}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: "white",
border: "solid 1px #f2f2f2",
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: 24,
p: "30px",
"&:focus": {
outline: "none",
},
}}
>
<Typography id="modal-delete-monitor" component="h2">
Do you really want to delete this monitor?
</Typography>
<Typography id="delete-monitor-confirmation">
Once deleted, this monitor cannot be retrieved.
</Typography>
<Stack
direction="row"
gap={theme.gap.small}
mt={theme.gap.large}
justifyContent="flex-end"
>
<Button
level="tertiary"
label="Cancel"
onClick={() => setIsOpen(false)}
/>
<Button
level="error"
label="Delete"
onClick={(e) => handleRemove(e)}
/>
</Stack>
</Stack>
</Modal>
</>
);
};
ActionsMenu.propTypes = {
monitor: PropTypes.shape({
_id: PropTypes.string,
url: PropTypes.string,
type: PropTypes.string,
}).isRequired,
isAdmin: PropTypes.bool,
};
export default ActionsMenu;
@@ -0,0 +1,43 @@
import { Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import Button from "../../../Components/Button";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
const Fallback = ({ isAdmin }) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<Stack
alignItems="center"
backgroundColor={theme.palette.otherColors.white}
p={theme.gap.xxl}
gap={theme.gap.xs}
border={1}
borderRadius={`${theme.shape.borderRadius}px`}
borderColor={theme.palette.otherColors.graishWhite}
>
<Typography component="h2">No monitors found</Typography>
<Typography>
It looks like you don’t have any monitors set up yet.
</Typography>
{isAdmin && (
<Button
level="primary"
label="Create your first monitor"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ mt: theme.gap.large }}
/>
)}
</Stack>
);
};
Fallback.propTypes = {
isAdmin: PropTypes.bool,
};
export default Fallback;
+33
View File
@@ -0,0 +1,33 @@
import { Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
/**
* Host component.
* This subcomponent receives a params object and displays the host details.
*
* @component
* @param {Object} params - An object containing the following properties:
* @param {string} params.title - The name of the host.
* @param {string} params.percentageColor - The color of the percentage text.
* @param {number} params.percentage - The percentage to display.
* @returns {React.ElementType} Returns a div element with the host details.
*/
const Host = ({ params }) => {
return (
<Stack direction="row" alignItems="baseline" className="host">
{params.title}
<Typography component="span" sx={{ color: params.percentageColor }}>
{params.percentage}%
</Typography>
</Stack>
);
};
Host.propTypes = {
params: PropTypes.shape({
title: PropTypes.string,
percentageColor: PropTypes.string,
percentage: PropTypes.number,
}).isRequired,
};
export default Host;
File renamed without changes.
+110
View File
@@ -0,0 +1,110 @@
import "./index.css";
import { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { getUptimeMonitorsByTeamId } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { useNavigate } from "react-router-dom";
import Button from "../../../Components/Button";
import { useTheme } from "@emotion/react";
import BasicTable from "../../../Components/BasicTable";
import { Box, Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
import SkeletonLayout from "./skeleton";
import Fallback from "./fallback";
import StatusBox from "./StatusBox";
import { buildData } from "./monitorData";
const Monitors = ({ isAdmin }) => {
const theme = useTheme();
const navigate = useNavigate();
const monitorState = useSelector((state) => state.uptimeMonitors);
const authState = useSelector((state) => state.auth);
const dispatch = useDispatch({});
useEffect(() => {
dispatch(getUptimeMonitorsByTeamId(authState.authToken));
}, [authState.authToken, dispatch]);
const up = monitorState.monitors.reduce((acc, cur) => {
return cur.status === true ? acc + 1 : acc;
}, 0);
const down = monitorState.monitors.length - up;
const data = buildData(monitorState.monitors, isAdmin, navigate);
let loading = monitorState.isLoading && monitorState.monitors.length === 0;
return (
<Stack className="monitors" gap={theme.gap.large}>
{loading ? (
<SkeletonLayout />
) : (
<>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Typography
component="h1"
sx={{ lineHeight: 1, alignSelf: "flex-end" }}
>
Hello, {authState.user.firstName}
</Typography>
{monitorState.monitors?.length !== 0 && (
<Button
level="primary"
label="Create monitor"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ fontWeight: 500 }}
/>
)}
</Stack>
{monitorState.monitors?.length === 0 && (
<Fallback isAdmin={isAdmin} />
)}
{monitorState.monitors?.length !== 0 && (
<>
<Stack
gap={theme.gap.large}
direction="row"
justifyContent="space-between"
>
<StatusBox title="up" value={up} />
<StatusBox title="down" value={down} />
<StatusBox title="paused" value={0} />
</Stack>
<Box
flex={1}
p={theme.gap.lgplus}
border={1}
borderColor={theme.palette.otherColors.graishWhite}
backgroundColor={theme.palette.otherColors.white}
sx={{
borderRadius: `${theme.shape.borderRadius}px`,
}}
>
<Stack direction="row" alignItems="center" mb={theme.gap.large}>
<Typography component="h2">Current monitors</Typography>
<Box className="current-monitors-counter">
{monitorState.monitors.length}
</Box>
{/* TODO - add search bar */}
</Stack>
<BasicTable data={data} paginated={true} table={"monitors"} />
</Box>
</>
)}
</>
)}
</Stack>
);
};
Monitors.propTypes = {
isAdmin: PropTypes.bool,
};
export default Monitors;
@@ -0,0 +1,77 @@
import Host from "./host";
import ActionsMenu from "./actionsMenu";
import BarChart from "../../../Components/Charts/BarChart";
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
import { StatusLabel } from "../../../Components/Label";
import { Box } from "@mui/material";
const data = {
cols: [
{ id: 1, name: "Host" },
{
id: 2,
name: (
<Box width="max-content">
Status
<span>
<ArrowDownwardRoundedIcon />
</span>
</Box>
),
},
{ id: 3, name: "Response Time" },
{ id: 4, name: "Type" },
{ id: 5, name: "Actions" },
],
rows: [],
};
export const buildData = (monitors, isAdmin, navigate) => {
data.rows = monitors.map((monitor, idx) => {
const params = {
title: monitor.name,
percentage: 100,
percentageColor:
monitor.status === true
? "var(--env-var-color-17)"
: "var(--env-var-color-19)",
status: monitor.status === true ? "up" : "down",
};
// Reverse checks so latest check is on the right
const reversedChecks = monitor.checks.slice().reverse();
return {
id: monitor._id,
// disabled for now
handleClick: () => {
navigate(`/monitors/${monitor._id}`);
},
data: [
{ id: idx, data: <Host params={params} /> },
{
id: idx + 1,
data: (
<StatusLabel
status={params.status}
text={params.status}
customStyles={{ textTransform: "capitalize" }}
/>
),
},
{ id: idx + 2, data: <BarChart checks={reversedChecks} /> },
{
id: idx + 3,
data: (
<span style={{ textTransform: "uppercase" }}>{monitor.type}</span>
),
},
{
id: idx + 4,
data: <ActionsMenu monitor={monitor} isAdmin={isAdmin} />,
},
],
};
});
return data;
};
@@ -0,0 +1,36 @@
import { Skeleton, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Skeleton variant="rounded" width="50%" height={36} />
<Skeleton variant="rounded" width="15%" height={36} />
</Stack>
<Stack
gap={theme.gap.large}
direction="row"
justifyContent="space-between"
>
<Skeleton variant="rounded" width="100%" height={100} />
<Skeleton variant="rounded" width="100%" height={100} />
<Skeleton variant="rounded" width="100%" height={100} />
</Stack>
<Stack gap={theme.gap.large} p={theme.gap.xl} backgroundColor="#f9fafb">
<Skeleton variant="rounded" width="50%" height={25} />
<Skeleton variant="rounded" width="100%" height={300} />
<Skeleton variant="rounded" width="100%" height={100} />
</Stack>
</>
);
};
export default SkeletonLayout;
-623
View File
@@ -1,623 +0,0 @@
import { useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import {
deleteUptimeMonitor,
getUptimeMonitorsByUserId,
} from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { StatusLabel } from "../../Components/Label";
import { createToast } from "../../Utils/toastUtils";
import {
Box,
IconButton,
Menu,
MenuItem,
Modal,
Skeleton,
Stack,
Typography,
} from "@mui/material";
import BasicTable from "../../Components/BasicTable";
import Button from "../../Components/Button";
import Settings from "../../assets/icons/settings-bold.svg?react";
import PropTypes from "prop-types";
import BarChart from "../../Components/Charts/BarChart";
import Breadcrumbs from "../../Components/Breadcrumbs";
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
import background from "../../assets/Images/background-grid.svg";
import Arrow from "../../assets/icons/top-right-arrow.svg?react";
import ClockSnooze from "../../assets/icons/clock-snooze.svg?react";
import "./index.css";
const ActionsMenu = ({ monitor }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [actions, setActions] = useState({});
const [isOpen, setIsOpen] = useState(false);
const dispatch = useDispatch();
const theme = useTheme();
const authState = useSelector((state) => state.auth);
const handleRemove = async (event) => {
event.preventDefault();
event.stopPropagation();
let monitor = { _id: actions.id };
const action = await dispatch(
deleteUptimeMonitor({ authToken: authState.authToken, monitor })
);
if (action.meta.requestStatus === "fulfilled") {
setIsOpen(false); // close modal
dispatch(getUptimeMonitorsByUserId(authState.authToken));
createToast({ body: "Monitor deleted successfully." });
} else {
createToast({ body: "Failed to delete monitor." });
}
};
const openMenu = (event, id, url) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
setActions({ id: id, url: url });
};
const openRemove = (e) => {
closeMenu(e);
setIsOpen(true);
};
const closeMenu = (e) => {
e.stopPropagation();
setAnchorEl(null);
};
const navigate = useNavigate();
return (
<>
<IconButton
aria-label="monitor actions"
onClick={(event) => {
event.stopPropagation();
openMenu(
event,
monitor._id,
monitor.type === "ping" ? null : monitor.url
);
}}
sx={{
"&:focus": {
outline: "none",
},
}}
>
<Settings />
</IconButton>
<Menu
className="actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={(e) => closeMenu(e)}
>
{actions.url !== null ? (
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
window.open(actions.url, "_blank", "noreferrer");
}}
>
Open site
</MenuItem>
) : (
""
)}
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/${actions.id}`);
}}
>
Details
</MenuItem>
{/* TODO - pass monitor id to Incidents page */}
<MenuItem disabled>Incidents</MenuItem>
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/configure/${actions.id}`);
}}
>
Configure
</MenuItem>
<MenuItem
onClick={(e) => {
e.stopPropagation();
openRemove(e);
}}
>
Remove
</MenuItem>
</Menu>
<Modal
aria-labelledby="modal-delete-monitor"
aria-describedby="delete-monitor-confirmation"
open={isOpen}
onClose={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
disablePortal
>
<Stack
gap={theme.gap.xs}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: "white",
border: "solid 1px #f2f2f2",
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: 24,
p: "30px",
"&:focus": {
outline: "none",
},
}}
>
<Typography id="modal-delete-monitor" component="h2">
Do you really want to delete this monitor?
</Typography>
<Typography id="delete-monitor-confirmation">
Once deleted, this monitor cannot be retrieved.
</Typography>
<Stack
direction="row"
gap={theme.gap.small}
mt={theme.gap.large}
justifyContent="flex-end"
>
<Button
level="tertiary"
label="Cancel"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
/>
<Button
level="error"
label="Delete"
onClick={(e) => handleRemove(e)}
/>
</Stack>
</Stack>
</Modal>
</>
);
};
ActionsMenu.propTypes = {
monitor: PropTypes.shape({
_id: PropTypes.string,
url: PropTypes.string,
type: PropTypes.string,
}).isRequired,
};
/**
* Host component.
* This subcomponent receives a params object and displays the host details.
*
* @component
* @param {Object} params - An object containing the following properties:
* @param {string} params.title - The name of the host.
* @param {string} params.percentageColor - The color of the percentage text.
* @param {number} params.percentage - The percentage to display.
* @returns {React.ElementType} Returns a div element with the host details.
*/
const Host = ({ params }) => {
return (
<Box className="host">
<Box
display="inline-block"
position="relative"
sx={{
fontWeight: 500,
"&:before": {
position: "absolute",
content: `""`,
width: "4px",
height: "4px",
borderRadius: "50%",
backgroundColor: "gray",
opacity: 0.8,
right: "-10px",
top: "42%",
},
}}
>
{params.title}
</Box>
<Typography
component="span"
sx={{
color: params.percentageColor,
fontWeight: 500,
ml: "15px",
}}
>
{params.percentage}%
</Typography>
<Box sx={{ opacity: 0.6 }}>{params.url}</Box>
</Box>
);
};
Host.propTypes = {
params: PropTypes.shape({
title: PropTypes.string,
percentageColor: PropTypes.string,
percentage: PropTypes.number,
}).isRequired,
};
/**
* StatusBox component displays a status box with a title and value.
* The icon and color change based on the status title.
*
* @param {Object} props
* @param {string} props.title - The status title, which determines the icon and color.
* @param {number|string} props.value - The value to be displayed inside the box.
* @returns {JSX.Element} The rendered StatusBox component.
*/
const StatusBox = ({ title, value }) => {
const theme = useTheme();
let sharedStyles = { position: "absolute", right: 8, opacity: 0.5 };
let color;
let icon;
if (title === "up") {
color = theme.pie.green.stroke;
icon = (
<Box sx={{ ...sharedStyles, top: 8 }}>
<Arrow />
</Box>
);
} else if (title === "down") {
color = theme.pie.red.stroke;
icon = (
<Box sx={{ ...sharedStyles, transform: "rotate(180deg)", top: 5 }}>
<Arrow />
</Box>
);
} else if (title === "paused") {
color = theme.pie.yellow.stroke;
icon = (
<Box sx={{ ...sharedStyles, top: 12, right: 12 }}>
<ClockSnooze />
</Box>
);
}
return (
<Box
position="relative"
flex={1}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
px={theme.spacing(12)}
py={theme.spacing(8)}
overflow="hidden"
sx={{
"&:hover": {
backgroundColor: "#f9fafb",
},
"&:after": {
position: "absolute",
content: `""`,
backgroundImage: `url(${background})`,
width: "400px",
height: "200px",
top: "-10%",
left: "5%",
zIndex: 10000,
pointerEvents: "none",
},
}}
>
<Box
textTransform="uppercase"
fontSize={15}
letterSpacing={0.5}
color={theme.palette.otherColors.bluishGray}
mb={theme.gap.ml}
sx={{ opacity: 0.6 }}
>
{title}
</Box>
{icon}
<Stack
direction="row"
alignItems="flex-start"
fontSize={36}
fontWeight={600}
color={color}
gap="2px"
>
{value}
<Typography
component="span"
fontSize={20}
fontWeight={300}
color={theme.palette.otherColors.bluishGray}
sx={{ opacity: 0.3 }}
>
#
</Typography>
</Stack>
</Box>
);
};
StatusBox.propTypes = {
title: PropTypes.oneOf(["up", "down", "paused"]).isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
};
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Skeleton variant="rounded" width="50%" height={36} />
<Skeleton variant="rounded" width="15%" height={36} />
</Stack>
<Stack
gap={theme.gap.large}
direction="row"
justifyContent="space-between"
>
<Skeleton variant="rounded" width="100%" height={100} />
<Skeleton variant="rounded" width="100%" height={100} />
<Skeleton variant="rounded" width="100%" height={100} />
</Stack>
<Stack gap={theme.gap.large} p={theme.gap.xl} backgroundColor="#f9fafb">
<Skeleton variant="rounded" width="50%" height={25} />
<Skeleton variant="rounded" width="100%" height={300} />
<Skeleton variant="rounded" width="100%" height={100} />
</Stack>
</>
);
};
const Monitors = () => {
const theme = useTheme();
const navigate = useNavigate();
const monitorState = useSelector((state) => state.uptimeMonitors);
const authState = useSelector((state) => state.auth);
const dispatch = useDispatch({});
useEffect(() => {
dispatch(getUptimeMonitorsByUserId(authState.authToken));
}, [authState.authToken, dispatch]);
const up = monitorState.monitors.reduce((acc, cur) => {
return cur.status === true ? acc + 1 : acc;
}, 0);
const down = monitorState.monitors.length - up;
const data = {
cols: [
{ id: 1, name: "Host" },
{
id: 2,
name: (
<Box width="max-content">
Status
<span>
<ArrowDownwardRoundedIcon />
</span>
</Box>
),
},
{ id: 3, name: "Response Time" },
{ id: 4, name: "Type" },
{ id: 5, name: "Actions" },
],
rows: [],
};
data.rows = monitorState.monitors.map((monitor, idx) => {
const params = {
url: monitor.url,
title: monitor.name,
percentage: 100,
percentageColor:
monitor.status === true ? "var(--success-color)" : "var(--error-color)",
status: monitor.status === true ? "up" : "down",
};
// Reverse checks so latest check is on the right
const reversedChecks = monitor.checks.slice().reverse();
return {
id: monitor._id,
// disabled for now
handleClick: () => {
navigate(`/monitors/${monitor._id}`);
},
data: [
{ id: idx, data: <Host params={params} /> },
{
id: idx + 1,
data: (
<StatusLabel
status={params.status}
text={params.status}
customStyles={{ textTransform: "capitalize" }}
/>
),
},
{ id: idx + 2, data: <BarChart checks={reversedChecks} /> },
{
id: idx + 3,
data: (
<span style={{ textTransform: "uppercase" }}>{monitor.type}</span>
),
},
{
id: idx + 4,
data: <ActionsMenu monitor={monitor} />,
},
],
};
});
let loading = monitorState.isLoading && monitorState.monitors.length === 0;
const now = new Date();
const hour = now.getHours();
let greeting = "";
let emoji = "";
if (hour < 12) {
greeting = "morning";
emoji = "🌅";
} else if (hour < 18) {
greeting = "afternoon";
emoji = "🌞";
} else {
greeting = "evening";
emoji = "🌙";
}
return (
<Stack className="monitors" gap={theme.gap.large}>
{loading ? (
<SkeletonLayout />
) : (
<>
<Box>
<Breadcrumbs list={[{ name: `monitors`, path: "/monitors" }]} />
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
mt={theme.gap.medium}
>
<Box>
<Typography component="h1" lineHeight={1}>
<Typography
component="span"
fontSize="inherit"
color={theme.palette.otherColors.bluishGray}
>
Good {greeting},{" "}
</Typography>
<Typography
component="span"
fontSize="inherit"
fontWeight="inherit"
>
{authState.user.firstName} {emoji}
</Typography>
</Typography>
<Typography
sx={{ opacity: 0.8 }}
lineHeight={1}
fontWeight={300}
>
Here’s an overview of your uptime monitors.
</Typography>
</Box>
{monitorState.monitors?.length !== 0 && (
<Button
level="primary"
label="Create monitor"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ fontWeight: 500, alignSelf: "flex-end" }}
/>
)}
</Stack>
</Box>
{monitorState.monitors?.length === 0 ? (
<Stack
alignItems="center"
backgroundColor={theme.palette.otherColors.white}
p={theme.gap.xxl}
gap={theme.gap.xs}
border={1}
borderRadius={`${theme.shape.borderRadius}px`}
borderColor={theme.palette.otherColors.graishWhite}
>
<Typography component="h2">No monitors found</Typography>
<Typography>
It looks like you don’t have any monitors set up yet.
</Typography>
<Button
level="primary"
label="Create your first monitor"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ mt: theme.gap.large }}
/>
</Stack>
) : (
<>
<Stack
gap={theme.gap.large}
direction="row"
justifyContent="space-between"
>
<StatusBox title="up" value={up} />
<StatusBox title="down" value={down} />
<StatusBox title="paused" value={0} />
</Stack>
<Box
flex={1}
py={theme.gap.large}
px={theme.gap.lgplus}
border={1}
borderColor={theme.palette.otherColors.graishWhite}
backgroundColor={theme.palette.otherColors.white}
sx={{
borderRadius: `${theme.shape.borderRadius}px`,
}}
>
<Stack direction="row" alignItems="center" mb={theme.gap.ml}>
<Typography component="h2" letterSpacing={-0.5}>
Actively monitoring
</Typography>
<Box className="current-monitors-counter">
{monitorState.monitors.length}
</Box>
{/* TODO - add search bar */}
</Stack>
<BasicTable data={data} paginated={true} table={"monitors"} />
</Box>
</>
)}
</>
)}
</Stack>
);
};
export default Monitors;
@@ -5,7 +5,7 @@ import { useDispatch, useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router";
import {
deletePageSpeed,
getPageSpeedByUserId,
getPageSpeedByTeamId,
updatePageSpeed,
} from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import { monitorValidation } from "../../../Validation/validation";
@@ -127,7 +127,7 @@ const PageSpeedConfigure = () => {
);
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor updated successfully!" });
dispatch(getPageSpeedByUserId(authToken));
dispatch(getPageSpeedByTeamId(authToken));
} else {
createToast({ body: "Failed to update monitor." });
}
@@ -223,7 +223,7 @@ const PageSpeedConfigure = () => {
</Stack>
<Stack gap={theme.gap.xl}>
<Stack direction="row">
<Typography component="h3">Monitor friendly name</Typography>
<Typography component="h3">Monitor display name</Typography>
<Field
type="text"
id="monitor-name"
@@ -116,7 +116,7 @@ const CreatePageSpeed = () => {
<Typography component="h1">Create pagespeed monitor</Typography>
<Stack gap={theme.gap.xl}>
<Stack direction="row">
<Typography component="h3">Monitor friendly name</Typography>
<Typography component="h3">Monitor display name</Typography>
<Field
type="text"
id="monitor-name"
+7 -3
View File
@@ -4,7 +4,7 @@ import { useTheme } from "@emotion/react";
import { formatDate, formatDurationRounded } from "../../Utils/timeUtils";
import { StatusLabel } from "../../Components/Label";
import { useDispatch, useSelector } from "react-redux";
import { getPageSpeedByUserId } from "../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import { getPageSpeedByTeamId } from "../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import PageSpeedIcon from "../../assets/icons/page-speed.svg?react";
import Fallback from "../../Components/Fallback";
import "./index.css";
@@ -127,7 +127,7 @@ const SkeletonLayout = () => {
);
};
const PageSpeed = () => {
const PageSpeed = ({ isAdmin }) => {
const theme = useTheme();
const dispatch = useDispatch();
const navigate = useNavigate();
@@ -137,7 +137,7 @@ const PageSpeed = () => {
(state) => state.pageSpeedMonitors
);
useEffect(() => {
dispatch(getPageSpeedByUserId(authToken));
dispatch(getPageSpeedByTeamId(authToken));
}, [authToken, dispatch]);
// will show skeletons only on initial load
@@ -196,10 +196,14 @@ const PageSpeed = () => {
"Give suggestions on how the page can be improved",
]}
link="/pagespeed/create"
isAdmin={isAdmin}
/>
)}
</Box>
);
};
PageSpeed.propTypes = {
isAdmin: PropTypes.bool,
};
export default PageSpeed;
+10 -10
View File
@@ -52,9 +52,9 @@ class NetworkService {
* @param {boolean} [normalize] - Whether to normalize the retrieved monitors.
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*/
async getMonitorsByUserId(
async getMonitorsByTeamId(
authToken,
userId,
teamId,
limit,
types,
status,
@@ -74,7 +74,7 @@ class NetworkService {
if (normalize) params.append("normalize", normalize);
return this.axiosInstance.get(
`/monitors/user/${userId}?${params.toString()}`,
`/monitors/team/${teamId}?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
@@ -293,8 +293,8 @@ class NetworkService {
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*
*/
async doesAdminExist() {
return this.axiosInstance.get("/auth/users/admin");
async doesSuperAdminExist() {
return this.axiosInstance.get("/auth/users/superadmin");
}
/**
@@ -327,7 +327,7 @@ class NetworkService {
*/
async requestInvitationToken(authToken, email, role) {
return this.axiosInstance.post(
`/auth/invite`,
`/invite`,
{ email, role },
{
headers: { Authorization: `Bearer ${authToken}` },
@@ -346,7 +346,7 @@ class NetworkService {
*
*/
async verifyInvitationToken(token) {
return this.axiosInstance.post(`/auth/invite/verify`, {
return this.axiosInstance.post(`/invite/verify`, {
token,
});
}
@@ -409,9 +409,9 @@ class NetworkService {
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*
*/
async getChecksByUser(
async getChecksByTeam(
authToken,
userId,
teamId,
sortOrder,
limit,
dateRange,
@@ -427,7 +427,7 @@ class NetworkService {
if (page) params.append("page", page);
if (rowsPerPage) params.append("rowsPerPage", rowsPerPage);
return this.axiosInstance.get(
`/checks/user/${userId}?${params.toString()}`,
`/checks/team/${teamId}?${params.toString()}`,
{
headers: { Authorization: `Bearer ${authToken}` },
}
+41 -1
View File
@@ -76,6 +76,7 @@ const credentials = joi.object({
return value;
}),
role: joi.array(),
teamId: joi.string().allow("").optional(),
});
const monitorValidation = joi.object({
@@ -111,4 +112,43 @@ const imageValidation = joi.object({
}),
});
export { credentials, imageValidation, monitorValidation };
const maintenanceWindowValidation = joi.object({
repeat: joi.number().valid(1, 2, 3).required().messages({
"number.base": "Repeat must be a number.",
"any.only": "Repeat must be one of [1, 2, 3].",
"any.required": "Repeat is required.",
}),
date: joi.date().required().messages({
"date.base": "Date must be a valid date.",
"any.required": "Date is required.",
}),
startTime: joi.string().required().messages({
"string.base": "Start time must be a valid time.",
"any.required": "Start time is required.",
}),
duration: joi.number().required().messages({
"number.empty": "duration is required.",
"number.base": "Duration must be a number.",
"any.required": "Duration is required.",
}),
unit: joi.string().valid("minutes", "hours", "days").required().messages({
"string.base": "Unit must be a string.",
"any.only": "Unit must be one of ['minutes', 'hours', 'days'].",
"any.required": "Unit is required.",
}),
displayName: joi.string().max(50).required().messages({
"string.empty": "Display name is required.",
"string.max": "Display name must be less than 50 characters long",
}),
addMonitors: joi.string().max(50).required().messages({
"string.empty": "Add monitors is required.",
"string.max": "Add monitors must be less than 50 characters long",
}),
});
export {
credentials,
imageValidation,
monitorValidation,
maintenanceWindowValidation,
};
+39 -24
View File
@@ -1,6 +1,6 @@
const express = require("express");
const {
registerValidation,
registrationBodyValidation,
loginValidation,
editUserParamValidation,
editUserBodyValidation,
@@ -50,7 +50,7 @@ const getTokenFromHeaders = (headers) => {
const registerController = async (req, res, next) => {
// joi validation
try {
await registerValidation.validateAsync(req.body);
await registrationBodyValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
@@ -78,7 +78,7 @@ const registerController = async (req, res, next) => {
// Create a new user
try {
const newUser = await req.db.insertUser(req, res);
const newUser = await req.db.insertUser({ ...req.body }, req.file);
logger.info(successMessages.AUTH_CREATE_USER, {
service: SERVICE_NAME,
userId: newUser._id,
@@ -90,12 +90,20 @@ const registerController = async (req, res, next) => {
const token = issueToken(userForToken);
await req.emailService.buildAndSendEmail(
"welcomeEmailTemplate",
{ name: newUser.firstName },
newUser.email,
"Welcome to Uptime Monitor"
);
req.emailService
.buildAndSendEmail(
"welcomeEmailTemplate",
{ name: newUser.firstName },
newUser.email,
"Welcome to Uptime Monitor"
)
.catch((error) => {
logger.error("Error sending welcome email", {
service: SERVICE_NAME,
error: error.message,
});
});
return res.status(200).json({
success: true,
msg: successMessages.AUTH_CREATE_USER,
@@ -226,7 +234,8 @@ const inviteController = async (req, res, next) => {
try {
// Only admins can invite
const token = getTokenFromHeaders(req.headers);
const { role, firstname } = jwt.decode(token);
const { role, firstname, teamId } = jwt.decode(token);
req.body.teamId = teamId;
try {
await inviteRoleValidation.validateAsync({ roles: role });
await inviteBodyValidation.validateAsync(req.body);
@@ -240,15 +249,22 @@ const inviteController = async (req, res, next) => {
}
const inviteToken = await req.db.requestInviteToken(req, res);
await req.emailService.buildAndSendEmail(
"employeeActivationTemplate",
{
name: firstname,
link: `${process.env.CLIENT_HOST}/register/${inviteToken.token}`,
},
req.body.email,
"Welcome to Uptime Monitor"
);
req.emailService
.buildAndSendEmail(
"employeeActivationTemplate",
{
name: firstname,
link: `${process.env.CLIENT_HOST}/register/${inviteToken.token}`,
},
req.body.email,
"Welcome to Uptime Monitor"
)
.catch((error) => {
logger.error("Error sending invite email", {
service: SERVICE_NAME,
error: error.message,
});
});
return res
.status(200)
@@ -274,7 +290,6 @@ const inviteVerifyController = async (req, res, next) => {
try {
const invite = await req.db.getInviteToken(req, res);
res
.status(200)
.json({ status: "success", msg: "Invite verified", data: invite });
@@ -293,13 +308,13 @@ const inviteVerifyController = async (req, res, next) => {
* @returns {Promise<Express.Response>}
*/
const checkAdminController = async (req, res) => {
const checkSuperadminController = async (req, res) => {
try {
const adminExists = await req.db.checkAdmin(req, res);
const superAdminExists = await req.db.checkSuperadmin(req, res);
return res.status(200).json({
success: true,
msg: successMessages.AUTH_ADMIN_EXISTS,
data: adminExists,
data: superAdminExists,
});
} catch (error) {
error.service = SERVICE_NAME;
@@ -508,7 +523,7 @@ module.exports = {
userEditController,
inviteController,
inviteVerifyController,
checkAdminController,
checkSuperadminController,
recoveryRequestController,
validateRecoveryTokenController,
resetPasswordController,
+7 -7
View File
@@ -3,8 +3,8 @@ const {
createCheckBodyValidation,
getChecksParamValidation,
getChecksQueryValidation,
getUserChecksParamValidation,
getUserChecksQueryValidation,
getTeamChecksParamValidation,
getTeamChecksQueryValidation,
deleteChecksParamValidation,
} = require("../validation/joi");
const { successMessages } = require("../utils/messages");
@@ -62,10 +62,10 @@ const getChecks = async (req, res, next) => {
}
};
const getUserChecks = async (req, res, next) => {
const getTeamChecks = async (req, res, next) => {
try {
await getUserChecksParamValidation.validateAsync(req.params);
await getUserChecksQueryValidation.validateAsync(req.query);
await getTeamChecksParamValidation.validateAsync(req.params);
await getTeamChecksQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
@@ -75,7 +75,7 @@ const getUserChecks = async (req, res, next) => {
return;
}
try {
const checkData = await req.db.getUserChecks(req);
const checkData = await req.db.getTeamChecks(req);
return res.status(200).json({
success: true,
msg: successMessages.CHECK_GET,
@@ -115,6 +115,6 @@ const deleteChecks = async (req, res, next) => {
module.exports = {
createCheck,
getChecks,
getUserChecks,
getTeamChecks,
deleteChecks,
};
+96
View File
@@ -0,0 +1,96 @@
const {
inviteRoleValidation,
inviteBodyValidation,
inviteVerifciationBodyValidation,
} = require("../validation/joi");
const logger = require("../utils/logger");
require("dotenv").config();
const { errorMessages, successMessages } = require("../utils/messages");
var jwt = require("jsonwebtoken");
const SERVICE_NAME = "inviteController";
const getTokenFromHeaders = (headers) => {
const authorizationHeader = headers.authorization;
if (!authorizationHeader) throw new Error("No auth headers");
const parts = authorizationHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer")
throw new Error("Invalid auth headers");
return parts[1];
};
const inviteController = async (req, res, next) => {
try {
// Only admins can invite
const token = getTokenFromHeaders(req.headers);
const { role, firstname, teamId } = jwt.decode(token);
req.body.teamId = teamId;
try {
await inviteRoleValidation.validateAsync({ roles: role });
await inviteBodyValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
}
const inviteToken = await req.db.requestInviteToken({ ...req.body });
req.emailService
.buildAndSendEmail(
"employeeActivationTemplate",
{
name: firstname,
link: `${process.env.CLIENT_HOST}/register/${inviteToken.token}`,
},
req.body.email,
"Welcome to Uptime Monitor"
)
.catch((error) => {
logger.error("Error sending invite email", {
service: SERVICE_NAME,
error: error.message,
});
});
return res
.status(200)
.json({ success: true, msg: "Invite sent", data: inviteToken });
} catch (error) {
error.service = SERVICE_NAME;
next(error);
return;
}
};
const inviteVerifyController = async (req, res, next) => {
try {
await inviteVerifciationBodyValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
}
try {
const invite = await req.db.getInviteToken(req.body.token);
res
.status(200)
.json({ status: "success", msg: "Invite verified", data: invite });
} catch (error) {
error.service = SERVICE_NAME;
next(error);
return;
}
};
module.exports = {
inviteController,
inviteVerifyController,
};
+9 -9
View File
@@ -1,10 +1,10 @@
const {
getMonitorByIdParamValidation,
getMonitorByIdQueryValidation,
getMonitorsByUserIdValidation,
getMonitorsByTeamIdValidation,
createMonitorBodyValidation,
editMonitorBodyValidation,
getMonitorsByUserIdQueryValidation,
getMonitorsByTeamIdQueryValidation,
} = require("../validation/joi");
const sslChecker = require("ssl-checker");
@@ -149,10 +149,10 @@ const getMonitorById = async (req, res, next) => {
* @returns {Promise<Express.Response>}
* @throws {Error}
*/
const getMonitorsByUserId = async (req, res, next) => {
const getMonitorsByTeamId = async (req, res, next) => {
try {
await getMonitorsByUserIdValidation.validateAsync(req.params);
await getMonitorsByUserIdQueryValidation.validateAsync(req.query);
await getMonitorsByTeamIdValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
@@ -163,12 +163,12 @@ const getMonitorsByUserId = async (req, res, next) => {
}
try {
const userId = req.params.userId;
const monitors = await req.db.getMonitorsByUserId(req, res);
const teamId = req.params.teamId;
const monitors = await req.db.getMonitorsByTeamId(req, res);
return res.json({
success: true,
msg: successMessages.MONITOR_GET_BY_USER_ID(userId),
msg: successMessages.MONITOR_GET_BY_USER_ID(teamId),
data: monitors,
});
} catch (error) {
@@ -342,7 +342,7 @@ module.exports = {
getMonitorStatsById,
getMonitorCertificate,
getMonitorById,
getMonitorsByUserId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
deleteAllMonitors,
+8 -8
View File
@@ -15,10 +15,10 @@ const connect = async () => {
}
};
const checkAdmin = async (req, res) => {
const checkSuperadmin = async (req, res) => {
try {
const admin = await UserModel.findOne({ role: "admin" });
if (admin !== null) {
const superAdmin = await UserModel.findOne({ role: "superadmin" });
if (superAdmin !== null) {
return true;
}
return false;
@@ -66,7 +66,7 @@ const {
getAllMonitors,
getMonitorStatsById,
getMonitorById,
getMonitorsByUserId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
deleteAllMonitors,
@@ -92,7 +92,7 @@ const {
createCheck,
getChecksCount,
getChecks,
getUserChecks,
getTeamChecks,
deleteChecks,
} = require("./modules/checkModule");
@@ -141,11 +141,11 @@ module.exports = {
requestRecoveryToken,
validateRecoveryToken,
resetPassword,
checkAdmin,
checkSuperadmin,
getAllMonitors,
getMonitorStatsById,
getMonitorById,
getMonitorsByUserId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
deleteAllMonitors,
@@ -153,7 +153,7 @@ module.exports = {
createCheck,
getChecksCount,
getChecks,
getUserChecks,
getTeamChecks,
deleteChecks,
createAlert,
getAlertsByUserId,
+4 -4
View File
@@ -116,12 +116,12 @@ const getChecks = async (req) => {
}
};
const getUserChecks = async (req) => {
const { userId } = req.params;
const getTeamChecks = async (req) => {
const { teamId } = req.params;
let { sortOrder, limit, dateRange, filter, page, rowsPerPage } = req.query;
// Get monitorIDs
const userMonitors = await Monitor.find({ userId: userId });
const userMonitors = await Monitor.find({ teamId: teamId });
const monitorIds = userMonitors.map((monitor) => monitor._id);
//Build check query
@@ -189,6 +189,6 @@ module.exports = {
createCheck,
getChecksCount,
getChecks,
getUserChecks,
getTeamChecks,
deleteChecks,
};
+30 -9
View File
@@ -2,14 +2,25 @@ const InviteToken = require("../../../models/InviteToken");
const crypto = require("crypto");
const { errorMessages } = require("../../../utils/messages");
const requestInviteToken = async (req, res) => {
/**
* Request an invite token for a user.
*
* This function deletes any existing invite tokens for the user's email,
* generates a new token, saves it, and then returns the new token.
*
* @param {Object} userData - The user data.
* @param {string} userData.email - The user's email.
* @param {mongoose.Schema.Types.ObjectId} userData.teamId - The ID of the team.
* @param {Array} userData.role - The user's role(s).
* @param {Date} [userData.expiry=Date.now] - The expiry date of the token. Defaults to the current date and time.
* @returns {Promise<InviteToken>} The invite token.
* @throws {Error} If there is an error.
*/
const requestInviteToken = async (userData) => {
try {
await InviteToken.deleteMany({ email: req.body.email });
let inviteToken = new InviteToken({
email: req.body.email,
role: req.body.role,
token: crypto.randomBytes(32).toString("hex"),
});
await InviteToken.deleteMany({ email: userData.email });
userData.token = crypto.randomBytes(32).toString("hex");
let inviteToken = new InviteToken(userData);
await inviteToken.save();
return inviteToken;
} catch (error) {
@@ -17,10 +28,20 @@ const requestInviteToken = async (req, res) => {
}
};
const getInviteToken = async (req, res) => {
/**
* Retrieves and deletes an invite token.
*
* This function searches for an invite token in the database and deletes it.
* If the invite token is not found, it throws an error.
*
* @param {string} token - The invite token to search for.
* @returns {Promise<InviteToken>} The invite token data.
* @throws {Error} If the invite token is not found or there is another error.
*/
const getInviteToken = async (token) => {
try {
const invite = await InviteToken.findOneAndDelete({
token: req.body.token,
token,
});
if (invite === null) {
throw new Error(errorMessages.AUTH_INVITE_NOT_FOUND);
+4 -5
View File
@@ -287,17 +287,17 @@ const getMonitorById = async (monitorId) => {
};
/**
* Get monitors by UserID
* Get monitors by TeamID
* @async
* @param {Express.Request} req
* @param {Express.Response} res
* @returns {Promise<Array<Monitor>>}
* @throws {Error}
*/
const getMonitorsByUserId = async (req, res) => {
const getMonitorsByTeamId = async (req, res) => {
try {
let { limit, type, status, sortOrder, normalize } = req.query || {};
const monitorQuery = { userId: req.params.userId };
const monitorQuery = { teamId: req.params.teamId };
if (type !== undefined) {
const types = Array.isArray(type) ? type : [type];
@@ -367,7 +367,6 @@ const createMonitor = async (req, res) => {
const monitor = new Monitor({ ...req.body });
// Remove notifications fom monitor as they aren't needed here
monitor.notifications = undefined;
monitor.userId = req.user._id;
await monitor.save();
return monitor;
} catch (error) {
@@ -453,7 +452,7 @@ module.exports = {
getAllMonitors,
getMonitorStatsById,
getMonitorById,
getMonitorsByUserId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
deleteAllMonitors,
+16 -6
View File
@@ -1,4 +1,5 @@
const UserModel = require("../../../models/user");
const TeamModel = require("../../../models/Team");
const { errorMessages } = require("../../../utils/messages");
const { GenerateAvatarImage } = require("../../../utils/imageProcessing");
@@ -13,20 +14,29 @@ const { ParseBoolean } = require("../../../utils/utils");
* @returns {Promise<UserModel>}
* @throws {Error}
*/
const insertUser = async (req, res) => {
const insertUser = async (userData, imageFile) => {
try {
const userData = { ...req.body };
if (req.file) {
if (imageFile) {
// 1. Save the full size image
userData.profileImage = {
data: req.file.buffer,
contentType: req.file.mimetype,
data: imageFile.buffer,
contentType: imageFile.mimetype,
};
// 2. Get the avatar sized image
const avatar = await GenerateAvatarImage(req.file);
const avatar = await GenerateAvatarImage(imageFile);
userData.avatarImage = avatar;
}
// Handle creating team if superadmin
if (userData.role.includes("superadmin")) {
const team = new TeamModel({
email: userData.email,
});
userData.teamId = team._id;
await team.save();
}
const newUser = new UserModel(userData);
await newUser.save();
return await UserModel.findOne({ _id: newUser._id })
+2
View File
@@ -7,6 +7,7 @@ const { verifyJWT } = require("./middleware/verifyJWT");
const { handleErrors } = require("./middleware/handleErrors");
const { errorMessages } = require("./utils/messages");
const authRouter = require("./routes/authRoute");
const inviteRouter = require("./routes/inviteRoute");
const monitorRouter = require("./routes/monitorRoute");
const checkRouter = require("./routes/checkRoute");
const alertRouter = require("./routes/alertRoute");
@@ -71,6 +72,7 @@ const startApp = async () => {
//routes
app.use("/api/v1/auth", authRouter);
app.use("/api/v1/invite", inviteRouter);
app.use("/api/v1/monitors", verifyJWT, monitorRouter);
app.use("/api/v1/checks", verifyJWT, checkRouter);
app.use("/api/v1/alerts", verifyJWT, alertRouter);
+53
View File
@@ -0,0 +1,53 @@
const jwt = require("jsonwebtoken");
const TOKEN_PREFIX = "Bearer ";
const SERVICE_NAME = "allowedRoles";
const { errorMessages } = require("../utils/messages");
const isAllowed = (allowedRoles) => {
return (req, res, next) => {
const token = req.headers["authorization"];
// If no token is pressent, return an error
if (!token) {
const error = new Error(errorMessages.NO_AUTH_TOKEN);
error.status = 401;
error.service = SERVICE_NAME;
next(error);
return;
}
// If the token is improperly formatted, return an error
if (!token.startsWith(TOKEN_PREFIX)) {
const error = new Error(errorMessages.INVALID_AUTH_TOKEN);
error.status = 400;
error.service = SERVICE_NAME;
next(error);
return;
}
// Parse the token
try {
const parsedToken = token.slice(TOKEN_PREFIX.length, token.length);
var decoded = jwt.verify(parsedToken, process.env.JWT_SECRET);
const userRoles = decoded.role;
// Check if the user has the required role
if (userRoles.some((role) => allowedRoles.includes(role))) {
next();
return;
} else {
const error = new Error(errorMessages.INSUFFICIENT_PERMISSIONS);
error.status = 401;
error.service = SERVICE_NAME;
next(error);
return;
}
} catch (error) {
error.status = 401;
error.service = SERVICE_NAME;
next(error);
return;
}
};
};
module.exports = { isAllowed };
@@ -11,7 +11,7 @@ const { errorMessages } = require("../utils/messages");
* @param {express.NextFunction} next
* @returns {express.Response}
*/
const verifyAdmin = (req, res, next) => {
const verifySuperAdmin = (req, res, next) => {
const token = req.headers["authorization"];
// Make sure a token is provided
if (!token) {
@@ -42,7 +42,7 @@ const verifyAdmin = (req, res, next) => {
.json({ success: false, msg: errorMessages.INVALID_AUTH_TOKEN });
}
if (decoded.role.includes("admin") === false) {
if (decoded.role.includes("superadmin") === false) {
logger.error(errorMessages.INVALID_AUTH_TOKEN, {
service: SERVICE_NAME,
});
@@ -54,4 +54,4 @@ const verifyAdmin = (req, res, next) => {
});
};
module.exports = { verifyAdmin };
module.exports = { verifySuperAdmin };
+6
View File
@@ -7,6 +7,12 @@ const InviteTokenSchema = mongoose.Schema(
required: true,
unique: true,
},
teamId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Team",
immutable: true,
required: true,
},
role: {
type: Array,
required: true,
+7
View File
@@ -6,6 +6,13 @@ const MonitorSchema = mongoose.Schema(
type: mongoose.Schema.Types.ObjectId,
ref: "User",
immutable: true,
required: true,
},
teamId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Team",
immutable: true,
required: true,
},
name: {
type: String,
+16
View File
@@ -0,0 +1,16 @@
const mongoose = require("mongoose");
const TeamSchema = mongoose.Schema(
{
email: {
type: String,
required: true,
unique: true,
},
},
{
timestamps: true,
}
);
module.exports = mongoose.model("Team", TeamSchema);
+12 -1
View File
@@ -38,7 +38,12 @@ const UserSchema = mongoose.Schema(
role: {
type: [String],
default: "user",
enum: ["user", "admin"],
enum: ["user", "admin", "superadmin"],
},
teamId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Team",
immutable: true,
},
},
{
@@ -70,4 +75,10 @@ UserSchema.methods.comparePassword = async function (submittedPassword) {
return res;
};
const User = mongoose.model("User", UserSchema);
User.init().then(() => {
console.log("User model initialized");
});
module.exports = mongoose.model("User", UserSchema);
+9 -9
View File
@@ -1,7 +1,7 @@
const router = require("express").Router();
const { verifyJWT } = require("../middleware/verifyJWT");
const { verifyAdmin } = require("../middleware/verifyAdmin");
const { verifyOwnership } = require("../middleware/verifyOwnership");
const { isAllowed } = require("../middleware/isAllowed");
const multer = require("multer");
const upload = multer();
const User = require("../models/user");
@@ -14,11 +14,9 @@ const {
recoveryRequestController,
validateRecoveryTokenController,
resetPasswordController,
checkAdminController,
checkSuperadminController,
getAllUsersController,
deleteUserController,
inviteController,
inviteVerifyController,
} = require("../controllers/authController");
//Auth routes
@@ -30,8 +28,13 @@ router.put(
verifyJWT,
userEditController
);
router.get("/users/admin", checkAdminController);
router.get("/users", verifyJWT, verifyAdmin, getAllUsersController);
router.get("/users/superadmin", checkSuperadminController);
router.get(
"/users",
verifyJWT,
isAllowed(["admin", "superadmin"]),
getAllUsersController
);
router.delete(
"/user/:userId",
verifyJWT,
@@ -39,9 +42,6 @@ router.delete(
deleteUserController
);
router.post("/invite", verifyJWT, inviteController);
router.post("/invite/verify", inviteVerifyController);
//Recovery routes
router.post("/recovery/request", recoveryRequestController);
router.post("/recovery/validate", validateRecoveryTokenController);
+2 -6
View File
@@ -9,13 +9,9 @@ router.post(
checkController.createCheck
);
router.get(
"/:monitorId",
verifyOwnership(Monitor, "monitorId"),
checkController.getChecks
);
router.get("/:monitorId", checkController.getChecks);
router.get("/user/:userId", checkController.getUserChecks);
router.get("/team/:teamId", checkController.getTeamChecks);
router.delete(
"/:monitorId",
+22
View File
@@ -0,0 +1,22 @@
const router = require("express").Router();
const { verifyJWT } = require("../middleware/verifyJWT");
const { isAllowed } = require("../middleware/isAllowed");
const {
inviteController,
inviteVerifyController,
} = require("../controllers/inviteController");
router.post(
"/",
isAllowed(["admin", "superadmin"]),
verifyJWT,
inviteController
);
router.post(
"/verify",
isAllowed(["admin", "superadmin"]),
inviteVerifyController
);
module.exports = router;
+16 -7
View File
@@ -1,25 +1,34 @@
const router = require("express").Router();
const monitorController = require("../controllers/monitorController");
const { verifyOwnership } = require("../middleware/verifyOwnership");
const Monitor = require("../models/Monitor");
const { isAllowed } = require("../middleware/isAllowed");
router.get("/", monitorController.getAllMonitors);
router.get("/stats/:monitorId", monitorController.getMonitorStatsById);
router.get("/certificate/:monitorId", monitorController.getMonitorCertificate);
router.get("/:monitorId", monitorController.getMonitorById);
router.get("/user/:userId", monitorController.getMonitorsByUserId);
router.get("/team/:teamId", monitorController.getMonitorsByTeamId);
router.post(
"/",
isAllowed(["admin", "superadmin"]),
monitorController.createMonitor
);
router.post("/", monitorController.createMonitor);
router.delete(
"/:monitorId",
verifyOwnership(Monitor, "monitorId"),
isAllowed(["admin", "superadmin"]),
monitorController.deleteMonitor
);
router.put(
"/:monitorId",
verifyOwnership(Monitor, "monitorId"),
isAllowed(["admin", "superadmin"]),
monitorController.editMonitor
);
router.delete("/all", monitorController.deleteAllMonitors);
router.delete(
"/all",
isAllowed(["superadmin"]),
monitorController.deleteAllMonitors
);
module.exports = router;
+3
View File
@@ -17,6 +17,9 @@ const errorMessages = {
VERIFY_OWNER_NOT_FOUND: "Document not found",
VERIFY_OWNER_UNAUTHORIZED: "Unauthorized access",
//Permissions Middleware
INSUFFICIENT_PERMISSIONS: "Insufficient permissions",
//DB Errors
DB_USER_EXISTS: "User already exists",
DB_USER_NOT_FOUND: "User not found",
+22 -16
View File
@@ -5,8 +5,11 @@ const joi = require("joi");
//****************************************
const roleValidatior = (role) => (value, helpers) => {
if (!value.includes(role)) {
throw new joi.ValidationError(`You do not have ${role} authorization`);
const hasRole = role.some((role) => value.includes(role));
if (!hasRole) {
throw new Joi.ValidationError(
`You do not have the required authorization. Required roles: ${roles.join(", ")}`
);
}
return value;
};
@@ -36,7 +39,7 @@ const loginValidation = joi.object({
),
});
const registerValidation = joi.object({
const registrationBodyValidation = joi.object({
firstName: joi
.string()
.required()
@@ -66,9 +69,10 @@ const registerValidation = joi.object({
profileImage: joi.any(),
role: joi
.array()
.items(joi.string().valid("admin", "user"))
.items(joi.string().valid("superadmin", "admin", "user"))
.min(1)
.required(),
teamId: joi.string().allow("").required(),
});
const editUserParamValidation = joi.object({
@@ -123,7 +127,7 @@ const deleteUserParamValidation = joi.object({
});
const inviteRoleValidation = joi.object({
roles: joi.custom(roleValidatior("admin")).required(),
roles: joi.custom(roleValidatior(["admin", "superadmin"])).required(),
});
const inviteBodyValidation = joi.object({
@@ -132,6 +136,7 @@ const inviteBodyValidation = joi.object({
"string.email": "Must be a valid email address",
}),
role: joi.array().required(),
teamId: joi.string().required(),
});
const inviteVerifciationBodyValidation = joi.object({
@@ -155,11 +160,11 @@ const getMonitorByIdQueryValidation = joi.object({
normalize: joi.boolean(),
});
const getMonitorsByUserIdValidation = joi.object({
userId: joi.string().required(),
const getMonitorsByTeamIdValidation = joi.object({
teamId: joi.string().required(),
});
const getMonitorsByUserIdQueryValidation = joi.object({
const getMonitorsByTeamIdQueryValidation = joi.object({
status: joi.boolean(),
sortOrder: joi.string().valid("asc", "desc"),
limit: joi.number(),
@@ -175,6 +180,7 @@ const getMonitorsByUserIdQueryValidation = joi.object({
const createMonitorBodyValidation = joi.object({
_id: joi.string(),
userId: joi.string().required(),
teamId: joi.string().required(),
name: joi.string().required(),
description: joi.string().required(),
type: joi.string().required(),
@@ -265,11 +271,11 @@ const getChecksQueryValidation = joi.object({
rowsPerPage: joi.number(),
});
const getUserChecksParamValidation = joi.object({
userId: joi.string().required(),
const getTeamChecksParamValidation = joi.object({
teamId: joi.string().required(),
});
const getUserChecksQueryValidation = joi.object({
const getTeamChecksQueryValidation = joi.object({
sortOrder: joi.string().valid("asc", "desc"),
limit: joi.number(),
dateRange: joi.string().valid("day", "week", "month"),
@@ -331,7 +337,7 @@ const getMaintenanceWindowsByMonitorIdParamValidation = joi.object({
module.exports = {
roleValidatior,
loginValidation,
registerValidation,
registrationBodyValidation,
recoveryValidation,
recoveryTokenValidation,
newPasswordValidation,
@@ -341,8 +347,8 @@ module.exports = {
createMonitorBodyValidation,
getMonitorByIdParamValidation,
getMonitorByIdQueryValidation,
getMonitorsByUserIdValidation,
getMonitorsByUserIdQueryValidation,
getMonitorsByTeamIdValidation,
getMonitorsByTeamIdQueryValidation,
editMonitorBodyValidation,
editUserParamValidation,
editUserBodyValidation,
@@ -358,8 +364,8 @@ module.exports = {
createCheckBodyValidation,
getChecksParamValidation,
getChecksQueryValidation,
getUserChecksParamValidation,
getUserChecksQueryValidation,
getTeamChecksParamValidation,
getTeamChecksQueryValidation,
deleteChecksParamValidation,
deleteUserParamValidation,
getPageSpeedCheckParamValidation,