This commit is contained in:
Alex Holliday
2026-01-16 22:03:35 +00:00
parent cb5b8e0673
commit 9c92542653
12 changed files with 154 additions and 36 deletions
+6
View File
@@ -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) => {
-25
View File
@@ -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);
+27
View File
@@ -0,0 +1,27 @@
import { Schema, model, type Types } from "mongoose";
import type { RecoveryToken as RecoveryTokenEntity } from "@/types/recoveryToken.js";
type RecoveryTokenDocumentBase = Omit<RecoveryTokenEntity, "id" | "createdAt" | "updatedAt" | "expiry"> & {
expiry: Date;
};
interface RecoveryTokenDocument extends RecoveryTokenDocumentBase {
_id: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
const RecoveryTokenSchema = new Schema<RecoveryTokenDocument>(
{
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<RecoveryTokenDocument>("RecoveryToken", RecoveryTokenSchema);
export type { RecoveryTokenDocument };
export { RecoveryTokenModel };
export default RecoveryTokenModel;
+3
View File
@@ -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";
+3
View File
@@ -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";
@@ -0,0 +1,12 @@
import type { RecoveryToken } from "@/types/recoveryToken.js";
export interface IRecoveryTokensRepository {
// create
create(email: string): Promise<RecoveryToken>;
// fetch
findByToken(token: string): Promise<RecoveryToken>;
// update
// delete
deleteManyByEmail(email: string): Promise<number>;
// other
}
@@ -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<RecoveryToken> => {
const token = await RecoveryTokenModel.create({
email,
token: crypto.randomBytes(32).toString("hex"),
});
return this.toEntity(token);
};
findByToken = async (token: string): Promise<RecoveryToken> => {
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;
+31 -9
View File
@@ -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) => {
@@ -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) {
+1
View File
@@ -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";
+1 -1
View File
@@ -1,4 +1,4 @@
import { UserRole } from "./user.js";
import type { UserRole } from "@/types/user.js";
export interface Invite {
id: string;
+8
View File
@@ -0,0 +1,8 @@
export interface RecoveryToken {
id: string;
email: string;
token: string;
expiry: string;
createdAt: string;
updatedAt: string;
}