428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
import {
|
|
IInvitesRepository,
|
|
IMonitorsRepository,
|
|
IRecoveryTokensRepository,
|
|
IUsersRepository,
|
|
ISettingsRepository,
|
|
ITeamsRepository,
|
|
} from "@/repositories/index.js";
|
|
import type { User } from "@/types/index.js";
|
|
import { canManageRole, type UserRole } from "@/types/user.js";
|
|
import bcrypt from "bcryptjs";
|
|
import { AppError } from "@/utils/AppError.js";
|
|
import { ISuperSimpleQueue } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
|
|
import { IEmailService } from "@/service/infrastructure/emailService.js";
|
|
import { EnvConfig, ISettingsService } from "@/service/system/settingsService.js";
|
|
import { ILogger } from "@/utils/logger.js";
|
|
import jwt from "jsonwebtoken";
|
|
import crypto from "crypto";
|
|
type CryptoType = typeof crypto;
|
|
type JwtType = typeof jwt;
|
|
const SERVICE_NAME = "userService";
|
|
|
|
export interface IUserService {
|
|
issueToken(payload: Partial<User>, appSettings: EnvConfig): string;
|
|
registerUser(user: Partial<User>, inviteToken: string, file: Express.Multer.File | null): Promise<{ user: User; token: string }>;
|
|
createUser(userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: Express.Multer.File | null): Promise<User>;
|
|
loginUser(email: string, password: string): Promise<{ user: User; token: string }>;
|
|
editUser(
|
|
updates: Partial<User & { newPassword?: string }>,
|
|
file: Express.Multer.File | null,
|
|
currentUserId: string,
|
|
currentUserEmail: string
|
|
): Promise<User>;
|
|
checkSuperadminExists(): Promise<boolean>;
|
|
requestRecovery(email: string): Promise<string | false | undefined>;
|
|
validateRecovery(recoveryToken: string): Promise<void>;
|
|
resetPassword(password: string, recoveryToken: string): Promise<{ user: User; token: string }>;
|
|
deleteUser(params: { userId: string; teamId: string; roles: UserRole[] }): Promise<void>;
|
|
deleteUserById(params: { actorId: string; actorTeamId: string; actorRoles: UserRole[]; targetUserId: string }): Promise<void>;
|
|
getAllUsers(): Promise<User[]>;
|
|
getUserById(roles: UserRole[], userId: string): Promise<User>;
|
|
editUserById(userId: string, patch: Partial<User>): Promise<void>;
|
|
setPasswordByUserId(userId: string, password: string): Promise<User>;
|
|
}
|
|
|
|
export class UserService implements IUserService {
|
|
static SERVICE_NAME = SERVICE_NAME;
|
|
|
|
private hashPassword = (password: string): string => {
|
|
const salt = bcrypt.genSaltSync(10);
|
|
return bcrypt.hashSync(password, salt);
|
|
};
|
|
|
|
private emailService: IEmailService;
|
|
private settingsService: ISettingsService;
|
|
private logger: ILogger;
|
|
private jwt: JwtType;
|
|
private jobQueue: ISuperSimpleQueue;
|
|
private crypto: CryptoType;
|
|
private monitorsRepository: IMonitorsRepository;
|
|
private usersRepository: IUsersRepository;
|
|
private invitesRepository: IInvitesRepository;
|
|
private recoveryTokensRepository: IRecoveryTokensRepository;
|
|
private settingsRepository: ISettingsRepository;
|
|
private teamsRepository: ITeamsRepository;
|
|
|
|
constructor({
|
|
crypto,
|
|
emailService,
|
|
settingsService,
|
|
logger,
|
|
jwt,
|
|
jobQueue,
|
|
monitorsRepository,
|
|
usersRepository,
|
|
invitesRepository,
|
|
recoveryTokensRepository,
|
|
settingsRepository,
|
|
teamsRepository,
|
|
}: {
|
|
crypto: CryptoType;
|
|
emailService: IEmailService;
|
|
settingsService: ISettingsService;
|
|
logger: ILogger;
|
|
jwt: JwtType;
|
|
jobQueue: ISuperSimpleQueue;
|
|
monitorsRepository: IMonitorsRepository;
|
|
usersRepository: IUsersRepository;
|
|
invitesRepository: IInvitesRepository;
|
|
recoveryTokensRepository: IRecoveryTokensRepository;
|
|
settingsRepository: ISettingsRepository;
|
|
teamsRepository: ITeamsRepository;
|
|
}) {
|
|
this.emailService = emailService;
|
|
this.settingsService = settingsService;
|
|
this.logger = logger;
|
|
this.jwt = jwt;
|
|
this.jobQueue = jobQueue;
|
|
this.crypto = crypto;
|
|
this.monitorsRepository = monitorsRepository;
|
|
this.usersRepository = usersRepository;
|
|
this.invitesRepository = invitesRepository;
|
|
this.recoveryTokensRepository = recoveryTokensRepository;
|
|
this.settingsRepository = settingsRepository;
|
|
this.teamsRepository = teamsRepository;
|
|
}
|
|
|
|
get serviceName() {
|
|
return UserService.SERVICE_NAME;
|
|
}
|
|
|
|
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
|
|
const tokenTTL = appSettings?.jwtTTL ?? "2h";
|
|
const tokenSecret = appSettings?.jwtSecret;
|
|
const payloadData = payload;
|
|
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
|
|
};
|
|
|
|
registerUser = async (user: Partial<User>, inviteToken: string, file: Express.Multer.File | null) => {
|
|
// Create a new user
|
|
// If superAdmin exists, a token should be attached to all further register requests
|
|
const superAdminExists = await this.usersRepository.findSuperAdmin();
|
|
if (superAdminExists) {
|
|
const invite = await this.invitesRepository.findByTokenAndDelete(inviteToken);
|
|
user.role = invite.role ?? ["user"];
|
|
user.teamId = invite.teamId;
|
|
user.email = invite.email;
|
|
} 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.settingsRepository.update({ jwtSecret });
|
|
// Create a new team
|
|
if (!user.email) {
|
|
throw new AppError({ message: "Email is required for first user", service: SERVICE_NAME, method: "registerUser", status: 400 });
|
|
}
|
|
const team = await this.teamsRepository.create(user.email);
|
|
user.teamId = team.id;
|
|
user.role = ["superadmin"];
|
|
}
|
|
|
|
// Hash password before storing
|
|
if (user.password) {
|
|
user.password = this.hashPassword(user.password);
|
|
}
|
|
|
|
const newUser = await this.usersRepository.create(user, file);
|
|
|
|
this.logger.debug({
|
|
message: "New user created",
|
|
service: SERVICE_NAME,
|
|
method: "registerUser",
|
|
details: { userId: newUser.id },
|
|
});
|
|
|
|
delete newUser.profileImage;
|
|
delete newUser.avatarImage;
|
|
|
|
const appSettings = await this.settingsService.getSettings();
|
|
|
|
const token = this.issueToken(newUser, appSettings);
|
|
|
|
try {
|
|
const html = await this.emailService.buildEmail("welcomeEmailTemplate", {
|
|
name: newUser.firstName,
|
|
});
|
|
if (!html) {
|
|
throw new Error("Failed to build welcome email HTML");
|
|
}
|
|
this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error: unknown) => {
|
|
this.logger.warn({
|
|
message: error instanceof Error ? error.message : "Unknown error",
|
|
service: SERVICE_NAME,
|
|
method: "registerUser",
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
});
|
|
} catch (error: unknown) {
|
|
this.logger.warn({
|
|
message: error instanceof Error ? error.message : "Unknown error",
|
|
service: SERVICE_NAME,
|
|
method: "registerUser",
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
}
|
|
|
|
return { user: newUser, token };
|
|
};
|
|
|
|
createUser = async (userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: Express.Multer.File | null) => {
|
|
// Validate that the creator can assign the requested roles
|
|
const targetRoles = userData.role ?? [];
|
|
for (const targetRole of targetRoles) {
|
|
const canManage = actorRoles.some((actorRole) => canManageRole(actorRole, targetRole));
|
|
if (!canManage) {
|
|
throw new AppError({
|
|
message: "You do not have permission to assign this role",
|
|
service: SERVICE_NAME,
|
|
method: "createUser",
|
|
status: 403,
|
|
});
|
|
}
|
|
}
|
|
|
|
userData.teamId = teamId;
|
|
|
|
if (userData.password) {
|
|
userData.password = this.hashPassword(userData.password);
|
|
}
|
|
|
|
const newUser = await this.usersRepository.create(userData, file);
|
|
|
|
this.logger.debug({
|
|
message: "New user created by superadmin",
|
|
service: SERVICE_NAME,
|
|
method: "createUser",
|
|
details: { userId: newUser.id },
|
|
});
|
|
|
|
newUser.profileImage = undefined;
|
|
newUser.avatarImage = undefined;
|
|
newUser.password = "";
|
|
|
|
return newUser;
|
|
};
|
|
|
|
loginUser = async (email: string, password: string) => {
|
|
// Check if user exists
|
|
const user = await this.usersRepository.findByEmail(email);
|
|
// Compare password
|
|
const match = await bcrypt.compare(password, user.password);
|
|
|
|
if (match !== true) {
|
|
throw new AppError({ message: "Incorrect password", service: SERVICE_NAME, status: 401 });
|
|
}
|
|
|
|
// Remove password from user object. Should this be abstracted to DB layer?
|
|
const userWithoutPassword = { ...user };
|
|
userWithoutPassword.password = "";
|
|
userWithoutPassword.avatarImage = "";
|
|
|
|
// Happy path, return token
|
|
const appSettings = await this.settingsService.getSettings();
|
|
const token = this.issueToken(userWithoutPassword, appSettings);
|
|
// reset avatar image
|
|
userWithoutPassword.avatarImage = user.avatarImage;
|
|
return { user: userWithoutPassword, token };
|
|
};
|
|
|
|
editUser = async (
|
|
updates: Partial<User & { newPassword?: string }>,
|
|
file: Express.Multer.File | null,
|
|
currentUserId: string,
|
|
currentUserEmail: string
|
|
) => {
|
|
// Change Password check
|
|
if (updates?.password && updates?.newPassword) {
|
|
// Get user's email
|
|
// Add user email to body for DB operation
|
|
updates.email = currentUserEmail;
|
|
// Get user
|
|
const user = await this.usersRepository.findByEmail(currentUserEmail);
|
|
// Compare passwords
|
|
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) {
|
|
throw new AppError({ message: "Incorrect current password", service: SERVICE_NAME, status: 403 });
|
|
}
|
|
// If a match, update the password
|
|
updates.password = this.hashPassword(updates.newPassword);
|
|
delete updates.newPassword;
|
|
}
|
|
|
|
return await this.usersRepository.updateById(currentUserId, updates, file);
|
|
};
|
|
|
|
checkSuperadminExists = async () => {
|
|
return await this.usersRepository.findSuperAdmin();
|
|
};
|
|
|
|
requestRecovery = async (email: string) => {
|
|
const user = await this.usersRepository.findByEmail(email);
|
|
|
|
// Delete existing tokens
|
|
await this.recoveryTokensRepository.deleteManyByEmail(email);
|
|
const recoveryToken = await this.recoveryTokensRepository.create(email);
|
|
const name = user.firstName;
|
|
const settings = this.settingsService.getSettings();
|
|
const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`;
|
|
|
|
const html = await this.emailService.buildEmail("passwordResetTemplate", {
|
|
name,
|
|
email,
|
|
url,
|
|
});
|
|
if (!html) {
|
|
throw new AppError({
|
|
message: "Failed to build password reset email HTML",
|
|
service: SERVICE_NAME,
|
|
method: "requestRecovery",
|
|
status: 500,
|
|
});
|
|
}
|
|
const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html);
|
|
return msgId;
|
|
};
|
|
|
|
validateRecovery = async (recoveryToken: string) => {
|
|
// Throws if token not found, validating
|
|
await this.recoveryTokensRepository.findByToken(recoveryToken);
|
|
};
|
|
|
|
resetPassword = async (password: string, recoveryToken: string) => {
|
|
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 });
|
|
}
|
|
|
|
const hashedPassword = this.hashPassword(password);
|
|
await this.usersRepository.updateById(existingUser.id, { password: hashedPassword }, 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 ({ userId, teamId, roles }: { userId: string; teamId: string; roles: UserRole[] }) => {
|
|
if (roles.includes("demo")) {
|
|
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUser", status: 400 });
|
|
}
|
|
|
|
// 1. Find all the monitors associated with the team ID if superadmin
|
|
const res = await this.monitorsRepository.findByTeamId(teamId, {});
|
|
|
|
if (roles.includes("superadmin")) {
|
|
// 2. Remove all jobs, delete checks and alerts
|
|
if (res && res.length > 0) {
|
|
await Promise.all(
|
|
res.map(async (monitor) => {
|
|
await this.jobQueue.deleteJob(monitor);
|
|
})
|
|
);
|
|
}
|
|
}
|
|
// 6. Delete the user by id
|
|
await this.usersRepository.deleteById(userId);
|
|
};
|
|
|
|
deleteUserById = async ({
|
|
actorId,
|
|
actorRoles,
|
|
actorTeamId,
|
|
targetUserId,
|
|
}: {
|
|
actorId: string;
|
|
actorTeamId: string;
|
|
actorRoles: UserRole[];
|
|
targetUserId: string;
|
|
}) => {
|
|
if (actorId === targetUserId) {
|
|
throw new AppError({ message: "Cannot delete your own account from here", service: SERVICE_NAME, method: "deleteUserById", status: 400 });
|
|
}
|
|
|
|
const targetUser = await this.usersRepository.findById(targetUserId);
|
|
|
|
if (targetUser.teamId !== actorTeamId) {
|
|
throw new AppError({ message: "User is not on your team", service: SERVICE_NAME, method: "deleteUserById", status: 403 });
|
|
}
|
|
|
|
if (targetUser.role.includes("demo")) {
|
|
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUserById", status: 400 });
|
|
}
|
|
|
|
const targetRoles = targetUser.role;
|
|
|
|
// Check actor can manage all of target's roles
|
|
for (const targetRole of targetRoles) {
|
|
const canManage = actorRoles.some((actorRole) => canManageRole(actorRole, targetRole));
|
|
if (!canManage) {
|
|
throw new AppError({
|
|
message: "You do not have permission to remove this user",
|
|
service: SERVICE_NAME,
|
|
method: "deleteUserById",
|
|
status: 403,
|
|
});
|
|
}
|
|
}
|
|
|
|
await this.usersRepository.deleteById(targetUserId);
|
|
|
|
this.logger.info({
|
|
message: `User ${targetUserId} deleted by ${actorId}`,
|
|
service: SERVICE_NAME,
|
|
method: "deleteUserById",
|
|
});
|
|
};
|
|
|
|
getAllUsers = async () => {
|
|
return await this.usersRepository.findAll();
|
|
};
|
|
|
|
getUserById = async (roles: UserRole[], userId: string) => {
|
|
if (!roles.includes("superadmin") && !roles.includes("admin")) {
|
|
throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 });
|
|
}
|
|
const user = await this.usersRepository.findById(userId);
|
|
|
|
return user;
|
|
};
|
|
|
|
editUserById = async (userId: string, patch: Partial<User>) => {
|
|
await this.usersRepository.updateById(userId, patch, null);
|
|
};
|
|
|
|
setPasswordByUserId = async (userId: string, password: string) => {
|
|
const hashedPassword = this.hashPassword(password);
|
|
const updatedUser = await this.usersRepository.updateById(userId, { password: hashedPassword }, null);
|
|
return updatedUser;
|
|
};
|
|
}
|