+
{
gap={theme.gap.small}
>
- BlueWave Uptime
+ BlueWave Uptime
{
- const dispatch = useDispatch();
- const navigate = useNavigate();
- const { token } = useParams();
+/**
+ * Displays the initial landing page.
+ *
+ * @param {Object} props
+ * @param {boolean} props.isAdmin - 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 theme = useTheme();
- // TODO If possible, change the IDs of these fields to match the backend
- const idMap = {
- "register-firstname-input": "firstName",
- "register-lastname-input": "lastName",
- "register-email-input": "email",
- "register-password-input": "password",
- "register-confirm-input": "confirm",
- };
-
- const [form, setForm] = useState({
- firstName: "",
- lastName: "",
- email: "",
- password: "",
- confirm: "",
- role: [],
- });
- const [errors, setErrors] = useState({});
-
- useEffect(() => {
- const fetchInvite = async () => {
- if (token !== undefined) {
- try {
- const res = await axiosInstance.post(`/auth/invite/verify`, {
- token,
- });
- const { role, email } = res.data.data;
- console.log(role);
- setForm({ ...form, email, role });
- } catch (error) {
- console.log(error);
- }
- }
- };
- fetchInvite();
- }, [token]);
-
- const handleSubmit = async (e) => {
- e.preventDefault();
- const registerForm = { ...form, role: isAdmin ? ["admin"] : form.role };
- const { error } = credentials.validate(registerForm, {
- abortEarly: false,
- context: { password: form.password },
- });
-
- if (error) {
- // validation errors
- 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.",
- });
- } else {
- delete registerForm.confirm;
- const action = await dispatch(register(registerForm));
- if (action.payload.success) {
- const token = action.payload.data;
- localStorage.setItem("token", token);
- navigate("/");
- createToast({
- body: "Welcome! Your account was created successfully.",
- });
- } else {
- if (action.payload) {
- // dispatch errors
- createToast({
- body: action.payload.msg,
- });
- } else {
- // unknown errors
- createToast({
- body: "Unknown error.",
- });
- }
- }
- }
- };
-
- const handleChange = (event) => {
- const { value, id } = event.target;
- const name = idMap[id];
- setForm((prev) => ({
- ...prev,
- [name]: value,
- }));
-
- const { error } = credentials.validate(
- { [name]: value },
- { abortEarly: false, context: { password: form.password } }
- );
-
- setErrors((prev) => {
- const prevErrors = { ...prev };
- if (error) prevErrors[name] = error.details[0].message;
- else delete prevErrors[name];
- return prevErrors;
- });
- };
return (
-
-

-
-
+
+ >
+ );
+};
+
+const Register = ({ isAdmin }) => {
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const { token } = useParams();
+ const theme = useTheme();
+ // TODO If possible, change the IDs of these fields to match the backend
+ const idMap = {
+ "register-firstname-input": "firstName",
+ "register-lastname-input": "lastName",
+ "register-email-input": "email",
+ "register-password-input": "password",
+ "register-confirm-input": "confirm",
+ };
+
+ const [form, setForm] = useState({
+ firstName: "",
+ lastName: "",
+ email: "",
+ password: "",
+ confirm: "",
+ role: [],
+ });
+ const [errors, setErrors] = useState({});
+ const [step, setStep] = useState(0);
+
+ useEffect(() => {
+ const fetchInvite = async () => {
+ if (token !== undefined) {
+ try {
+ const res = await axiosInstance.post(`/auth/invite/verify`, {
+ token,
+ });
+ const { role, email } = res.data.data;
+ console.log(role);
+ setForm({ ...form, email, role });
+ } catch (error) {
+ console.log(error);
+ }
+ }
+ };
+ fetchInvite();
+ }, [token]);
+
+ /**
+ * Validates the form data against the validation schema.
+ *
+ * @param {Object} data - The form data to validate.
+ * @param {Object} [options] - Optional settings for validation.
+ * @returns {Object | undefined} - Returns the validation error object if there are validation errors; otherwise, `undefined`.
+ */
+ const validateForm = (data, options = {}) => {
+ const { error } = credentials.validate(data, {
+ abortEarly: false,
+ ...options,
+ });
+ return error;
+ };
+
+ /**
+ * Handles validation errors by setting the state with error messages and displaying a toast notification.
+ *
+ * @param {Object} error - The validation error object returned from the validation schema.
+ */
+ const handleError = (error) => {
+ const newErrors = {};
+ error.details.forEach((err) => {
+ newErrors[err.path[0]] = err.message;
+ });
+ setErrors(newErrors);
+ createToast({ body: error.details[0].message || "Error validating data." });
+ };
+
+ const handleStepOne = async (e) => {
+ e.preventDefault();
+
+ let error = validateForm({
+ firstName: form.firstName,
+ lastName: form.lastName,
+ });
+
+ if (error) {
+ handleError(error);
+ return;
+ }
+
+ setStep(2);
+ };
+
+ const handleStepTwo = async (e) => {
+ e.preventDefault();
+
+ let error;
+ error = validateForm({ email: form.email });
+ if (error) {
+ handleError(error);
+ return;
+ }
+
+ setStep(3);
+ };
+
+ // Final step
+ // Attempts account registration
+ const handleStepThree = async (e) => {
+ e.preventDefault();
+
+ let registerForm = { ...form, role: isAdmin ? ["admin"] : form.role };
+ let error = validateForm(registerForm, {
+ context: { password: form.password },
+ });
+ if (error) {
+ handleError(error);
+ return;
+ }
+
+ delete registerForm.confirm;
+ const action = await dispatch(register(registerForm));
+ if (action.payload.success) {
+ const token = action.payload.data;
+ localStorage.setItem("token", token);
+ navigate("/");
+ createToast({
+ body: "Welcome! Your account was created successfully.",
+ });
+ } else {
+ if (action.payload) {
+ // dispatch errors
+ createToast({
+ body: action.payload.msg,
+ });
+ } else {
+ // unknown errors
+ createToast({
+ body: "Unknown error.",
+ });
+ }
+ }
+ };
+
+ const handleChange = (event) => {
+ const { value, id } = event.target;
+ const name = idMap[id];
+ setForm((prev) => ({
+ ...prev,
+ [name]: value,
+ }));
+
+ const { error } = credentials.validate(
+ { [name]: value },
+ { abortEarly: false, context: { password: form.password } }
+ );
+
+ setErrors((prev) => {
+ const prevErrors = { ...prev };
+ if (error) prevErrors[name] = error.details[0].message;
+ else delete prevErrors[name];
+ return prevErrors;
+ });
+ };
+
+ return (
+
+
+
+
+ BlueWave Uptime
+
+ .MuiStack-root": {
+ border: 1,
+ borderRadius: theme.shape.borderRadius,
+ borderColor: theme.palette.otherColors.graishWhite,
+ backgroundColor: theme.palette.otherColors.white,
+ padding: {
+ xs: theme.gap.large,
+ sm: theme.gap.xl,
+ },
+ },
+ }}
+ >
+ {step === 0 ? (
+ setStep(1)} />
+ ) : step === 1 ? (
+ setStep(0)}
+ />
+ ) : step === 2 ? (
+ setStep(1)}
+ />
+ ) : step === 3 ? (
+ setStep(2)}
+ />
+ ) : (
+ ""
+ )}
+
+
+
+ Already have an account? —
+
+ {
+ navigate("/login");
+ }}
+ sx={{ userSelect: "none" }}
+ >
+ Log In
+
+
+
);
};
Register.propTypes = {
diff --git a/Client/src/Pages/Auth/index.css b/Client/src/Pages/Auth/index.css
index f762c1a24..577f27799 100644
--- a/Client/src/Pages/Auth/index.css
+++ b/Client/src/Pages/Auth/index.css
@@ -1,7 +1,6 @@
/* ////// */
/* SHARED */
/* ////// */
-.register-page,
.set-new-password-page,
.password-confirmed-page,
.forgot-password-page,
@@ -10,7 +9,6 @@
justify-content: center;
height: var(--env-var-height-1);
}
-.register-page h1.MuiTypography-root,
.set-new-password-page h1.MuiTypography-root,
.password-confirmed-page h1.MuiTypography-root,
.forgot-password-page h1.MuiTypography-root,
@@ -19,10 +17,6 @@
color: var(--env-var-color-1);
font-weight: 600;
}
-.register-page p.MuiTypography-root,
-.register-page span,
-.register-page button,
-.login-page button,
.set-new-password-page p.MuiTypography-root,
.set-new-password-page button,
.password-confirmed-page p.MuiTypography-root,
@@ -34,11 +28,6 @@
.check-email-page span.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}
-.register-page p.MuiTypography-root {
- color: var(--env-var-color-2);
- opacity: 0.8;
-}
-.register-page button:not(.MuiIconButton-root),
.set-new-password-page button:not(.MuiIconButton-root),
.password-confirmed-page button:not(.MuiIconButton-root),
.forgot-password-page button:not(.MuiIconButton-root),
@@ -48,13 +37,6 @@
line-height: 0;
}
-.register-page svg rect {
- fill: none;
-}
-.register-form {
- margin-top: 95px;
-}
-
.set-new-password-form,
.password-confirmed-form,
.forgot-password-form,
@@ -76,9 +58,6 @@
margin-right: 5px;
}
-.register-page
- .MuiFormControl-root:has(#register-password-input)
- + span.MuiTypography-root,
.set-new-password-page
.MuiFormControl-root:has(#register-password-input)
+ span.MuiTypography-root,
@@ -89,17 +68,6 @@
visibility: hidden;
}
-/* ///// */
-/* LOGIN */
-/* ///// */
-.login-page
- .MuiStack-root:not(:has(> .MuiButtonBase-root))
- > span:not(.field-required):not(.input-error) {
- color: var(--env-var-color-3);
- cursor: pointer;
- font-weight: 600;
-}
-
/* /////////// */
/* Check Email */
/* /////////// */
@@ -107,68 +75,71 @@
font-weight: 600;
}
-/* */
-.login-page {
+/* AUTH */
+.auth {
height: 100vh;
}
-.login-page h1 {
- font-size: 37px;
+.auth h1 {
+ font-size: var(--env-var-font-size-xlarge);
font-weight: 600;
color: var(--env-var-color-3);
}
-.login-page button:not(.MuiIconButton-root),
-.login-page .field h3.MuiTypography-root,
-.login-page .field input,
-.login-page .field .input-error,
-.login-page p,
-.login-page span {
+.auth button:not(.MuiIconButton-root),
+.auth .field h3.MuiTypography-root,
+.auth .field input,
+.auth .field .input-error,
+.auth p,
+.auth span {
font-size: var(--env-var-font-size-medium-plus);
}
-.login-page p {
+.auth p {
color: var(--env-var-color-2-light);
}
-.login-page .field h3.MuiTypography-root,
-.login-page .field input {
+.auth .field h3.MuiTypography-root,
+.auth .field input {
color: var(--env-var-color-5);
}
-.login-page p + span {
+.auth p + span {
color: var(--env-var-color-3);
opacity: 0.8;
cursor: pointer;
transition: opacity 300ms ease-in;
}
-.login-page p > span {
+.auth p > span {
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
transition: all 200ms;
}
-.login-page p > span:hover {
+.auth p > span:hover {
color: var(--env-var-color-2);
text-underline-offset: 4px;
}
-.login-page p + span:hover {
+.auth p + span:hover {
opacity: 1;
}
-.login-page button:not(.MuiIconButton-root) {
+.auth button:not(.MuiIconButton-root) {
user-select: none;
border-radius: var(--env-var-radius-2);
line-height: 1;
}
-.login-page button:not(.MuiIconButton-root),
-.login-page .field .MuiInputBase-root:has(input) {
+.auth button:not(.MuiIconButton-root),
+.auth .field .MuiInputBase-root:has(input) {
height: 38px;
}
-.login-page .field svg {
+.auth .field svg {
width: 24px;
height: 24px;
}
+.auth .check span.MuiTypography-root {
+ font-size: var(--env-var-font-size-medium);
+}
-.login-page > .MuiStack-root:nth-of-type(1) {
+.auth > .MuiStack-root:nth-of-type(1) {
height: var(--env-var-nav-bar-height);
}
-.login-page .background-pattern-svg {
+.auth .background-pattern-svg {
top: 14%;
width: 100%;
max-width: 800px;
diff --git a/Client/src/Pages/Incidents/IncidentTable/index.jsx b/Client/src/Pages/Incidents/IncidentTable/index.jsx
index d16c23733..95230bb72 100644
--- a/Client/src/Pages/Incidents/IncidentTable/index.jsx
+++ b/Client/src/Pages/Incidents/IncidentTable/index.jsx
@@ -28,8 +28,18 @@ const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
rowsPerPage: 12,
});
+ useEffect(() => {
+ setPaginationController({
+ ...paginationController,
+ page: 0,
+ });
+ }, [filter, selectedMonitor]);
+
useEffect(() => {
const fetchPage = async () => {
+ if (!monitors || Object.keys(monitors).length === 0) {
+ return;
+ }
try {
let url = `/checks/${selectedMonitor}?sortOrder=desc&filter=${filter}&page=${paginationController.page}&rowsPerPage=${paginationController.rowsPerPage}`;
@@ -52,6 +62,7 @@ const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
}, [
authToken,
user,
+ monitors,
selectedMonitor,
filter,
paginationController.page,
diff --git a/Client/src/Pages/Monitors/Details/PaginationTable/index.jsx b/Client/src/Pages/Monitors/Details/PaginationTable/index.jsx
index 7f59705b1..28a668977 100644
--- a/Client/src/Pages/Monitors/Details/PaginationTable/index.jsx
+++ b/Client/src/Pages/Monitors/Details/PaginationTable/index.jsx
@@ -27,6 +27,13 @@ const PaginationTable = ({ monitorId, dateRange }) => {
rowsPerPage: 5,
});
+ useEffect(() => {
+ setPaginationController({
+ ...paginationController,
+ page: 0,
+ });
+ }, [dateRange]);
+
useEffect(() => {
const fetchPage = async () => {
try {
diff --git a/Client/src/assets/Images/bwl-logo-2.svg b/Client/src/assets/Images/bwl-logo-2.svg
deleted file mode 100644
index 325472177..000000000
--- a/Client/src/assets/Images/bwl-logo-2.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
\ No newline at end of file
diff --git a/Docker/client.Dockerfile b/Docker/client.Dockerfile
index 6b6451560..61b090ed7 100644
--- a/Docker/client.Dockerfile
+++ b/Docker/client.Dockerfile
@@ -1,4 +1,4 @@
-FROM node:20
+FROM node:20-alpine
WORKDIR /app
diff --git a/Docker/server.Dockerfile b/Docker/server.Dockerfile
index f3bb45060..5d4ea7364 100644
--- a/Docker/server.Dockerfile
+++ b/Docker/server.Dockerfile
@@ -1,4 +1,4 @@
-FROM node:20
+FROM node:20-alpine
WORKDIR /app
diff --git a/README.md b/README.md
index d3e822f94..d73109925 100644
--- a/README.md
+++ b/README.md
@@ -11,10 +11,7 @@
An open source server monitoring application
-
-
-
-
+
BlueWave Uptime is an open source server monitoring application used to track the operational status and performance of servers and websites. It regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.
diff --git a/Server/controllers/authController.js b/Server/controllers/authController.js
index 02eb1bea4..0f89e3371 100644
--- a/Server/controllers/authController.js
+++ b/Server/controllers/authController.js
@@ -135,6 +135,10 @@ const loginController = async (req, res, next) => {
throw new Error(errorMessages.AUTH_INCORRECT_PASSWORD);
}
+ if (user.authToken) {
+ throw new Error(errorMessages.AUTH_ALREADY_LOGGED_IN);
+ }
+
// Remove password from user object. Should this be abstracted to DB layer?
const userWithoutPassword = { ...user._doc };
delete userWithoutPassword.password;
@@ -142,6 +146,8 @@ const loginController = async (req, res, next) => {
// Happy path, return token
const token = issueToken(userWithoutPassword);
+ user.authToken = token;
+ await user.save();
// reset avatar image
userWithoutPassword.avatarImage = user.avatarImage;
@@ -157,6 +163,23 @@ const loginController = async (req, res, next) => {
}
};
+const logoutController = async (req, res, next) => {
+ try {
+ // Get user
+ const { email } = req.body;
+ const userToLogout = await req.db.getUserByEmail({ body: { email } }, res);
+ userToLogout.authToken = null;
+ await userToLogout.save();
+
+ return res
+ .status(200)
+ .json({ success: true, msg: successMessages.AUTH_LOGOUT_USER });
+ } catch (error) {
+ error.service = SERVICE_NAME;
+ next(error);
+ }
+};
+
const userEditController = async (req, res, next) => {
try {
await editUserParamValidation.validateAsync(req.params);
@@ -524,6 +547,7 @@ const getAllUsersController = async (req, res) => {
module.exports = {
registerController,
loginController,
+ logoutController,
userEditController,
inviteController,
inviteVerifyController,
diff --git a/Server/db/mongo/MongoDB.js b/Server/db/mongo/MongoDB.js
index ed1b9e4b1..fa84cd98a 100644
--- a/Server/db/mongo/MongoDB.js
+++ b/Server/db/mongo/MongoDB.js
@@ -37,6 +37,7 @@ const {
updateUser,
deleteUser,
getAllUsers,
+ logoutUser,
} = require("./modules/userModule");
//****************************************
@@ -133,6 +134,7 @@ module.exports = {
updateUser,
deleteUser,
getAllUsers,
+ logoutUser,
requestInviteToken,
getInviteToken,
requestRecoveryToken,
diff --git a/Server/db/mongo/modules/userModule.js b/Server/db/mongo/modules/userModule.js
index de7d54ebf..d626bcb9a 100644
--- a/Server/db/mongo/modules/userModule.js
+++ b/Server/db/mongo/modules/userModule.js
@@ -27,7 +27,6 @@ const insertUser = async (req, res) => {
const avatar = await GenerateAvatarImage(req.file);
userData.avatarImage = avatar;
}
- console.log(userData);
const newUser = new UserModel(userData);
await newUser.save();
return await UserModel.findOne({ _id: newUser._id })
@@ -151,10 +150,20 @@ const getAllUsers = async (req, res) => {
}
};
+const logoutUser = async (userId) => {
+ try {
+ await UserModel.updateOne({ _id: userId }, { $unset: { authToken: null } });
+ return true;
+ } catch (error) {
+ throw error;
+ }
+};
+
module.exports = {
insertUser,
getUserByEmail,
updateUser,
deleteUser,
getAllUsers,
+ logoutUser,
};
diff --git a/Server/middleware/verifyJWT.js b/Server/middleware/verifyJWT.js
index 1dbbe8d99..cfbf4b46c 100644
--- a/Server/middleware/verifyJWT.js
+++ b/Server/middleware/verifyJWT.js
@@ -3,6 +3,8 @@ const logger = require("../utils/logger");
const SERVICE_NAME = "verifyJWT";
const TOKEN_PREFIX = "Bearer ";
const { errorMessages } = require("../utils/messages");
+const { parse } = require("path");
+const User = require("../models/user");
/**
* Verifies the JWT token
* @function
@@ -32,14 +34,27 @@ const verifyJWT = (req, res, next) => {
const parsedToken = token.slice(TOKEN_PREFIX.length, token.length);
// Verify the token's authenticity
- jwt.verify(parsedToken, process.env.JWT_SECRET, (err, decoded) => {
+ jwt.verify(parsedToken, process.env.JWT_SECRET, async (err, decoded) => {
if (err) {
- logger.error(errorMessages.INVALID_AUTH_TOKEN, {
- service: SERVICE_NAME,
- });
- return res
- .status(401)
- .json({ success: false, msg: errorMessages.INVALID_AUTH_TOKEN });
+ try {
+ const userId = jwt.decode(parsedToken)._id;
+ await req.db.logoutUser(userId);
+ logger.error(errorMessages.INVALID_AUTH_TOKEN, {
+ service: SERVICE_NAME,
+ });
+ return res
+ .status(401)
+ .json({ success: false, msg: errorMessages.INVALID_AUTH_TOKEN });
+ } catch (error) {
+ logger.error(errorMessages.UNKNOWN_ERROR, {
+ service: SERVICE_NAME,
+ error: error,
+ });
+ error.status = 401;
+ error.service = SERVICE_NAME;
+ next(error);
+ return;
+ }
}
//Add the user to the request object for use in the route
req.user = decoded;
diff --git a/Server/models/user.js b/Server/models/user.js
index 977cabc15..dee89b5f7 100644
--- a/Server/models/user.js
+++ b/Server/models/user.js
@@ -40,6 +40,9 @@ const UserSchema = mongoose.Schema(
default: "user",
enum: ["user", "admin"],
},
+ authToken: {
+ type: String,
+ },
},
{
timestamps: true,
diff --git a/Server/routes/authRoute.js b/Server/routes/authRoute.js
index b01e90294..224b4501f 100644
--- a/Server/routes/authRoute.js
+++ b/Server/routes/authRoute.js
@@ -9,6 +9,7 @@ const User = require("../models/user");
const {
registerController,
loginController,
+ logoutController,
userEditController,
recoveryRequestController,
validateRecoveryTokenController,
@@ -23,6 +24,7 @@ const {
//Auth routes
router.post("/register", upload.single("profileImage"), registerController);
router.post("/login", loginController);
+router.post("/logout", logoutController);
router.put(
"/user/:userId",
upload.single("profileImage"),
diff --git a/Server/utils/messages.js b/Server/utils/messages.js
index d04052607..40d7205e5 100644
--- a/Server/utils/messages.js
+++ b/Server/utils/messages.js
@@ -7,6 +7,7 @@ const errorMessages = {
UNAUTHORIZED: "Unauthorized access",
AUTH_ADMIN_EXISTS: "Admin already exists",
AUTH_INVITE_NOT_FOUND: "Invite not found",
+ AUTH_ALREADY_LOGGED_IN: "User already logged in",
//Error handling middleware
UNKNOWN_SERVICE: "Unknown service",
@@ -55,6 +56,7 @@ const successMessages = {
// Auth Controller
AUTH_CREATE_USER: "User created successfully",
AUTH_LOGIN_USER: "User logged in successfully",
+ AUTH_LOGOUT_USER: "User logged out successfully",
AUTH_UPDATE_USER: "User updated successfully",
AUTH_CREATE_RECOVERY_TOKEN: "Recovery token created successfully",
AUTH_VERIFY_RECOVERY_TOKEN: "Recovery token verified successfully",