From af82dec2ad97fd10001e50cdd2e56b3e83f92910 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 16 Jan 2026 19:12:21 +0000 Subject: [PATCH 1/8] separate user from token --- .../src/Components/v1/ActionsMenu/index.jsx | 1 - client/src/Features/Auth/authSlice.js | 29 ++++++++++--------- client/src/Hooks/monitorHooks.js | 1 - client/src/Pages/Auth/Register/index.jsx | 2 +- client/src/Pages/Uptime/Monitors/index.jsx | 1 - client/src/Utils/NetworkService.js | 13 ++------- 6 files changed, 19 insertions(+), 28 deletions(-) diff --git a/client/src/Components/v1/ActionsMenu/index.jsx b/client/src/Components/v1/ActionsMenu/index.jsx index b469e6764..ea0d029b8 100644 --- a/client/src/Components/v1/ActionsMenu/index.jsx +++ b/client/src/Components/v1/ActionsMenu/index.jsx @@ -32,7 +32,6 @@ const ActionsMenu = ({ event.preventDefault(); event.stopPropagation(); let monitor = { id: actions.id }; - console.log(monitor); await deleteMonitor({ monitor }); updateRowCallback(); }; diff --git a/client/src/Features/Auth/authSlice.js b/client/src/Features/Auth/authSlice.js index 5ee31944a..b78a8b47e 100644 --- a/client/src/Features/Auth/authSlice.js +++ b/client/src/Features/Auth/authSlice.js @@ -10,21 +10,24 @@ const initialState = { msg: null, }; -export const register = createAsyncThunk("auth/register", async (form, thunkApi) => { - try { - const res = await networkService.registerUser(form); - return res.data; - } catch (error) { - if (error.response.data) { - return thunkApi.rejectWithValue(error.response.data); +export const register = createAsyncThunk( + "auth/register", + async ({ user, token }, thunkApi) => { + try { + const res = await networkService.registerUser(user, token); + return res.data; + } catch (error) { + if (error.response.data) { + return thunkApi.rejectWithValue(error.response.data); + } + const payload = { + status: false, + msg: error.message ? error.message : "Unknown error", + }; + return thunkApi.rejectWithValue(payload); } - const payload = { - status: false, - msg: error.message ? error.message : "Unknown error", - }; - return thunkApi.rejectWithValue(payload); } -}); +); export const login = createAsyncThunk("auth/login", async (form, thunkApi) => { try { diff --git a/client/src/Hooks/monitorHooks.js b/client/src/Hooks/monitorHooks.js index d865ffab8..4fdedbcff 100644 --- a/client/src/Hooks/monitorHooks.js +++ b/client/src/Hooks/monitorHooks.js @@ -342,7 +342,6 @@ export const useDeleteMonitor = () => { const deleteMonitor = async ({ monitor, redirect }) => { try { setIsLoading(true); - console.log(monitor); await networkService.deleteMonitorById({ monitorId: monitor.id }); createToast({ body: "Monitor deleted successfully!" }); if (redirect) { diff --git a/client/src/Pages/Auth/Register/index.jsx b/client/src/Pages/Auth/Register/index.jsx index 3345d8aa0..821c56427 100644 --- a/client/src/Pages/Auth/Register/index.jsx +++ b/client/src/Pages/Auth/Register/index.jsx @@ -176,7 +176,7 @@ const Register = ({ superAdminExists }) => { delete toSubmit.confirm; - const action = await dispatch(register(toSubmit)); + const action = await dispatch(register({ user: toSubmit, token })); if (action.payload.success) { navigate("/uptime"); createToast({ diff --git a/client/src/Pages/Uptime/Monitors/index.jsx b/client/src/Pages/Uptime/Monitors/index.jsx index 101020eea..67019362f 100644 --- a/client/src/Pages/Uptime/Monitors/index.jsx +++ b/client/src/Pages/Uptime/Monitors/index.jsx @@ -143,7 +143,6 @@ const UptimeMonitors = () => { type: ["http", "ping", "docker", "port", "game"], }); - console.log(monitors); useEffect(() => { if (isSearching) { setPage(undefined); diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index bd0e37b30..5b28281fe 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -347,17 +347,8 @@ class NetworkService { return this.axiosInstance.get(`/monitors/certificate/${config.monitorId}`); } - /** - * ************************************ - * Registers a new user - * ************************************ - * - * @async - * @param {Object} form - The form data for the new user to be registered. - * @returns {Promise} The response from the axios POST request. - */ - async registerUser(form) { - return this.axiosInstance.post(`/auth/register`, form); + async registerUser(user, token) { + return this.axiosInstance.post(`/auth/register`, { user, token }); } /** From 64dc381e0f527c7ec6f7214983d7c3a1674447c9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 16 Jan 2026 19:14:03 +0000 Subject: [PATCH 2/8] separate user from token --- server/src/controllers/authController.ts | 10 +- server/src/db/models/User.js | 105 ------------------ server/src/db/models/User.ts | 95 ++++++++++++++++ server/src/db/models/index.ts | 3 + server/src/repositories/index.ts | 3 + .../src/repositories/users/IUserRepository.ts | 9 ++ .../repositories/users/MongoUserRepository.ts | 4 + server/src/service/business/userService.ts | 5 +- server/src/types/index.ts | 1 + server/src/types/user.ts | 24 ++++ 10 files changed, 148 insertions(+), 111 deletions(-) delete mode 100755 server/src/db/models/User.js create mode 100644 server/src/db/models/User.ts create mode 100644 server/src/repositories/users/IUserRepository.ts create mode 100644 server/src/repositories/users/MongoUserRepository.ts create mode 100644 server/src/types/user.ts diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index d033aa39d..435813a36 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -32,11 +32,13 @@ class AuthController { registerUser = async (req: Request, res: Response, next: NextFunction) => { try { - if (req.body?.email) { - req.body.email = req.body.email?.toLowerCase(); + const newUser = req.body.user; + const newUserToken = req.body.token; + if (newUser?.email) { + newUser.email = newUser.email.toLowerCase(); } - await registrationBodyValidation.validateAsync(req.body); - const { user, token } = await this.userService.registerUser(req.body, req.file); + await registrationBodyValidation.validateAsync(newUser); + const { user, token } = await this.userService.registerUser(newUser, newUserToken, req.file); res.status(200).json({ success: true, msg: "User registered successfully", diff --git a/server/src/db/models/User.js b/server/src/db/models/User.js deleted file mode 100755 index bf3012b26..000000000 --- a/server/src/db/models/User.js +++ /dev/null @@ -1,105 +0,0 @@ -import mongoose from "mongoose"; -import bcrypt from "bcryptjs"; -import Monitor from "./Monitor.js"; -import Team from "./Team.js"; -import Notification from "./Notification.js"; - -const UserSchema = mongoose.Schema( - { - firstName: { - type: String, - required: true, - }, - lastName: { - type: String, - required: true, - }, - email: { - type: String, - required: true, - unique: true, - }, - password: { - type: String, - required: true, - }, - avatarImage: { - type: String, - }, - profileImage: { - data: Buffer, - contentType: String, - }, - isActive: { - type: Boolean, - default: true, - }, - isVerified: { - type: Boolean, - default: false, - }, - role: { - type: [String], - default: "user", - enum: ["user", "admin", "superadmin", "demo"], - }, - teamId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Team", - immutable: true, - }, - checkTTL: { - type: Number, - }, - }, - { - timestamps: true, - } -); - -UserSchema.pre("save", function (next) { - if (!this.isModified("password")) { - return next(); - } - const salt = bcrypt.genSaltSync(10); - this.password = bcrypt.hashSync(this.password, salt); - next(); -}); - -UserSchema.pre("findOneAndUpdate", function (next) { - const update = this.getUpdate(); - if ("password" in update) { - const salt = bcrypt.genSaltSync(10); - update.password = bcrypt.hashSync(update.password, salt); - } - - next(); -}); - -UserSchema.pre("findOneAndDelete", async function (next) { - try { - const userToDelete = await this.model.findOne(this.getFilter()); - if (!userToDelete) return next(); - if (userToDelete.role.includes("superadmin")) { - await Team.deleteOne({ _id: userToDelete.teamId }); - await Monitor.deleteMany({ userId: userToDelete._id }); - await this.model.deleteMany({ - teamId: userToDelete.teamId, - _id: { $ne: userToDelete._id }, - }); - await Notification.deleteMany({ teamId: userToDelete.teamId }); - } - next(); - } catch (error) { - next(error); - } -}); - -UserSchema.methods.comparePassword = async function (submittedPassword) { - const res = await bcrypt.compare(submittedPassword, this.password); - return res; -}; - -const User = mongoose.model("User", UserSchema); - -export default User; diff --git a/server/src/db/models/User.ts b/server/src/db/models/User.ts new file mode 100644 index 000000000..e00250340 --- /dev/null +++ b/server/src/db/models/User.ts @@ -0,0 +1,95 @@ +import { Schema, model, type Types } from "mongoose"; +import bcrypt from "bcryptjs"; +import type { User, UserProfileImage, UserRole } from "@/types/index.js"; +import { MonitorModel } from "@/db/models/index.js"; +import Team from "./Team.js"; +import Notification from "./Notification.js"; + +type UserDocumentBase = Omit & { + teamId?: Types.ObjectId; + profileImage?: Required; +}; + +interface UserDocument extends UserDocumentBase { + _id: Types.ObjectId; + teamId?: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const profileImageSchema = new Schema>( + { + data: { type: Buffer }, + contentType: { type: String }, + }, + { _id: false } +); + +const UserSchema = new Schema( + { + firstName: { type: String, required: true }, + lastName: { type: String, required: true }, + email: { type: String, required: true, unique: true }, + password: { type: String, required: true }, + avatarImage: { type: String }, + profileImage: { type: profileImageSchema }, + isActive: { type: Boolean, default: true }, + isVerified: { type: Boolean, default: false }, + role: { + type: [String], + enum: ["user", "admin", "superadmin", "demo" satisfies UserRole], + default: ["user"], + }, + teamId: { + type: Schema.Types.ObjectId, + ref: "Team", + immutable: true, + }, + checkTTL: { type: Number }, + }, + { timestamps: true } +); + +UserSchema.pre("save", function (next) { + if (!this.isModified("password")) { + return next(); + } + const salt = bcrypt.genSaltSync(10); + this.password = bcrypt.hashSync(this.password, salt); + next(); +}); + +UserSchema.pre("findOneAndUpdate", function (next) { + const update = this.getUpdate(); + if (update && "password" in update) { + const salt = bcrypt.genSaltSync(10); + (update as any).password = bcrypt.hashSync((update as any).password, salt); + } + next(); +}); + +UserSchema.pre("findOneAndDelete", async function (next) { + try { + const userToDelete = await this.model.findOne(this.getFilter()); + if (!userToDelete) return next(); + if (userToDelete.role.includes("superadmin")) { + await Team.deleteOne({ _id: userToDelete.teamId }); + await MonitorModel.deleteMany({ userId: userToDelete._id }); + await this.model.deleteMany({ teamId: userToDelete.teamId, _id: { $ne: userToDelete._id } }); + await Notification.deleteMany({ teamId: userToDelete.teamId }); + } + next(); + } catch (error) { + next(error as Error); + } +}); + +UserSchema.methods.comparePassword = async function (submittedPassword: string) { + return bcrypt.compare(submittedPassword, this.password); +}; + +const UserModel = model("User", UserSchema); + +export type { UserDocument }; +export { UserModel }; +export default UserModel; diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index b92ae18b7..d91aa0778 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -9,3 +9,6 @@ export { default as MonitorStatsModel } from "@/db/models/MonitorStats.js"; export * from "@/db/models/StatusPage.js"; export { default as StatusPageModel } from "@/db/models/StatusPage.js"; + +export * from "@/db/models/User.js"; +export { default as UserModel } from "@/db/models/User.js"; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 2c27591f1..92d9b2371 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -9,3 +9,6 @@ export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-s export * from "@/repositories/status-pages/IStatusPagesRepository.js"; export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js"; + +export * from "@/repositories/users/IUserRepository.js"; +export { default as MongoUserRepository } from "@/repositories/users/MongoUserRepository.js"; diff --git a/server/src/repositories/users/IUserRepository.ts b/server/src/repositories/users/IUserRepository.ts new file mode 100644 index 000000000..259ba781b --- /dev/null +++ b/server/src/repositories/users/IUserRepository.ts @@ -0,0 +1,9 @@ +import type { User } from "@/types/index.js"; + +export interface IUsersRepository { + // create + // fetch + // update + // delete + // other +} diff --git a/server/src/repositories/users/MongoUserRepository.ts b/server/src/repositories/users/MongoUserRepository.ts new file mode 100644 index 000000000..82c5eb5b6 --- /dev/null +++ b/server/src/repositories/users/MongoUserRepository.ts @@ -0,0 +1,4 @@ +import { IUsersRepository } from "@/repositories/index.js"; +class MongoUserRepository implements IUsersRepository {} + +export default MongoUserRepository; diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 129ed1e59..e409c9f98 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -1,4 +1,5 @@ import { IMonitorsRepository } from "@/repositories/index.js"; +import type { User } from "@/types/index.js"; const SERVICE_NAME = "userService"; @@ -62,12 +63,12 @@ class UserService { return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL }); }; - registerUser = async (user: any, file: any) => { + registerUser = async (user: Partial, inviteToken: string, file: any) => { // Create a new user // If superAdmin exists, a token should be attached to all further register requests const superAdminExists = await this.db.userModule.checkSuperadmin(); if (superAdminExists) { - const invitedUser = await this.db.inviteModule.getInviteTokenAndDelete(user.inviteToken); + const invitedUser = await this.db.inviteModule.getInviteTokenAndDelete(inviteToken); user.role = invitedUser.role; user.teamId = invitedUser.teamId; } else { diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 4f71788f2..b6495497d 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -3,3 +3,4 @@ export * from "@/types/monitor.js"; export * from "@/types/monitorStats.js"; export * from "@/types/statusPage.js"; export * from "@/types/network.js"; +export * from "@/types/user.js"; diff --git a/server/src/types/user.ts b/server/src/types/user.ts new file mode 100644 index 000000000..41c009791 --- /dev/null +++ b/server/src/types/user.ts @@ -0,0 +1,24 @@ +export const UserRoles = ["user", "admin", "superadmin", "demo"] as const; +export type UserRole = (typeof UserRoles)[number]; + +export interface UserProfileImage { + data?: Buffer; + contentType?: string; +} + +export interface User { + id: string; + firstName: string; + lastName: string; + email: string; + password: string; + avatarImage?: string; + profileImage?: UserProfileImage; + isActive: boolean; + isVerified: boolean; + role: UserRole[]; + teamId: string; + checkTTL?: number; + createdAt: string; + updatedAt: string; +} From cb5b8e0673124487380dffa689fbcef27aaa7758 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 16 Jan 2026 20:29:54 +0000 Subject: [PATCH 3/8] update user refactor --- server/src/config/services.ts | 17 ++- server/src/controllers/authController.ts | 1 + server/src/db/models/Invite.ts | 30 +++++ server/src/db/models/InviteToken.js | 35 ----- server/src/db/models/index.ts | 3 + server/src/repositories/index.ts | 7 +- .../invites/IInvitesRepository.ts | 11 ++ .../invites/MongoInviteRepository.ts | 45 +++++++ .../src/repositories/users/IUserRepository.ts | 9 -- .../repositories/users/IUsersRepository.ts | 12 ++ .../repositories/users/MongoUserRepository.ts | 4 - .../users/MongoUsersRepository.ts | 125 ++++++++++++++++++ server/src/service/business/userService.ts | 56 +++++--- server/src/types/index.ts | 1 + server/src/types/invite.ts | 12 ++ server/src/utils/utils.js | 5 - 16 files changed, 293 insertions(+), 80 deletions(-) create mode 100755 server/src/db/models/Invite.ts delete mode 100755 server/src/db/models/InviteToken.js create mode 100644 server/src/repositories/invites/IInvitesRepository.ts create mode 100644 server/src/repositories/invites/MongoInviteRepository.ts delete mode 100644 server/src/repositories/users/IUserRepository.ts create mode 100644 server/src/repositories/users/IUsersRepository.ts delete mode 100644 server/src/repositories/users/MongoUserRepository.ts create mode 100644 server/src/repositories/users/MongoUsersRepository.ts create mode 100644 server/src/types/invite.ts diff --git a/server/src/config/services.ts b/server/src/config/services.ts index af49cc2d4..66fb18f47 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -37,9 +37,6 @@ import crypto from "crypto"; import { games, GameDig } from "gamedig"; import jmespath from "jmespath"; -import { fileURLToPath } from "url"; -import { ObjectId } from "mongodb"; - // DB Modules import { NormalizeData, NormalizeDataUptimeDetails } from "../utils/dataUtils.js"; import { GenerateAvatarImage } from "../utils/imageProcessing.js"; @@ -48,7 +45,7 @@ import { ParseBoolean } from "../utils/utils.js"; // Models import Monitor from "../db/models/Monitor.js"; import User from "../db/models/User.js"; -import InviteToken from "../db/models/InviteToken.js"; +import InviteToken from "../db/models/Invite.js"; import StatusPage from "../db/models/StatusPage.js"; import Team from "../db/models/Team.js"; import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; @@ -74,10 +71,14 @@ import { MongoChecksRepository, MongoMonitorStatsRepository, MongoStatusPagesRepository, + MongoUsersRepository, + MongoInvitesRepository, IMonitorsRepository, IChecksRepository, IMonitorStatsRepository, IStatusPagesRepository, + IUsersRepository, + IInvitesRepository, } from "@/repositories/index.js"; export type InitializedSerivces = { @@ -107,6 +108,8 @@ export type InitializedSerivces = { checksRepository: IChecksRepository; monitorStatsRepository: IMonitorStatsRepository; statusPagesRepository: IStatusPagesRepository; + usersRepository: IUsersRepository; + invitesRepository: IInvitesRepository; }; export const initializeServices = async ({ @@ -158,6 +161,8 @@ export const initializeServices = async ({ const checksRepository = new MongoChecksRepository(logger); const monitorStatsRepository = new MongoMonitorStatsRepository(); const statusPagesRepository = new MongoStatusPagesRepository(); + const usersRepository = new MongoUsersRepository(); + const invitesRepository = new MongoInvitesRepository(); const networkService = new NetworkService({ axios, @@ -240,6 +245,8 @@ export const initializeServices = async ({ errorService, jobQueue: superSimpleQueue, monitorsRepository, + usersRepository, + invitesRepository, }); const diagnosticService = new DiagnosticService(); @@ -298,6 +305,8 @@ export const initializeServices = async ({ checksRepository, monitorStatsRepository, statusPagesRepository, + usersRepository, + invitesRepository, }; Object.values(services).forEach((service) => { diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index 435813a36..1b76b249e 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -38,6 +38,7 @@ class AuthController { newUser.email = newUser.email.toLowerCase(); } await registrationBodyValidation.validateAsync(newUser); + const { user, token } = await this.userService.registerUser(newUser, newUserToken, req.file); res.status(200).json({ success: true, diff --git a/server/src/db/models/Invite.ts b/server/src/db/models/Invite.ts new file mode 100755 index 000000000..25ddd9a72 --- /dev/null +++ b/server/src/db/models/Invite.ts @@ -0,0 +1,30 @@ +import { Schema, model, type Types } from "mongoose"; +import type { Invite } from "@/types/invite.js"; + +type InviteDocumentBase = Omit & { + teamId: Types.ObjectId; + expiry: Date; +}; + +interface InviteDocument extends InviteDocumentBase { + _id: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const InviteSchema = new Schema( + { + email: { type: String, required: true, unique: true }, + teamId: { type: Schema.Types.ObjectId, ref: "Team", immutable: true, required: true }, + role: { type: [String], required: true, default: ["user"] }, + token: { type: String, required: true }, + expiry: { type: Date, default: Date.now, expires: 3600 }, + }, + { timestamps: true } +); + +const InviteModel = model("Invite", InviteSchema); + +export type { InviteDocument }; +export { InviteModel }; +export default InviteModel; diff --git a/server/src/db/models/InviteToken.js b/server/src/db/models/InviteToken.js deleted file mode 100755 index 079600647..000000000 --- a/server/src/db/models/InviteToken.js +++ /dev/null @@ -1,35 +0,0 @@ -import mongoose from "mongoose"; -const InviteTokenSchema = mongoose.Schema( - { - email: { - type: String, - required: true, - unique: true, - }, - teamId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Team", - immutable: true, - required: true, - }, - role: { - type: Array, - required: true, - default: ["user"], - }, - token: { - type: String, - required: true, - }, - expiry: { - type: Date, - default: Date.now, - expires: 3600, - }, - }, - { - timestamps: true, - } -); - -export default mongoose.model("InviteToken", InviteTokenSchema); diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index d91aa0778..d378c6750 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -12,3 +12,6 @@ export { default as StatusPageModel } from "@/db/models/StatusPage.js"; export * from "@/db/models/User.js"; export { default as UserModel } from "@/db/models/User.js"; + +export * from "@/db/models/Invite.js"; +export { default as InviteModel } from "@/db/models/Invite.js"; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 92d9b2371..7afe3a9f4 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -10,5 +10,8 @@ export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-s export * from "@/repositories/status-pages/IStatusPagesRepository.js"; export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js"; -export * from "@/repositories/users/IUserRepository.js"; -export { default as MongoUserRepository } from "@/repositories/users/MongoUserRepository.js"; +export * from "@/repositories/users/IUsersRepository.js"; +export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js"; + +export * from "@/repositories/invites/IInvitesRepository.js"; +export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js"; diff --git a/server/src/repositories/invites/IInvitesRepository.ts b/server/src/repositories/invites/IInvitesRepository.ts new file mode 100644 index 000000000..066a51a0f --- /dev/null +++ b/server/src/repositories/invites/IInvitesRepository.ts @@ -0,0 +1,11 @@ +import type { Invite } from "@/types/index.js"; + +export interface IInvitesRepository { + // create + // fetch + findByTokenAndDelete(token: string): Promise; + // update + + // delete + // other +} diff --git a/server/src/repositories/invites/MongoInviteRepository.ts b/server/src/repositories/invites/MongoInviteRepository.ts new file mode 100644 index 000000000..16e56440b --- /dev/null +++ b/server/src/repositories/invites/MongoInviteRepository.ts @@ -0,0 +1,45 @@ +import { IInvitesRepository } from "@/repositories/index.js"; +import type { Invite } from "@/types/index.js"; +import { type InviteDocument, InviteModel } from "@/db/models/index.js"; +import { AppError } from "@/utils/AppError.js"; +import mongoose from "mongoose"; + +class MongoInvitesRepository implements IInvitesRepository { + private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { + if (!value) { + return ""; + } + return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value); + }; + + private toDateString = (value?: Date | string | null): string => { + if (!value) { + return new Date(0).toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }; + + protected toEntity = (doc: InviteDocument): Invite => { + return { + id: this.toStringId(doc._id), + email: doc.email, + teamId: this.toStringId(doc.teamId), + role: doc.role ?? [], + token: doc.token, + expiry: this.toDateString(doc.expiry), + createdAt: this.toDateString(doc.createdAt), + updatedAt: this.toDateString(doc.updatedAt), + }; + }; + + findByTokenAndDelete = async (token: string) => { + const invite = await InviteModel.findOneAndDelete({ + token, + }); + if (invite === null) { + throw new AppError({ message: "Invite not found", status: 404 }); + } + return this.toEntity(invite); + }; +} +export default MongoInvitesRepository; diff --git a/server/src/repositories/users/IUserRepository.ts b/server/src/repositories/users/IUserRepository.ts deleted file mode 100644 index 259ba781b..000000000 --- a/server/src/repositories/users/IUserRepository.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { User } from "@/types/index.js"; - -export interface IUsersRepository { - // create - // fetch - // update - // delete - // other -} diff --git a/server/src/repositories/users/IUsersRepository.ts b/server/src/repositories/users/IUsersRepository.ts new file mode 100644 index 000000000..9678de47a --- /dev/null +++ b/server/src/repositories/users/IUsersRepository.ts @@ -0,0 +1,12 @@ +import type { User } from "@/types/index.js"; +export interface IUsersRepository { + // create + create(user: Partial, imageFile?: Express.Multer.File | null): Promise; + // fetch + findByEmail(email: string): Promise; + // update + updateById(id: string, patch: Partial, file?: Express.Multer.File | null): Promise; + // delete + // other + findSuperAdmin(): Promise; +} diff --git a/server/src/repositories/users/MongoUserRepository.ts b/server/src/repositories/users/MongoUserRepository.ts deleted file mode 100644 index 82c5eb5b6..000000000 --- a/server/src/repositories/users/MongoUserRepository.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { IUsersRepository } from "@/repositories/index.js"; -class MongoUserRepository implements IUsersRepository {} - -export default MongoUserRepository; diff --git a/server/src/repositories/users/MongoUsersRepository.ts b/server/src/repositories/users/MongoUsersRepository.ts new file mode 100644 index 000000000..b2687ff4a --- /dev/null +++ b/server/src/repositories/users/MongoUsersRepository.ts @@ -0,0 +1,125 @@ +import mongoose from "mongoose"; +import { IUsersRepository } from "@/repositories/index.js"; +import { UserModel, type UserDocument } from "@/db/models/index.js"; +import type { User, UserProfileImage } from "@/types/index.js"; +import { GenerateAvatarImage } from "@/utils/imageProcessing.js"; +import { ParseBoolean } from "@/utils/utils.js"; +import { AppError } from "@/utils/AppError.js"; +const SERVICE_NAME = "MongoUsersRepository"; + +class MongoUsersRepository implements IUsersRepository { + static SERVICE_NAME = SERVICE_NAME; + private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { + if (!value) { + return ""; + } + return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value); + }; + + private toDateString = (value?: Date | string | null): string => { + if (!value) { + return new Date(0).toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }; + + private mapProfileImage = (image?: (UserProfileImage & { data?: Buffer }) | null) => { + if (!image) { + return undefined; + } + return { + data: image.data, + contentType: image.contentType, + }; + }; + + protected toEntity = (doc: UserDocument): User => { + return { + id: this.toStringId(doc._id), + firstName: doc.firstName, + lastName: doc.lastName, + email: doc.email, + password: doc.password, + avatarImage: doc.avatarImage ?? undefined, + profileImage: this.mapProfileImage(doc.profileImage), + isActive: doc.isActive ?? false, + isVerified: doc.isVerified ?? false, + role: doc.role ?? [], + teamId: this.toStringId(doc.teamId), + checkTTL: doc.checkTTL ?? undefined, + createdAt: this.toDateString(doc.createdAt), + updatedAt: this.toDateString(doc.updatedAt), + }; + }; + + create = async (user: Partial, imageFile: Express.Multer.File | null) => { + if (imageFile) { + // 1. Save the full size image + user.profileImage = { + data: imageFile.buffer, + contentType: imageFile.mimetype, + }; + + // 2. Get the avatar sized image + const avatar = await GenerateAvatarImage(imageFile); + user.avatarImage = avatar; + } + + const newUser = new UserModel(user); + await newUser.save(); + const sanitizedUser = await UserModel.findOne({ _id: newUser._id }).select("-password").select("-profileImage"); + if (!sanitizedUser) { + throw new AppError({ message: "Failed to create user", service: SERVICE_NAME, status: 500 }); + } + return this.toEntity(sanitizedUser); + }; + + findByEmail = async (email: string) => { + const user = await UserModel.findOne({ email: email }).select("-profileImage"); + if (!user) { + throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(user); + }; + + updateById = async (id: string, patch: Partial, file?: Express.Multer.File | null): Promise => { + const candidateUser = { ...patch }; + + if (ParseBoolean(candidateUser.deleteProfileImage) === true) { + candidateUser.profileImage = undefined; + candidateUser.avatarImage = undefined; + } else if (file) { + // 1. Save the full size image + candidateUser.profileImage = { + data: file.buffer, + contentType: file.mimetype, + }; + + // 2. Get the avatar sized image + const avatar = await GenerateAvatarImage(file); + candidateUser.avatarImage = avatar; + } + + const updatedUser = await UserModel.findOneAndUpdate( + { _id: id }, + candidateUser, + { new: true } // Returns updated user instead of pre-update user + ) + .select("-password") + .select("-profileImage"); + if (!updatedUser) { + throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(updatedUser); + }; + + findSuperAdmin = async () => { + const superAdmin = await UserModel.findOne({ role: "superadmin" }); + if (superAdmin !== null) { + return true; + } + return false; + }; +} + +export default MongoUsersRepository; diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index e409c9f98..dca362ebe 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -1,5 +1,7 @@ -import { IMonitorsRepository } from "@/repositories/index.js"; +import { IInvitesRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; +import Team from "@/db/models/Team.js"; import type { User } from "@/types/index.js"; +import bcrypt from "bcryptjs"; const SERVICE_NAME = "userService"; @@ -16,6 +18,8 @@ class UserService { private jobQueue: any; private crypto: any; private monitorsRepository: IMonitorsRepository; + private usersRepository: IUsersRepository; + private invitesRepository: IInvitesRepository; constructor({ crypto, @@ -28,6 +32,8 @@ class UserService { errorService, jobQueue, monitorsRepository, + usersRepository, + invitesRepository, }: { crypto: any; db: any; @@ -39,6 +45,8 @@ class UserService { errorService: any; jobQueue: any; monitorsRepository: IMonitorsRepository; + usersRepository: IUsersRepository; + invitesRepository: IInvitesRepository; }) { this.db = db; this.emailService = emailService; @@ -50,6 +58,8 @@ class UserService { this.jobQueue = jobQueue; this.crypto = crypto; this.monitorsRepository = monitorsRepository; + this.usersRepository = usersRepository; + this.invitesRepository = invitesRepository; } get serviceName() { @@ -66,33 +76,37 @@ class UserService { registerUser = async (user: Partial, inviteToken: string, file: any) => { // Create a new user // If superAdmin exists, a token should be attached to all further register requests - const superAdminExists = await this.db.userModule.checkSuperadmin(); + const superAdminExists = await this.usersRepository.findSuperAdmin(); if (superAdminExists) { - const invitedUser = await this.db.inviteModule.getInviteTokenAndDelete(inviteToken); - user.role = invitedUser.role; - user.teamId = invitedUser.teamId; + const invite = await this.invitesRepository.findByTokenAndDelete(inviteToken); + user.role = invite.role; + user.teamId = invite.teamId; } else { // This is the first account, create JWT secret to use if one is not supplied by env const jwtSecret = this.crypto.randomBytes(64).toString("hex"); await this.db.settingsModule.updateAppSettings({ jwtSecret }); + // Create a new team + const team = new Team({ + email: user.email, + }); + user.teamId = team._id; } - const newUser = await this.db.userModule.insertUser({ ...user }, file); + const newUser = await this.usersRepository.create({ ...user }, file); this.logger.debug({ message: "New user created", service: SERVICE_NAME, method: "registerUser", - details: newUser._id, + details: newUser.id, }); - const userForToken = { ...newUser._doc }; - delete userForToken.profileImage; - delete userForToken.avatarImage; + delete newUser.profileImage; + delete newUser.avatarImage; const appSettings = await this.settingsService.getSettings(); - const token = this.issueToken(userForToken, appSettings); + const token = this.issueToken(newUser, appSettings); try { const html = await this.emailService.buildEmail("welcomeEmailTemplate", { @@ -120,17 +134,18 @@ class UserService { loginUser = async (email: string, password: string) => { // Check if user exists - const user = await this.db.userModule.getUserByEmail(email); + const user = await this.usersRepository.findByEmail(email); // Compare password - const match = await user.comparePassword(password); + const match = await bcrypt.compare(password, user.password); + if (match !== true) { throw this.errorService.createAuthenticationError(this.stringService.authIncorrectPassword); } // Remove password from user object. Should this be abstracted to DB layer? - const userWithoutPassword = { ...user._doc }; - delete userWithoutPassword.password; - delete userWithoutPassword.avatarImage; + const userWithoutPassword = { ...user }; + userWithoutPassword.password = ""; + userWithoutPassword.avatarImage = ""; // Happy path, return token const appSettings = await this.settingsService.getSettings(); @@ -140,16 +155,16 @@ class UserService { return { user: userWithoutPassword, token }; }; - editUser = async (updates: any, file: any, currentUser: any) => { + editUser = async (updates: Partial, file: any, currentUser: any) => { // Change Password check if (updates?.password && updates?.newPassword) { // Get user's email // Add user email to body for DB operation updates.email = currentUser.email; // Get user - const user = await this.db.userModule.getUserByEmail(currentUser.email); + const user = await this.usersRepository.findByEmail(currentUser.email); // Compare passwords - const match = await user.comparePassword(updates?.password); + const match = await bcrypt.compare(updates?.password, user.password); // If not a match, throw a 403 // 403 instead of 401 to avoid triggering axios interceptor if (!match) { @@ -159,8 +174,7 @@ class UserService { updates.password = updates.newPassword; } - const updatedUser = await this.db.userModule.updateUser({ userId: currentUser?._id, user: updates, file: file }); - return updatedUser; + return await this.usersRepository.updateById(currentUser.id, updates, file); }; checkSuperadminExists = async () => { diff --git a/server/src/types/index.ts b/server/src/types/index.ts index b6495497d..9416de9fa 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -4,3 +4,4 @@ export * from "@/types/monitorStats.js"; export * from "@/types/statusPage.js"; export * from "@/types/network.js"; export * from "@/types/user.js"; +export * from "@/types/invite.js"; diff --git a/server/src/types/invite.ts b/server/src/types/invite.ts new file mode 100644 index 000000000..8e65d54e9 --- /dev/null +++ b/server/src/types/invite.ts @@ -0,0 +1,12 @@ +import { UserRole } from "./user.js"; + +export interface Invite { + id: string; + email: string; + teamId: string; + role: UserRole[]; + token: string; + expiry: string; + createdAt: string; + updatedAt: string; +} diff --git a/server/src/utils/utils.js b/server/src/utils/utils.js index bcb4d8e5e..ee4ee1c6d 100755 --- a/server/src/utils/utils.js +++ b/server/src/utils/utils.js @@ -1,8 +1,3 @@ -/** - * Converts a request body parameter to a boolean. - * @param {string | boolean} value - * @returns {boolean} - */ const ParseBoolean = (value) => { if (value === true || value === "true") { return true; From 9c92542653a0e040e1a771e20e777ff7f8ed9732 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 16 Jan 2026 22:03:35 +0000 Subject: [PATCH 4/8] recovery --- server/src/config/services.ts | 6 ++ server/src/db/models/RecoveryToken.js | 25 -------- server/src/db/models/RecoveryToken.ts | 27 ++++++++ server/src/db/models/index.ts | 3 + server/src/repositories/index.ts | 3 + .../IRecoveryTokensRepository.ts | 12 ++++ .../MongoRecoveryTokensRepository.ts | 61 +++++++++++++++++++ server/src/service/business/userService.ts | 40 +++++++++--- .../service/infrastructure/emailService.js | 2 +- server/src/types/index.ts | 1 + server/src/types/invite.ts | 2 +- server/src/types/recoveryToken.ts | 8 +++ 12 files changed, 154 insertions(+), 36 deletions(-) delete mode 100755 server/src/db/models/RecoveryToken.js create mode 100644 server/src/db/models/RecoveryToken.ts create mode 100644 server/src/repositories/recovery-tokens/IRecoveryTokensRepository.ts create mode 100644 server/src/repositories/recovery-tokens/MongoRecoveryTokensRepository.ts create mode 100644 server/src/types/recoveryToken.ts diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 66fb18f47..aef32440f 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -73,12 +73,14 @@ import { MongoStatusPagesRepository, MongoUsersRepository, MongoInvitesRepository, + MongoRecoveryTokensRepository, IMonitorsRepository, IChecksRepository, IMonitorStatsRepository, IStatusPagesRepository, IUsersRepository, IInvitesRepository, + IRecoveryTokensRepository, } from "@/repositories/index.js"; export type InitializedSerivces = { @@ -110,6 +112,7 @@ export type InitializedSerivces = { statusPagesRepository: IStatusPagesRepository; usersRepository: IUsersRepository; invitesRepository: IInvitesRepository; + recoveryTokensRepository: IRecoveryTokensRepository; }; export const initializeServices = async ({ @@ -163,6 +166,7 @@ export const initializeServices = async ({ const statusPagesRepository = new MongoStatusPagesRepository(); const usersRepository = new MongoUsersRepository(); const invitesRepository = new MongoInvitesRepository(); + const recoveryTokensRepository = new MongoRecoveryTokensRepository(); const networkService = new NetworkService({ axios, @@ -247,6 +251,7 @@ export const initializeServices = async ({ monitorsRepository, usersRepository, invitesRepository, + recoveryTokensRepository, }); const diagnosticService = new DiagnosticService(); @@ -307,6 +312,7 @@ export const initializeServices = async ({ statusPagesRepository, usersRepository, invitesRepository, + recoveryTokensRepository, }; Object.values(services).forEach((service) => { diff --git a/server/src/db/models/RecoveryToken.js b/server/src/db/models/RecoveryToken.js deleted file mode 100755 index 2219a4bca..000000000 --- a/server/src/db/models/RecoveryToken.js +++ /dev/null @@ -1,25 +0,0 @@ -import mongoose from "mongoose"; - -const RecoveryTokenSchema = mongoose.Schema( - { - email: { - type: String, - required: true, - unique: true, - }, - token: { - type: String, - required: true, - }, - expiry: { - type: Date, - default: Date.now, - expires: 600, - }, - }, - { - timestamps: true, - } -); - -export default mongoose.model("RecoveryToken", RecoveryTokenSchema); diff --git a/server/src/db/models/RecoveryToken.ts b/server/src/db/models/RecoveryToken.ts new file mode 100644 index 000000000..07b697d9e --- /dev/null +++ b/server/src/db/models/RecoveryToken.ts @@ -0,0 +1,27 @@ +import { Schema, model, type Types } from "mongoose"; +import type { RecoveryToken as RecoveryTokenEntity } from "@/types/recoveryToken.js"; + +type RecoveryTokenDocumentBase = Omit & { + expiry: Date; +}; + +interface RecoveryTokenDocument extends RecoveryTokenDocumentBase { + _id: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const RecoveryTokenSchema = new Schema( + { + email: { type: String, required: true, unique: true }, + token: { type: String, required: true }, + expiry: { type: Date, default: Date.now, expires: 600 }, + }, + { timestamps: true } +); + +const RecoveryTokenModel = model("RecoveryToken", RecoveryTokenSchema); + +export type { RecoveryTokenDocument }; +export { RecoveryTokenModel }; +export default RecoveryTokenModel; diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index d378c6750..1e5024b1c 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -15,3 +15,6 @@ export { default as UserModel } from "@/db/models/User.js"; export * from "@/db/models/Invite.js"; export { default as InviteModel } from "@/db/models/Invite.js"; + +export * from "@/db/models/RecoveryToken.js"; +export { default as RecoveryTokenModel } from "@/db/models/RecoveryToken.js"; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 7afe3a9f4..7a3c08c81 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -15,3 +15,6 @@ export { default as MongoUsersRepository } from "@/repositories/users/MongoUsers export * from "@/repositories/invites/IInvitesRepository.js"; export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js"; + +export * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js"; +export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.js"; diff --git a/server/src/repositories/recovery-tokens/IRecoveryTokensRepository.ts b/server/src/repositories/recovery-tokens/IRecoveryTokensRepository.ts new file mode 100644 index 000000000..9df1fa793 --- /dev/null +++ b/server/src/repositories/recovery-tokens/IRecoveryTokensRepository.ts @@ -0,0 +1,12 @@ +import type { RecoveryToken } from "@/types/recoveryToken.js"; + +export interface IRecoveryTokensRepository { + // create + create(email: string): Promise; + // fetch + findByToken(token: string): Promise; + // update + // delete + deleteManyByEmail(email: string): Promise; + // other +} diff --git a/server/src/repositories/recovery-tokens/MongoRecoveryTokensRepository.ts b/server/src/repositories/recovery-tokens/MongoRecoveryTokensRepository.ts new file mode 100644 index 000000000..4c9f9ebb6 --- /dev/null +++ b/server/src/repositories/recovery-tokens/MongoRecoveryTokensRepository.ts @@ -0,0 +1,61 @@ +import type { RecoveryToken } from "@/types/index.js"; +import type { IRecoveryTokensRepository } from "./IRecoveryTokensRepository.js"; +import type { RecoveryTokenDocument } from "@/db/models/RecoveryToken.js"; +import { RecoveryTokenModel } from "@/db/models/RecoveryToken.js"; +import mongoose from "mongoose"; +import crypto from "crypto"; +import { AppError } from "@/utils/AppError.js"; +const SERVICE_NAME = "MongoRecoveryTokensRepository"; + +class MongoRecoveryTokensRepository implements IRecoveryTokensRepository { + static SERVICE_NAME = SERVICE_NAME; + private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { + if (!value) { + return ""; + } + return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value); + }; + + private toDateString = (value?: Date | string | null): string => { + if (!value) { + return new Date(0).toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }; + + protected toEntity = (doc: RecoveryTokenDocument): RecoveryToken => { + return { + id: this.toStringId(doc._id), + email: doc.email, + token: doc.token, + expiry: this.toDateString(doc.expiry), + createdAt: this.toDateString(doc.createdAt), + updatedAt: this.toDateString(doc.updatedAt), + }; + }; + + create = async (email: string): Promise => { + const token = await RecoveryTokenModel.create({ + email, + token: crypto.randomBytes(32).toString("hex"), + }); + return this.toEntity(token); + }; + + findByToken = async (token: string): Promise => { + const recoveryToken = await RecoveryTokenModel.findOne({ token }); + if (!recoveryToken) { + throw new AppError({ message: "Recovery token not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(recoveryToken); + }; + + deleteManyByEmail = async (email: string) => { + const result = await RecoveryTokenModel.deleteMany({ + email, + }); + return Promise.resolve(result.deletedCount || 0); + }; +} + +export default MongoRecoveryTokensRepository; diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index dca362ebe..efd599299 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -1,7 +1,8 @@ -import { IInvitesRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; +import { IInvitesRepository, IMonitorsRepository, IRecoveryTokensRepository, IUsersRepository } from "@/repositories/index.js"; import Team from "@/db/models/Team.js"; import type { User } from "@/types/index.js"; import bcrypt from "bcryptjs"; +import { AppError } from "@/utils/AppError.js"; const SERVICE_NAME = "userService"; @@ -20,6 +21,7 @@ class UserService { private monitorsRepository: IMonitorsRepository; private usersRepository: IUsersRepository; private invitesRepository: IInvitesRepository; + private recoveryTokensRepository: IRecoveryTokensRepository; constructor({ crypto, @@ -34,6 +36,7 @@ class UserService { monitorsRepository, usersRepository, invitesRepository, + recoveryTokensRepository, }: { crypto: any; db: any; @@ -47,6 +50,7 @@ class UserService { monitorsRepository: IMonitorsRepository; usersRepository: IUsersRepository; invitesRepository: IInvitesRepository; + recoveryTokensRepository: IRecoveryTokensRepository; }) { this.db = db; this.emailService = emailService; @@ -60,6 +64,7 @@ class UserService { this.monitorsRepository = monitorsRepository; this.usersRepository = usersRepository; this.invitesRepository = invitesRepository; + this.recoveryTokensRepository = recoveryTokensRepository; } get serviceName() { @@ -178,13 +183,15 @@ class UserService { }; checkSuperadminExists = async () => { - const superAdminExists = await this.db.userModule.checkSuperadmin(); - return superAdminExists; + return await this.usersRepository.findSuperAdmin(); }; requestRecovery = async (email: string) => { const user = await this.db.userModule.getUserByEmail(email); - const recoveryToken = await this.db.recoveryModule.requestRecoveryToken(email); + + // Delete existing tokens + await this.recoveryTokensRepository.deleteManyByEmail(email); + const recoveryToken = await this.recoveryTokensRepository.create(email); const name = user.firstName; const { clientHost } = this.settingsService.getSettings(); const url = `${clientHost}/set-new-password/${recoveryToken.token}`; @@ -199,14 +206,29 @@ class UserService { }; validateRecovery = async (recoveryToken: string) => { - await this.db.recoveryModule.validateRecoveryToken(recoveryToken); + // Throws if token not found, validating + await this.recoveryTokensRepository.findByToken(recoveryToken); }; resetPassword = async (password: string, recoveryToken: string) => { - const user = await this.db.recoveryModule.resetPassword(password, recoveryToken); - const appSettings = await this.settingsService.getSettings(); - const token = this.issueToken(user._doc, appSettings); - return { user, token }; + const existingToken = await this.recoveryTokensRepository.findByToken(recoveryToken); + const existingUser = await this.usersRepository.findByEmail(existingToken.email); + + const match = await bcrypt.compare(password, existingUser.password); + if (match === true) { + throw new AppError({ message: "New password cannot be same as old password", service: SERVICE_NAME, status: 400 }); + } + + existingUser.password = password; + await this.usersRepository.updateById(existingUser.id, existingUser, null); + await this.recoveryTokensRepository.deleteManyByEmail(existingUser.email); + + existingUser.password = ""; + existingUser.profileImage = undefined; + + const token = this.issueToken(existingUser, await this.settingsService.getSettings()); + + return { user: existingUser, token }; }; deleteUser = async (user: any) => { diff --git a/server/src/service/infrastructure/emailService.js b/server/src/service/infrastructure/emailService.js index ed661ff16..bc040d51a 100755 --- a/server/src/service/infrastructure/emailService.js +++ b/server/src/service/infrastructure/emailService.js @@ -46,7 +46,7 @@ class EmailService { */ this.loadTemplate = (templateName) => { try { - const templatePath = this.path.join(__dirname, `../../../templates/${templateName}.mjml`); + const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`); const templateContent = this.fs.readFileSync(templatePath, "utf8"); return this.compile(templateContent); } catch (error) { diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 9416de9fa..1a43d4849 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -5,3 +5,4 @@ export * from "@/types/statusPage.js"; export * from "@/types/network.js"; export * from "@/types/user.js"; export * from "@/types/invite.js"; +export * from "@/types/recoveryToken.js"; diff --git a/server/src/types/invite.ts b/server/src/types/invite.ts index 8e65d54e9..cdbdb37e5 100644 --- a/server/src/types/invite.ts +++ b/server/src/types/invite.ts @@ -1,4 +1,4 @@ -import { UserRole } from "./user.js"; +import type { UserRole } from "@/types/user.js"; export interface Invite { id: string; diff --git a/server/src/types/recoveryToken.ts b/server/src/types/recoveryToken.ts new file mode 100644 index 000000000..488e1f4c1 --- /dev/null +++ b/server/src/types/recoveryToken.ts @@ -0,0 +1,8 @@ +export interface RecoveryToken { + id: string; + email: string; + token: string; + expiry: string; + createdAt: string; + updatedAt: string; +} From b47f3710fe6dac3abb27d2bf7e51ea2bad78009b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 16 Jan 2026 22:41:12 +0000 Subject: [PATCH 5/8] refactor user-service --- server/src/controllers/authController.ts | 7 +++-- server/src/controllers/incidentController.ts | 2 +- .../maintenanceWindowController.ts | 4 +-- server/src/controllers/monitorController.ts | 8 ++--- .../src/controllers/notificationController.ts | 2 +- .../src/controllers/statusPageController.ts | 6 ++-- .../v1/{verifyJWT.js => verifyJWT.ts} | 18 ++++-------- .../repositories/users/IUsersRepository.ts | 3 ++ .../users/MongoUsersRepository.ts | 29 +++++++++++++++++++ server/src/service/business/userService.ts | 24 ++++++++------- server/src/types/express.d.ts | 4 +-- 11 files changed, 69 insertions(+), 38 deletions(-) rename server/src/middleware/v1/{verifyJWT.js => verifyJWT.ts} (74%) diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index 1b76b249e..23ea6e42e 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -192,7 +192,8 @@ class AuthController { editUserById = async (req: Request, res: Response, next: NextFunction) => { try { const roles = req?.user?.role; - if (!roles.includes("superadmin")) { + + if (!roles || !roles.includes("superadmin")) { throw new AppError({ message: "Unauthorized", status: 403 }); } @@ -201,7 +202,7 @@ class AuthController { await editUserByIdParamValidation.validateAsync(req.params); // If this is superadmin self edit, allow "superadmin" role - if (userId === req.user._id) { + if (userId === req.user?.id) { await editSuperadminUserByIdBodyValidation.validateAsync(req.body); } else { await editUserByIdBodyValidation.validateAsync(req.body); @@ -217,7 +218,7 @@ class AuthController { editUserPasswordById = async (req: Request, res: Response, next: NextFunction) => { try { const roles = req?.user?.role; - if (!roles.includes("superadmin")) { + if (!roles || !roles.includes("superadmin")) { throw new AppError({ message: "Unauthorized", status: 403 }); } diff --git a/server/src/controllers/incidentController.ts b/server/src/controllers/incidentController.ts index 8a56b5ef8..b17788d80 100644 --- a/server/src/controllers/incidentController.ts +++ b/server/src/controllers/incidentController.ts @@ -69,7 +69,7 @@ class IncidentController { try { const resolvedIncident = await this.incidentService.resolveIncidentManually({ incidentId: req?.params?.incidentId, - userId: req?.user?._id, + userId: req?.user?.id, teamId: req?.user?.teamId, comment: req?.body?.comment, }); diff --git a/server/src/controllers/maintenanceWindowController.ts b/server/src/controllers/maintenanceWindowController.ts index afeba9219..3d212769f 100644 --- a/server/src/controllers/maintenanceWindowController.ts +++ b/server/src/controllers/maintenanceWindowController.ts @@ -46,7 +46,7 @@ class MaintenanceWindowController { try { await getMaintenanceWindowByIdParamValidation.validateAsync(req.params); - const teamId = req.user.teamId; + const teamId = req.user?.teamId; if (!teamId) { throw new AppError({ message: "Team ID is required", status: 400 }); } @@ -130,7 +130,7 @@ class MaintenanceWindowController { await editMaintenanceWindowByIdParamValidation.validateAsync(req.params); await editMaintenanceByIdWindowBodyValidation.validateAsync(req.body); - const teamId = req.user.teamId; + const teamId = req.user?.teamId; if (!teamId) { throw new AppError({ message: "Team ID is required", status: 400 }); } diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index d46845026..0b0184bae 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -164,7 +164,7 @@ class MonitorController { try { await createMonitorBodyValidation.validateAsync(req.body); - const userId = requireString(req?.user?._id, "User ID"); + const userId = requireString(req?.user?.id, "User ID"); const teamId = requireTeamId(req?.user?.teamId); const monitor = await this.monitorService.createMonitor(teamId, userId, req.body); @@ -193,7 +193,7 @@ class MonitorController { throw new AppError({ message: "File is empty", status: 400 }); } - const userId = requireString(req?.user?._id, "User ID"); + const userId = requireString(req?.user?.id, "User ID"); const teamId = requireTeamId(req?.user?.teamId); const fileData = req?.file?.buffer?.toString("utf-8"); @@ -286,9 +286,9 @@ class MonitorController { addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => { try { - const _id = requireString(req?.user?._id, "User ID"); + const id = requireString(req?.user?.id, "User ID"); const teamId = requireTeamId(req?.user?.teamId); - const demoMonitors = await this.monitorService.addDemoMonitors({ userId: _id, teamId }); + const demoMonitors = await this.monitorService.addDemoMonitors({ userId: id, teamId }); return res.status(200).json({ success: true, diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index defae2fd3..6f4a7f164 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -53,7 +53,7 @@ class NotificationController { throw new AppError({ message: "Team ID is required", status: 400 }); } - const userId = req?.user?._id; + const userId = req?.user?.id; if (!userId) { throw new AppError({ message: "User ID is required", status: 400 }); } diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts index 105beb230..b63e925c0 100644 --- a/server/src/controllers/statusPageController.ts +++ b/server/src/controllers/statusPageController.ts @@ -21,11 +21,11 @@ class StatusPageController { await createStatusPageBodyValidation.validateAsync(req.body); await imageValidation.validateAsync(req.file); - const { _id, teamId } = req.user; + const { id, teamId } = req.user ?? {}; const statusPage = await this.db.statusPageModule.createStatusPage({ statusPageData: req.body, image: req.file, - userId: _id, + userId: id, teamId, }); return res.status(200).json({ @@ -87,7 +87,7 @@ class StatusPageController { }; getStatusPagesByTeamId = async (req: Request, res: Response, next: NextFunction) => { try { - const teamId = req.user.teamId; + const teamId = req.user?.teamId; const statusPages = await this.db.statusPageModule.getStatusPagesByTeamId(teamId); return res.status(200).json({ diff --git a/server/src/middleware/v1/verifyJWT.js b/server/src/middleware/v1/verifyJWT.ts similarity index 74% rename from server/src/middleware/v1/verifyJWT.js rename to server/src/middleware/v1/verifyJWT.ts index cf983a6c8..b3211d7b6 100755 --- a/server/src/middleware/v1/verifyJWT.js +++ b/server/src/middleware/v1/verifyJWT.ts @@ -1,3 +1,5 @@ +import { NextFunction, Request, Response } from "express"; + import jwt from "jsonwebtoken"; import ServiceRegistry from "../../service/system/serviceRegistry.js"; import SettingsService from "../../service/system/settingsService.js"; @@ -5,20 +7,12 @@ import StringService from "../../service/system/stringService.js"; const SERVICE_NAME = "verifyJWT"; const TOKEN_PREFIX = "Bearer "; -/** - * Verifies the JWT token - * @function - * @param {express.Request} req - * @param {express.Response} res - * @param {express.NextFunction} next - * @returns {express.Response} - */ -const verifyJWT = (req, res, next) => { +const verifyJWT = (req: Request, res: Response, next: NextFunction) => { const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); const token = req.headers["authorization"]; // Make sure a token is provided if (!token) { - const error = new Error(stringService.noAuthToken); + const error: any = new Error(stringService.noAuthToken); error.status = 401; error.service = SERVICE_NAME; next(error); @@ -26,7 +20,7 @@ const verifyJWT = (req, res, next) => { } // Make sure it is properly formatted if (!token.startsWith(TOKEN_PREFIX)) { - const error = new Error(stringService.invalidAuthToken); // Instantiate a new Error object for improperly formatted token + const error: any = new Error(stringService.invalidAuthToken); // Instantiate a new Error object for improperly formatted token error.status = 401; error.service = SERVICE_NAME; error.method = "verifyJWT"; @@ -37,7 +31,7 @@ const verifyJWT = (req, res, next) => { const parsedToken = token.slice(TOKEN_PREFIX.length, token.length); // Verify the token's authenticity const { jwtSecret } = ServiceRegistry.get(SettingsService.SERVICE_NAME).getSettings(); - jwt.verify(parsedToken, jwtSecret, (err, decoded) => { + jwt.verify(parsedToken, jwtSecret, (err: any, decoded: any) => { if (err) { const errorMessage = err.name === "TokenExpiredError" ? stringService.expiredAuthToken : stringService.invalidAuthToken; err.details = { msg: errorMessage }; diff --git a/server/src/repositories/users/IUsersRepository.ts b/server/src/repositories/users/IUsersRepository.ts index 9678de47a..3fa094c54 100644 --- a/server/src/repositories/users/IUsersRepository.ts +++ b/server/src/repositories/users/IUsersRepository.ts @@ -4,9 +4,12 @@ export interface IUsersRepository { create(user: Partial, imageFile?: Express.Multer.File | null): Promise; // fetch findByEmail(email: string): Promise; + findById(id: string): Promise; + findAll(): Promise; // update updateById(id: string, patch: Partial, file?: Express.Multer.File | null): Promise; // delete + deleteById(id: string): Promise; // other findSuperAdmin(): Promise; } diff --git a/server/src/repositories/users/MongoUsersRepository.ts b/server/src/repositories/users/MongoUsersRepository.ts index b2687ff4a..023e8aefb 100644 --- a/server/src/repositories/users/MongoUsersRepository.ts +++ b/server/src/repositories/users/MongoUsersRepository.ts @@ -82,6 +82,20 @@ class MongoUsersRepository implements IUsersRepository { return this.toEntity(user); }; + findById = async (id: string) => { + const user = await UserModel.findById(id).select("-password").select("-profileImage"); + if (!user) { + throw new Error("User not found"); + } + + return this.toEntity(user); + }; + + findAll = async () => { + const users = await UserModel.find().select("-password").select("-profileImage"); + return this.mapDocuments(users); + }; + updateById = async (id: string, patch: Partial, file?: Express.Multer.File | null): Promise => { const candidateUser = { ...patch }; @@ -113,6 +127,14 @@ class MongoUsersRepository implements IUsersRepository { return this.toEntity(updatedUser); }; + deleteById = async (id: string) => { + const deletedUser = await UserModel.findByIdAndDelete(id); + if (!deletedUser) { + throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(deletedUser); + }; + findSuperAdmin = async () => { const superAdmin = await UserModel.findOne({ role: "superadmin" }); if (superAdmin !== null) { @@ -120,6 +142,13 @@ class MongoUsersRepository implements IUsersRepository { } return false; }; + + private mapDocuments = (documents: UserDocument[]): User[] => { + if (!documents?.length) { + return []; + } + return documents.map((doc) => this.toEntity(doc)); + }; } export default MongoUsersRepository; diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index efd599299..7d9e5811e 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -187,7 +187,7 @@ class UserService { }; requestRecovery = async (email: string) => { - const user = await this.db.userModule.getUserByEmail(email); + const user = await this.usersRepository.findByEmail(email); // Delete existing tokens await this.recoveryTokensRepository.deleteManyByEmail(email); @@ -231,14 +231,14 @@ class UserService { return { user: existingUser, token }; }; - deleteUser = async (user: any) => { + deleteUser = async (user: User) => { const email = user?.email; if (!email) { throw this.errorService.createBadRequestError("No email in request"); } const teamId = user?.teamId; - const userId = user?._id; + const userId = user?.id; if (!teamId) { throw this.errorService.createBadRequestError("No team ID in request"); @@ -267,24 +267,28 @@ class UserService { )); } // 6. Delete the user by id - await this.db.userModule.deleteUser(userId); + await this.usersRepository.deleteById(userId); }; getAllUsers = async () => { - const users = await this.db.userModule.getAllUsers(); - return users; + return await this.usersRepository.findAll(); }; getUserById = async (roles: any, userId: any) => { - const user = await this.db.userModule.getUserById(roles, userId); + if (!roles.includes("superadmin")) { + throw new AppError({ message: "User is not a superadmin", service: SERVICE_NAME, status: 403 }); + } + const user = await this.usersRepository.findById(userId); + return user; }; - editUserById = async (userId: any, user: any) => { - await this.db.userModule.editUserById(userId, user); + editUserById = async (userId: any, patch: Partial) => { + await this.usersRepository.updateById(userId, patch, null); }; + setPasswordByUserId = async (userId: any, password: string) => { - const updatedUser = await this.db.userModule.updateUser({ userId: userId, user: { password: password }, file: null }); + const updatedUser = await this.usersRepository.updateById(userId, { password }, null); return updatedUser; }; } diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index b9f7386f6..66755659c 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -1,10 +1,10 @@ -import { ITokenizedUser } from "../db/models/index.ts"; +import type { User } from "@/types/index.js"; declare global { namespace Express { interface Request { file?: Multer.File; - user?: ITokenizedUser; + user?: User | undefined; resource?: any; } } From e498bd60b17e9404cd51bc251d7b37eb73c2c06e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 17 Jan 2026 05:41:52 +0000 Subject: [PATCH 6/8] team panel fix --- client/src/Pages/Account/components/TeamPanel.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/Account/components/TeamPanel.jsx b/client/src/Pages/Account/components/TeamPanel.jsx index 03a6145a5..bb40a5e4f 100644 --- a/client/src/Pages/Account/components/TeamPanel.jsx +++ b/client/src/Pages/Account/components/TeamPanel.jsx @@ -104,7 +104,7 @@ const TeamPanel = () => { team = team.map((member) => ({ ...member, - id: member._id, + id: member.id, role: member.role.map((role) => ROLE_MAP[role]).join(","), })); setData(team); @@ -268,6 +268,7 @@ const TeamPanel = () => { cursor: isSuperAdmin ? "pointer" : "default", }, onRowClick: (row) => { + console.log(row); if (isSuperAdmin) { navigate(`/account/team/${row.id}`); } From 048e53ea1556fe45434c8b52ab68e7583639b17b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 17 Jan 2026 18:29:42 +0000 Subject: [PATCH 7/8] setting service --- server/src/db/models/AppSettings.js | 84 ------------------- server/src/db/models/AppSettings.ts | 49 +++++++++++ server/src/db/models/index.ts | 3 + server/src/repositories/index.ts | 3 + .../settings/ISettingsRepository.ts | 8 ++ .../settings/MongoSettingsRepository.ts | 74 ++++++++++++++++ server/src/types/index.ts | 1 + server/src/types/settings.ts | 31 +++++++ 8 files changed, 169 insertions(+), 84 deletions(-) delete mode 100755 server/src/db/models/AppSettings.js create mode 100644 server/src/db/models/AppSettings.ts create mode 100644 server/src/repositories/settings/ISettingsRepository.ts create mode 100644 server/src/repositories/settings/MongoSettingsRepository.ts create mode 100644 server/src/types/settings.ts diff --git a/server/src/db/models/AppSettings.js b/server/src/db/models/AppSettings.js deleted file mode 100755 index a1a9964f2..000000000 --- a/server/src/db/models/AppSettings.js +++ /dev/null @@ -1,84 +0,0 @@ -import mongoose from "mongoose"; - -const AppSettingsSchema = mongoose.Schema( - { - checkTTL: { - type: Number, - default: 30, - }, - language: { - type: String, - default: "gb", - }, - pagespeedApiKey: { - type: String, - }, - systemEmailHost: { - type: String, - }, - systemEmailPort: { - type: Number, - }, - systemEmailAddress: { - type: String, - }, - systemEmailPassword: { - type: String, - }, - systemEmailUser: { - type: String, - }, - systemEmailConnectionHost: { - type: String, - default: "localhost", - }, - systemEmailTLSServername: { - type: String, - }, - systemEmailSecure: { - type: Boolean, - default: false, - }, - systemEmailPool: { - type: Boolean, - default: false, - }, - systemEmailIgnoreTLS: { - type: Boolean, - default: false, - }, - systemEmailRequireTLS: { - type: Boolean, - default: false, - }, - systemEmailRejectUnauthorized: { - type: Boolean, - default: true, - }, - showURL: { - type: Boolean, - default: false, - }, - singleton: { - type: Boolean, - required: true, - unique: true, - default: true, - }, - version: { - type: Number, - default: 1, - }, - globalThresholds: { - cpu: { type: Number }, - memory: { type: Number }, - disk: { type: Number }, - temperature: { type: Number }, - }, - }, - { - timestamps: true, - } -); - -export default mongoose.model("AppSettings", AppSettingsSchema); diff --git a/server/src/db/models/AppSettings.ts b/server/src/db/models/AppSettings.ts new file mode 100644 index 000000000..22d1369e5 --- /dev/null +++ b/server/src/db/models/AppSettings.ts @@ -0,0 +1,49 @@ +import { Schema, model, type Types } from "mongoose"; +import type { Settings, SettingsThresholds } from "@/types/settings.js"; + +interface AppSettingsDocument extends Omit { + _id: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const thresholdsSchema = new Schema( + { + cpu: { type: Number }, + memory: { type: Number }, + disk: { type: Number }, + temperature: { type: Number }, + }, + { _id: false } +); + +const AppSettingsSchema = new Schema( + { + checkTTL: { type: Number, default: 30 }, + language: { type: String, default: "gb" }, + pagespeedApiKey: { type: String }, + systemEmailHost: { type: String }, + systemEmailPort: { type: Number }, + systemEmailAddress: { type: String }, + systemEmailPassword: { type: String }, + systemEmailUser: { type: String }, + systemEmailConnectionHost: { type: String, default: "localhost" }, + systemEmailTLSServername: { type: String }, + systemEmailSecure: { type: Boolean, default: false }, + systemEmailPool: { type: Boolean, default: false }, + systemEmailIgnoreTLS: { type: Boolean, default: false }, + systemEmailRequireTLS: { type: Boolean, default: false }, + systemEmailRejectUnauthorized: { type: Boolean, default: true }, + showURL: { type: Boolean, default: false }, + singleton: { type: Boolean, required: true, unique: true, default: true }, + version: { type: Number, default: 1 }, + globalThresholds: { type: thresholdsSchema }, + }, + { timestamps: true } +); + +const AppSettingsModel = model("AppSettings", AppSettingsSchema); + +export type { AppSettingsDocument }; +export { AppSettingsModel }; +export default AppSettingsModel; diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index 1e5024b1c..59ed8125d 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -16,5 +16,8 @@ export { default as UserModel } from "@/db/models/User.js"; export * from "@/db/models/Invite.js"; export { default as InviteModel } from "@/db/models/Invite.js"; +export * from "@/db/models/AppSettings.js"; +export { default as AppSettingsModel } from "@/db/models/AppSettings.js"; + export * from "@/db/models/RecoveryToken.js"; export { default as RecoveryTokenModel } from "@/db/models/RecoveryToken.js"; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 7a3c08c81..09a686595 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -18,3 +18,6 @@ export { default as MongoInvitesRepository } from "@/repositories/invites/MongoI export * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js"; export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.js"; + +export * from "@/repositories/settings/ISettingsRepository.js"; +export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js"; diff --git a/server/src/repositories/settings/ISettingsRepository.ts b/server/src/repositories/settings/ISettingsRepository.ts new file mode 100644 index 000000000..111a92a55 --- /dev/null +++ b/server/src/repositories/settings/ISettingsRepository.ts @@ -0,0 +1,8 @@ +import type { Settings } from "@/types/index.js"; +export interface ISettingsRepository { + // create + // fetch + // update + update(settings: Partial): Promise; + // delete +} diff --git a/server/src/repositories/settings/MongoSettingsRepository.ts b/server/src/repositories/settings/MongoSettingsRepository.ts new file mode 100644 index 000000000..f4d71d56e --- /dev/null +++ b/server/src/repositories/settings/MongoSettingsRepository.ts @@ -0,0 +1,74 @@ +import mongoose from "mongoose"; +import { ISettingsRepository } from "@/repositories/settings/ISettingsRepository.js"; +import type { Settings } from "@/types/index.js"; +import { AppSettingsModel, type AppSettingsDocument } from "@/db/models/AppSettings.js"; +import { AppError } from "@/utils/AppError.js"; + +class MongoSettingsRepository implements ISettingsRepository { + private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { + if (!value) { + return ""; + } + return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value); + }; + + private toDateString = (value?: Date | string | null): string => { + if (!value) { + return new Date(0).toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }; + + protected toEntity = (doc: AppSettingsDocument): Settings => { + return { + id: this.toStringId(doc._id), + checkTTL: doc.checkTTL, + language: doc.language, + pagespeedApiKey: doc.pagespeedApiKey ?? undefined, + systemEmailHost: doc.systemEmailHost ?? undefined, + systemEmailPort: doc.systemEmailPort ?? undefined, + systemEmailAddress: doc.systemEmailAddress ?? undefined, + systemEmailPassword: doc.systemEmailPassword ?? undefined, + systemEmailUser: doc.systemEmailUser ?? undefined, + systemEmailConnectionHost: doc.systemEmailConnectionHost ?? undefined, + systemEmailTLSServername: doc.systemEmailTLSServername ?? undefined, + systemEmailSecure: doc.systemEmailSecure ?? false, + systemEmailPool: doc.systemEmailPool ?? false, + systemEmailIgnoreTLS: doc.systemEmailIgnoreTLS ?? false, + systemEmailRequireTLS: doc.systemEmailRequireTLS ?? false, + systemEmailRejectUnauthorized: doc.systemEmailRejectUnauthorized ?? true, + showURL: doc.showURL ?? false, + singleton: doc.singleton, + version: doc.version ?? 1, + globalThresholds: doc.globalThresholds ?? undefined, + createdAt: this.toDateString(doc.createdAt), + updatedAt: this.toDateString(doc.updatedAt), + }; + }; + + update = async (settings: Partial) => { + const update: Record = { $set: { ...settings } }; + + if (settings.pagespeedApiKey === "") { + update.$unset = { pagespeedApiKey: "" }; + delete update.$set.pagespeedApiKey; + } + + if (settings.systemEmailPassword === "") { + update.$unset = { systemEmailPassword: "" }; + delete update.$set.systemEmailPassword; + } + + await AppSettingsModel.findOneAndUpdate({}, update, { + upsert: true, + }); + + const currentSettings = await AppSettingsModel.findOne().select("-__v -_id -createdAt -updatedAt -singleton").lean(); + if (!currentSettings) { + throw new AppError({ message: "Settings not found after update", status: 500 }); + } + return this.toEntity(currentSettings); + }; +} + +export default MongoSettingsRepository; diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 1a43d4849..fcfb6fc37 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -6,3 +6,4 @@ export * from "@/types/network.js"; export * from "@/types/user.js"; export * from "@/types/invite.js"; export * from "@/types/recoveryToken.js"; +export * from "@/types/settings.js"; diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts new file mode 100644 index 000000000..78284a451 --- /dev/null +++ b/server/src/types/settings.ts @@ -0,0 +1,31 @@ +export interface SettingsThresholds { + cpu?: number; + memory?: number; + disk?: number; + temperature?: number; +} + +export interface Settings { + id: string; + checkTTL: number; + language: string; + pagespeedApiKey?: string; + systemEmailHost?: string; + systemEmailPort?: number; + systemEmailAddress?: string; + systemEmailPassword?: string; + systemEmailUser?: string; + systemEmailConnectionHost?: string; + systemEmailTLSServername?: string; + systemEmailSecure: boolean; + systemEmailPool: boolean; + systemEmailIgnoreTLS: boolean; + systemEmailRequireTLS: boolean; + systemEmailRejectUnauthorized: boolean; + showURL: boolean; + singleton: boolean; + version: number; + globalThresholds?: SettingsThresholds; + createdAt: string; + updatedAt: string; +} From ba352309195b4458bed20b0fcc2e7b7d3a8d46e5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 17 Jan 2026 19:08:12 +0000 Subject: [PATCH 8/8] remove db references from userService --- server/src/config/services.ts | 7 ++++++- server/src/db/models/AppSettings.ts | 1 + .../settings/MongoSettingsRepository.ts | 14 ++++++++------ server/src/service/business/userService.ts | 12 ++++++------ server/src/types/settings.ts | 1 + 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index aef32440f..6b91bcf63 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -74,6 +74,7 @@ import { MongoUsersRepository, MongoInvitesRepository, MongoRecoveryTokensRepository, + MongoSettingsRepository, IMonitorsRepository, IChecksRepository, IMonitorStatsRepository, @@ -81,6 +82,7 @@ import { IUsersRepository, IInvitesRepository, IRecoveryTokensRepository, + ISettingsRepository, } from "@/repositories/index.js"; export type InitializedSerivces = { @@ -113,6 +115,7 @@ export type InitializedSerivces = { usersRepository: IUsersRepository; invitesRepository: IInvitesRepository; recoveryTokensRepository: IRecoveryTokensRepository; + settingsRepository: ISettingsRepository; }; export const initializeServices = async ({ @@ -167,6 +170,7 @@ export const initializeServices = async ({ const usersRepository = new MongoUsersRepository(); const invitesRepository = new MongoInvitesRepository(); const recoveryTokensRepository = new MongoRecoveryTokensRepository(); + const settingsRepository = new MongoSettingsRepository(); const networkService = new NetworkService({ axios, @@ -240,7 +244,6 @@ export const initializeServices = async ({ // Business services const userService = new UserService({ crypto, - db, emailService, settingsService, logger, @@ -252,6 +255,7 @@ export const initializeServices = async ({ usersRepository, invitesRepository, recoveryTokensRepository, + settingsRepository, }); const diagnosticService = new DiagnosticService(); @@ -313,6 +317,7 @@ export const initializeServices = async ({ usersRepository, invitesRepository, recoveryTokensRepository, + settingsRepository, }; Object.values(services).forEach((service) => { diff --git a/server/src/db/models/AppSettings.ts b/server/src/db/models/AppSettings.ts index 22d1369e5..9eeaa640a 100644 --- a/server/src/db/models/AppSettings.ts +++ b/server/src/db/models/AppSettings.ts @@ -21,6 +21,7 @@ const AppSettingsSchema = new Schema( { checkTTL: { type: Number, default: 30 }, language: { type: String, default: "gb" }, + jwtSecret: { type: String }, pagespeedApiKey: { type: String }, systemEmailHost: { type: String }, systemEmailPort: { type: Number }, diff --git a/server/src/repositories/settings/MongoSettingsRepository.ts b/server/src/repositories/settings/MongoSettingsRepository.ts index f4d71d56e..2cc700525 100644 --- a/server/src/repositories/settings/MongoSettingsRepository.ts +++ b/server/src/repositories/settings/MongoSettingsRepository.ts @@ -2,7 +2,6 @@ import mongoose from "mongoose"; import { ISettingsRepository } from "@/repositories/settings/ISettingsRepository.js"; import type { Settings } from "@/types/index.js"; import { AppSettingsModel, type AppSettingsDocument } from "@/db/models/AppSettings.js"; -import { AppError } from "@/utils/AppError.js"; class MongoSettingsRepository implements ISettingsRepository { private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { @@ -24,6 +23,7 @@ class MongoSettingsRepository implements ISettingsRepository { id: this.toStringId(doc._id), checkTTL: doc.checkTTL, language: doc.language, + jwtSecret: doc.jwtSecret ?? undefined, pagespeedApiKey: doc.pagespeedApiKey ?? undefined, systemEmailHost: doc.systemEmailHost ?? undefined, systemEmailPort: doc.systemEmailPort ?? undefined, @@ -63,11 +63,13 @@ class MongoSettingsRepository implements ISettingsRepository { upsert: true, }); - const currentSettings = await AppSettingsModel.findOne().select("-__v -_id -createdAt -updatedAt -singleton").lean(); - if (!currentSettings) { - throw new AppError({ message: "Settings not found after update", status: 500 }); - } - return this.toEntity(currentSettings); + const updatedSettings = await AppSettingsModel.findOneAndUpdate({}, update, { + upsert: true, + new: true, + projection: "-__v -_id -createdAt -updatedAt -singleton", + }); + + return this.toEntity(updatedSettings); }; } diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 7d9e5811e..5f73508dd 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -1,4 +1,4 @@ -import { IInvitesRepository, IMonitorsRepository, IRecoveryTokensRepository, IUsersRepository } from "@/repositories/index.js"; +import { IInvitesRepository, IMonitorsRepository, IRecoveryTokensRepository, IUsersRepository, ISettingsRepository } from "@/repositories/index.js"; import Team from "@/db/models/Team.js"; import type { User } from "@/types/index.js"; import bcrypt from "bcryptjs"; @@ -9,7 +9,6 @@ const SERVICE_NAME = "userService"; class UserService { static SERVICE_NAME = SERVICE_NAME; - private db: any; private emailService: any; private settingsService: any; private logger: any; @@ -22,10 +21,10 @@ class UserService { private usersRepository: IUsersRepository; private invitesRepository: IInvitesRepository; private recoveryTokensRepository: IRecoveryTokensRepository; + private settingsRepository: ISettingsRepository; constructor({ crypto, - db, emailService, settingsService, logger, @@ -37,9 +36,9 @@ class UserService { usersRepository, invitesRepository, recoveryTokensRepository, + settingsRepository, }: { crypto: any; - db: any; emailService: any; settingsService: any; logger: any; @@ -51,8 +50,8 @@ class UserService { usersRepository: IUsersRepository; invitesRepository: IInvitesRepository; recoveryTokensRepository: IRecoveryTokensRepository; + settingsRepository: ISettingsRepository; }) { - this.db = db; this.emailService = emailService; this.settingsService = settingsService; this.logger = logger; @@ -65,6 +64,7 @@ class UserService { this.usersRepository = usersRepository; this.invitesRepository = invitesRepository; this.recoveryTokensRepository = recoveryTokensRepository; + this.settingsRepository = settingsRepository; } get serviceName() { @@ -89,7 +89,7 @@ class UserService { } else { // This is the first account, create JWT secret to use if one is not supplied by env const jwtSecret = this.crypto.randomBytes(64).toString("hex"); - await this.db.settingsModule.updateAppSettings({ jwtSecret }); + await this.settingsRepository.update({ jwtSecret }); // Create a new team const team = new Team({ email: user.email, diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts index 78284a451..108cdcb72 100644 --- a/server/src/types/settings.ts +++ b/server/src/types/settings.ts @@ -9,6 +9,7 @@ export interface Settings { id: string; checkTTL: number; language: string; + jwtSecret?: string; pagespeedApiKey?: string; systemEmailHost?: string; systemEmailPort?: number;