2025-08-21 15:24:40 +09:00
|
|
|
import {
|
|
|
|
|
Injectable,
|
|
|
|
|
UnauthorizedException,
|
|
|
|
|
ConflictException,
|
|
|
|
|
BadRequestException,
|
2025-09-02 13:52:13 +09:00
|
|
|
NotFoundException,
|
2025-08-22 17:02:49 +09:00
|
|
|
Inject,
|
2025-08-21 15:24:40 +09:00
|
|
|
} from "@nestjs/common";
|
|
|
|
|
import { JwtService } from "@nestjs/jwt";
|
|
|
|
|
import { ConfigService } from "@nestjs/config";
|
|
|
|
|
import * as bcrypt from "bcrypt";
|
|
|
|
|
import { UsersService } from "../users/users.service";
|
|
|
|
|
import { MappingsService } from "../mappings/mappings.service";
|
|
|
|
|
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
|
|
|
|
import { SalesforceService } from "../vendors/salesforce/salesforce.service";
|
|
|
|
|
import { AuditService, AuditAction } from "../common/audit/audit.service";
|
|
|
|
|
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
|
|
|
|
import { SignupDto } from "./dto/signup.dto";
|
|
|
|
|
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
2025-08-30 15:10:24 +09:00
|
|
|
import { ValidateSignupDto } from "./dto/validate-signup.dto";
|
2025-08-21 15:24:40 +09:00
|
|
|
import { SetPasswordDto } from "./dto/set-password.dto";
|
|
|
|
|
import { getErrorMessage } from "../common/utils/error.util";
|
2025-08-22 17:02:49 +09:00
|
|
|
import { Logger } from "nestjs-pino";
|
2025-08-23 17:24:37 +09:00
|
|
|
import { EmailService } from "../common/email/email.service";
|
2025-08-23 18:02:05 +09:00
|
|
|
import { User as SharedUser } from "@customer-portal/shared";
|
|
|
|
|
import type { User as PrismaUser } from "@prisma/client";
|
|
|
|
|
import type { Request } from "express";
|
|
|
|
|
import { WhmcsClientResponse } from "../vendors/whmcs/types/whmcs-api.types";
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class AuthService {
|
|
|
|
|
private readonly MAX_LOGIN_ATTEMPTS = 5;
|
|
|
|
|
private readonly LOCKOUT_DURATION_MINUTES = 15;
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private usersService: UsersService,
|
|
|
|
|
private mappingsService: MappingsService,
|
|
|
|
|
private jwtService: JwtService,
|
|
|
|
|
private configService: ConfigService,
|
|
|
|
|
private whmcsService: WhmcsService,
|
|
|
|
|
private salesforceService: SalesforceService,
|
|
|
|
|
private auditService: AuditService,
|
|
|
|
|
private tokenBlacklistService: TokenBlacklistService,
|
2025-08-23 17:24:37 +09:00
|
|
|
private emailService: EmailService,
|
2025-08-22 17:02:49 +09:00
|
|
|
@Inject(Logger) private readonly logger: Logger
|
2025-08-20 18:02:50 +09:00
|
|
|
) {}
|
|
|
|
|
|
2025-08-30 15:10:24 +09:00
|
|
|
async healthCheck() {
|
|
|
|
|
const health = {
|
|
|
|
|
database: false,
|
|
|
|
|
whmcs: false,
|
|
|
|
|
salesforce: false,
|
|
|
|
|
whmcsConfig: {
|
|
|
|
|
baseUrl: !!this.configService.get("WHMCS_BASE_URL"),
|
|
|
|
|
identifier: !!this.configService.get("WHMCS_API_IDENTIFIER"),
|
|
|
|
|
secret: !!this.configService.get("WHMCS_API_SECRET"),
|
|
|
|
|
},
|
|
|
|
|
salesforceConfig: {
|
|
|
|
|
connected: false,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check database
|
|
|
|
|
try {
|
|
|
|
|
await this.usersService.findByEmail("health-check@test.com");
|
|
|
|
|
health.database = true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.debug("Database health check failed", { error: getErrorMessage(error) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check WHMCS
|
|
|
|
|
try {
|
|
|
|
|
// Try a simple WHMCS API call (this will fail if not configured)
|
|
|
|
|
await this.whmcsService.getProducts();
|
|
|
|
|
health.whmcs = true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.debug("WHMCS health check failed", { error: getErrorMessage(error) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check Salesforce
|
|
|
|
|
try {
|
|
|
|
|
health.salesforceConfig.connected = this.salesforceService.healthCheck();
|
|
|
|
|
health.salesforce = health.salesforceConfig.connected;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.debug("Salesforce health check failed", { error: getErrorMessage(error) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: health.database && health.whmcs && health.salesforce ? "healthy" : "degraded",
|
|
|
|
|
services: health,
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async validateSignup(validateData: ValidateSignupDto, request?: Request) {
|
|
|
|
|
const { sfNumber } = validateData;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 1. Check if SF number exists in Salesforce
|
|
|
|
|
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber);
|
|
|
|
|
if (!sfAccount) {
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.SIGNUP,
|
|
|
|
|
undefined,
|
|
|
|
|
{ sfNumber, reason: "SF number not found" },
|
|
|
|
|
request,
|
|
|
|
|
false,
|
|
|
|
|
"Customer number not found in Salesforce"
|
|
|
|
|
);
|
|
|
|
|
throw new BadRequestException("Customer number not found in Salesforce");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Check if SF account already has a mapping (already registered)
|
|
|
|
|
const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id);
|
|
|
|
|
if (existingMapping) {
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.SIGNUP,
|
|
|
|
|
undefined,
|
|
|
|
|
{ sfNumber, sfAccountId: sfAccount.id, reason: "Already has mapping" },
|
|
|
|
|
request,
|
|
|
|
|
false,
|
|
|
|
|
"Customer number already registered"
|
|
|
|
|
);
|
2025-09-01 15:11:42 +09:00
|
|
|
throw new ConflictException(
|
|
|
|
|
"You already have an account. Please use the login page to access your existing account."
|
|
|
|
|
);
|
2025-08-30 15:10:24 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Check WH_Account__c field in Salesforce
|
|
|
|
|
const accountDetails = await this.salesforceService.getAccountDetails(sfAccount.id);
|
|
|
|
|
if (accountDetails?.WH_Account__c && accountDetails.WH_Account__c.trim() !== "") {
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.SIGNUP,
|
|
|
|
|
undefined,
|
2025-09-01 15:11:42 +09:00
|
|
|
{
|
|
|
|
|
sfNumber,
|
|
|
|
|
sfAccountId: sfAccount.id,
|
|
|
|
|
whAccount: accountDetails.WH_Account__c,
|
|
|
|
|
reason: "WH Account not empty",
|
|
|
|
|
},
|
2025-08-30 15:10:24 +09:00
|
|
|
request,
|
|
|
|
|
false,
|
|
|
|
|
"Account already has WHMCS integration"
|
|
|
|
|
);
|
2025-09-01 15:11:42 +09:00
|
|
|
throw new ConflictException(
|
|
|
|
|
"You already have an account. Please use the login page to access your existing account."
|
|
|
|
|
);
|
2025-08-30 15:10:24 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Log successful validation
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.SIGNUP,
|
|
|
|
|
undefined,
|
|
|
|
|
{ sfNumber, sfAccountId: sfAccount.id, step: "validation" },
|
|
|
|
|
request,
|
|
|
|
|
true
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
valid: true,
|
|
|
|
|
sfAccountId: sfAccount.id,
|
2025-09-01 15:11:42 +09:00
|
|
|
message: "Customer number validated successfully",
|
2025-08-30 15:10:24 +09:00
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Re-throw known exceptions
|
|
|
|
|
if (error instanceof BadRequestException || error instanceof ConflictException) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Log unexpected errors
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.SIGNUP,
|
|
|
|
|
undefined,
|
|
|
|
|
{ sfNumber, error: getErrorMessage(error) },
|
|
|
|
|
request,
|
|
|
|
|
false,
|
|
|
|
|
getErrorMessage(error)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.logger.error("Signup validation error", { error: getErrorMessage(error) });
|
|
|
|
|
throw new BadRequestException("Validation failed");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 18:02:05 +09:00
|
|
|
async signup(signupData: SignupDto, request?: Request) {
|
2025-08-23 17:24:37 +09:00
|
|
|
const {
|
|
|
|
|
email,
|
|
|
|
|
password,
|
|
|
|
|
firstName,
|
|
|
|
|
lastName,
|
|
|
|
|
company,
|
|
|
|
|
phone,
|
|
|
|
|
sfNumber,
|
|
|
|
|
address,
|
|
|
|
|
nationality,
|
|
|
|
|
dateOfBirth,
|
|
|
|
|
gender,
|
|
|
|
|
} = signupData;
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-22 17:02:49 +09:00
|
|
|
// Enhanced input validation
|
|
|
|
|
this.validateSignupData(signupData);
|
|
|
|
|
|
2025-09-02 13:52:13 +09:00
|
|
|
// Check if a portal user already exists
|
2025-08-23 18:02:05 +09:00
|
|
|
const existingUser: PrismaUser | null = await this.usersService.findByEmailInternal(email);
|
2025-08-20 18:02:50 +09:00
|
|
|
if (existingUser) {
|
2025-09-02 13:52:13 +09:00
|
|
|
// Determine whether the user has a completed mapping (registered) or not
|
|
|
|
|
const mapped = await this.mappingsService.hasMapping(existingUser.id);
|
|
|
|
|
|
|
|
|
|
const message = mapped
|
|
|
|
|
? "You already have an account. Please sign in."
|
|
|
|
|
: "You already have an account with us. Please sign in to continue setup.";
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.SIGNUP,
|
2025-09-02 13:52:13 +09:00
|
|
|
existingUser.id,
|
|
|
|
|
{ email, reason: mapped ? "mapped_user_exists" : "unmapped_user_exists" },
|
2025-08-20 18:02:50 +09:00
|
|
|
request,
|
|
|
|
|
false,
|
2025-09-02 13:52:13 +09:00
|
|
|
message
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
2025-09-02 13:52:13 +09:00
|
|
|
throw new ConflictException(message);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
2025-08-22 17:02:49 +09:00
|
|
|
// Hash password with environment-based configuration
|
2025-08-27 10:54:05 +09:00
|
|
|
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
|
|
|
|
const saltRounds =
|
|
|
|
|
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
2025-08-20 18:02:50 +09:00
|
|
|
const passwordHash = await bcrypt.hash(password, saltRounds);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-08-23 17:24:37 +09:00
|
|
|
// 0. Lookup Salesforce Account by Customer Number (SF Number)
|
2025-08-23 18:02:05 +09:00
|
|
|
const sfAccount: { id: string } | null =
|
|
|
|
|
await this.salesforceService.findAccountByCustomerNumber(sfNumber);
|
2025-08-23 17:24:37 +09:00
|
|
|
if (!sfAccount) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
`Salesforce account not found for Customer Number: ${sfNumber}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
// 1. Create user in portal
|
2025-08-23 18:02:05 +09:00
|
|
|
const user: SharedUser = await this.usersService.create({
|
2025-08-20 18:02:50 +09:00
|
|
|
email,
|
|
|
|
|
passwordHash,
|
|
|
|
|
firstName,
|
|
|
|
|
lastName,
|
|
|
|
|
company,
|
|
|
|
|
phone,
|
|
|
|
|
emailVerified: false,
|
|
|
|
|
failedLoginAttempts: 0,
|
|
|
|
|
lockedUntil: null,
|
|
|
|
|
lastLoginAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2. Create client in WHMCS
|
2025-08-30 15:10:24 +09:00
|
|
|
let whmcsClient: { clientId: number };
|
|
|
|
|
try {
|
2025-09-02 13:52:13 +09:00
|
|
|
// 2.0 Pre-check for existing WHMCS client by email to avoid duplicates and guide UX
|
|
|
|
|
try {
|
|
|
|
|
const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email);
|
|
|
|
|
if (existingWhmcs) {
|
|
|
|
|
// If a mapping already exists for this WHMCS client, user should sign in
|
|
|
|
|
const existingMapping = await this.mappingsService.findByWhmcsClientId(
|
|
|
|
|
existingWhmcs.id
|
|
|
|
|
);
|
|
|
|
|
if (existingMapping) {
|
|
|
|
|
throw new ConflictException("You already have an account. Please sign in.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Otherwise, instruct to link the existing billing account instead of creating a new one
|
|
|
|
|
throw new ConflictException(
|
|
|
|
|
"We found an existing billing account for this email. Please link your account instead."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (pre) {
|
|
|
|
|
// Continue only if the client was not found; rethrow other errors
|
|
|
|
|
if (!(pre instanceof NotFoundException)) {
|
|
|
|
|
throw pre;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-30 15:10:24 +09:00
|
|
|
// Prepare WHMCS custom fields (IDs configurable via env)
|
|
|
|
|
const customerNumberFieldId = this.configService.get<string>(
|
|
|
|
|
"WHMCS_CUSTOMER_NUMBER_FIELD_ID",
|
|
|
|
|
"198"
|
|
|
|
|
);
|
|
|
|
|
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
|
|
|
|
|
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
|
|
|
|
|
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
|
|
|
|
|
|
|
|
|
|
const customfields: Record<string, string> = {};
|
|
|
|
|
if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber;
|
|
|
|
|
if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth;
|
|
|
|
|
if (genderFieldId && gender) customfields[genderFieldId] = gender;
|
|
|
|
|
if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality;
|
|
|
|
|
|
|
|
|
|
this.logger.log("Creating WHMCS client", { email, firstName, lastName, sfNumber });
|
|
|
|
|
|
|
|
|
|
// Validate required WHMCS fields
|
2025-09-01 15:11:42 +09:00
|
|
|
if (
|
|
|
|
|
!address?.line1 ||
|
|
|
|
|
!address?.city ||
|
|
|
|
|
!address?.state ||
|
|
|
|
|
!address?.postalCode ||
|
|
|
|
|
!address?.country
|
|
|
|
|
) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
"Complete address information is required for billing account creation"
|
|
|
|
|
);
|
2025-08-30 15:10:24 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!phone) {
|
|
|
|
|
throw new BadRequestException("Phone number is required for billing account creation");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.logger.log("WHMCS client data", {
|
|
|
|
|
email,
|
|
|
|
|
firstName,
|
|
|
|
|
lastName,
|
|
|
|
|
address: address,
|
|
|
|
|
phone,
|
|
|
|
|
country: address.country,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
whmcsClient = await this.whmcsService.addClient({
|
|
|
|
|
firstname: firstName,
|
|
|
|
|
lastname: lastName,
|
|
|
|
|
email,
|
|
|
|
|
companyname: company || "",
|
|
|
|
|
phonenumber: phone,
|
|
|
|
|
address1: address.line1,
|
|
|
|
|
address2: address.line2 || "",
|
|
|
|
|
city: address.city,
|
|
|
|
|
state: address.state,
|
|
|
|
|
postcode: address.postalCode,
|
|
|
|
|
country: address.country,
|
|
|
|
|
password2: password, // WHMCS requires plain password for new clients
|
|
|
|
|
customfields,
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-01 15:11:42 +09:00
|
|
|
this.logger.log("WHMCS client created successfully", {
|
|
|
|
|
clientId: whmcsClient.clientId,
|
|
|
|
|
email,
|
2025-08-30 15:10:24 +09:00
|
|
|
});
|
|
|
|
|
} catch (whmcsError) {
|
|
|
|
|
this.logger.error("Failed to create WHMCS client", {
|
|
|
|
|
error: getErrorMessage(whmcsError),
|
|
|
|
|
email,
|
|
|
|
|
firstName,
|
|
|
|
|
lastName,
|
|
|
|
|
});
|
2025-09-01 15:11:42 +09:00
|
|
|
|
2025-08-30 15:10:24 +09:00
|
|
|
// Rollback: Delete the portal user since WHMCS creation failed
|
|
|
|
|
try {
|
|
|
|
|
// Note: We should add a delete method to UsersService, but for now use direct approach
|
|
|
|
|
this.logger.warn("WHMCS creation failed, user account created but not fully integrated", {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
email,
|
|
|
|
|
whmcsError: getErrorMessage(whmcsError),
|
|
|
|
|
});
|
|
|
|
|
} catch (rollbackError) {
|
|
|
|
|
this.logger.error("Failed to log rollback information", {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
email,
|
|
|
|
|
rollbackError: getErrorMessage(rollbackError),
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-09-01 15:11:42 +09:00
|
|
|
|
2025-08-30 15:10:24 +09:00
|
|
|
throw new BadRequestException(
|
|
|
|
|
`Failed to create billing account: ${getErrorMessage(whmcsError)}`
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-08-20 18:02:50 +09:00
|
|
|
|
2025-08-23 17:24:37 +09:00
|
|
|
// 3. Store ID mappings
|
2025-08-20 18:02:50 +09:00
|
|
|
await this.mappingsService.createMapping({
|
|
|
|
|
userId: user.id,
|
|
|
|
|
whmcsClientId: whmcsClient.clientId,
|
|
|
|
|
sfAccountId: sfAccount.id,
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-06 10:01:44 +09:00
|
|
|
// 4. Do not update Salesforce Account fields from the portal. Salesforce stays authoritative.
|
2025-08-30 15:10:24 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
// Log successful signup
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.SIGNUP,
|
|
|
|
|
user.id,
|
2025-09-06 10:01:44 +09:00
|
|
|
{ email, whmcsClientId: whmcsClient.clientId },
|
2025-08-20 18:02:50 +09:00
|
|
|
request,
|
2025-08-22 17:02:49 +09:00
|
|
|
true
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Generate JWT token
|
2025-08-27 10:54:05 +09:00
|
|
|
const tokens = this.generateTokens(user);
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
user: this.sanitizeUser(user),
|
|
|
|
|
...tokens,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Log failed signup
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.SIGNUP,
|
|
|
|
|
undefined,
|
|
|
|
|
{ email, error: getErrorMessage(error) },
|
|
|
|
|
request,
|
|
|
|
|
false,
|
2025-08-22 17:02:49 +09:00
|
|
|
getErrorMessage(error)
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
// TODO: Implement rollback logic if any step fails
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.error("Signup error", { error: getErrorMessage(error) });
|
2025-08-21 15:24:40 +09:00
|
|
|
throw new BadRequestException("Failed to create user account");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-22 17:02:49 +09:00
|
|
|
async login(
|
|
|
|
|
user: {
|
|
|
|
|
id: string;
|
|
|
|
|
email: string;
|
|
|
|
|
role?: string;
|
|
|
|
|
passwordHash?: string | null;
|
|
|
|
|
failedLoginAttempts?: number | null;
|
|
|
|
|
lockedUntil?: Date | null;
|
|
|
|
|
},
|
2025-08-27 10:54:05 +09:00
|
|
|
request?: Request
|
2025-08-22 17:02:49 +09:00
|
|
|
) {
|
2025-08-20 18:02:50 +09:00
|
|
|
// Update last login time and reset failed attempts
|
|
|
|
|
await this.usersService.update(user.id, {
|
|
|
|
|
lastLoginAt: new Date(),
|
|
|
|
|
failedLoginAttempts: 0,
|
|
|
|
|
lockedUntil: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Log successful login
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.LOGIN_SUCCESS,
|
|
|
|
|
user.id,
|
|
|
|
|
{ email: user.email },
|
|
|
|
|
request,
|
2025-08-22 17:02:49 +09:00
|
|
|
true
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
|
2025-08-27 10:54:05 +09:00
|
|
|
const tokens = this.generateTokens(user);
|
2025-08-20 18:02:50 +09:00
|
|
|
return {
|
|
|
|
|
user: this.sanitizeUser(user),
|
|
|
|
|
...tokens,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 18:02:05 +09:00
|
|
|
async linkWhmcsUser(linkData: LinkWhmcsDto, _request?: Request) {
|
2025-08-20 18:02:50 +09:00
|
|
|
const { email, password } = linkData;
|
|
|
|
|
|
|
|
|
|
// Check if user already exists in portal
|
2025-08-23 18:02:05 +09:00
|
|
|
const existingUser: PrismaUser | null = await this.usersService.findByEmailInternal(email);
|
2025-08-20 18:02:50 +09:00
|
|
|
if (existingUser) {
|
|
|
|
|
// If user exists but has no password (abandoned during setup), allow them to continue
|
|
|
|
|
if (!existingUser.passwordHash) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.log(
|
2025-08-22 17:02:49 +09:00
|
|
|
`User ${email} exists but has no password - allowing password setup to continue`
|
2025-08-21 15:24:40 +09:00
|
|
|
);
|
2025-08-20 18:02:50 +09:00
|
|
|
return {
|
|
|
|
|
user: this.sanitizeUser(existingUser),
|
|
|
|
|
needsPasswordSet: true,
|
|
|
|
|
};
|
|
|
|
|
} else {
|
2025-08-21 15:24:40 +09:00
|
|
|
throw new ConflictException(
|
2025-08-22 17:02:49 +09:00
|
|
|
"User already exists in portal and has completed setup. Please use the login page."
|
2025-08-21 15:24:40 +09:00
|
|
|
);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 1. First, find the client by email using GetClientsDetails directly
|
2025-08-23 18:02:05 +09:00
|
|
|
let clientDetails: WhmcsClientResponse["client"];
|
2025-08-20 18:02:50 +09:00
|
|
|
try {
|
|
|
|
|
clientDetails = await this.whmcsService.getClientDetailsByEmail(email);
|
|
|
|
|
} catch (error) {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.warn(`WHMCS client lookup failed for email ${email}`, {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
|
|
|
|
throw new UnauthorizedException("WHMCS client not found with this email address");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-02 13:52:13 +09:00
|
|
|
// 1.a If this WHMCS client is already mapped, direct the user to sign in instead
|
|
|
|
|
try {
|
2025-09-02 16:09:17 +09:00
|
|
|
const existingMapping = await this.mappingsService.findByWhmcsClientId(clientDetails.id);
|
2025-09-02 13:52:13 +09:00
|
|
|
if (existingMapping) {
|
2025-09-02 16:09:17 +09:00
|
|
|
throw new ConflictException("This billing account is already linked. Please sign in.");
|
2025-09-02 13:52:13 +09:00
|
|
|
}
|
|
|
|
|
} catch (mapErr) {
|
|
|
|
|
if (mapErr instanceof ConflictException) throw mapErr;
|
|
|
|
|
// ignore not-found mapping cases; proceed with linking
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
// 2. Validate the password using ValidateLogin
|
|
|
|
|
try {
|
|
|
|
|
this.logger.debug(`About to validate WHMCS password for ${email}`);
|
2025-08-22 17:02:49 +09:00
|
|
|
const validateResult = await this.whmcsService.validateLogin(email, password);
|
|
|
|
|
this.logger.debug("WHMCS validation successful", { email });
|
2025-08-20 18:02:50 +09:00
|
|
|
if (!validateResult || !validateResult.userId) {
|
2025-08-21 15:24:40 +09:00
|
|
|
throw new UnauthorizedException("Invalid WHMCS credentials");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.debug("WHMCS validation failed", { email, error: getErrorMessage(error) });
|
2025-08-21 15:24:40 +09:00
|
|
|
throw new UnauthorizedException("Invalid WHMCS password");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Extract Customer Number from field ID 198
|
2025-08-28 18:57:12 +09:00
|
|
|
const customerNumberField = clientDetails.customfields?.find(field => field.id === 198);
|
|
|
|
|
const customerNumber = customerNumberField?.value?.trim();
|
2025-08-20 18:02:50 +09:00
|
|
|
|
2025-08-28 18:57:12 +09:00
|
|
|
if (!customerNumber) {
|
2025-08-20 18:02:50 +09:00
|
|
|
throw new BadRequestException(
|
2025-08-28 18:57:12 +09:00
|
|
|
`Customer Number not found in WHMCS custom field 198. Please contact support.`
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.log(
|
2025-08-22 17:02:49 +09:00
|
|
|
`Found Customer Number: ${customerNumber} for WHMCS client ${clientDetails.id}`
|
2025-08-21 15:24:40 +09:00
|
|
|
);
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
// 3. Find existing Salesforce account using Customer Number
|
2025-08-23 18:02:05 +09:00
|
|
|
const sfAccount: { id: string } | null =
|
|
|
|
|
await this.salesforceService.findAccountByCustomerNumber(customerNumber);
|
2025-08-20 18:02:50 +09:00
|
|
|
if (!sfAccount) {
|
2025-08-21 15:24:40 +09:00
|
|
|
throw new BadRequestException(
|
2025-08-22 17:02:49 +09:00
|
|
|
`Salesforce account not found for Customer Number: ${customerNumber}. Please contact support.`
|
2025-08-21 15:24:40 +09:00
|
|
|
);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. Create portal user (without password initially)
|
2025-08-23 18:02:05 +09:00
|
|
|
const user: SharedUser = await this.usersService.create({
|
2025-08-20 18:02:50 +09:00
|
|
|
email,
|
|
|
|
|
passwordHash: null, // No password hash - will be set when user sets password
|
2025-08-21 15:24:40 +09:00
|
|
|
firstName: clientDetails.firstname || "",
|
|
|
|
|
lastName: clientDetails.lastname || "",
|
|
|
|
|
company: clientDetails.companyname || "",
|
|
|
|
|
phone: clientDetails.phonenumber || "",
|
2025-08-20 18:02:50 +09:00
|
|
|
emailVerified: true, // WHMCS users are pre-verified
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 5. Store ID mappings
|
|
|
|
|
await this.mappingsService.createMapping({
|
|
|
|
|
userId: user.id,
|
2025-08-23 18:02:05 +09:00
|
|
|
whmcsClientId: clientDetails.id,
|
2025-08-20 18:02:50 +09:00
|
|
|
sfAccountId: sfAccount.id,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
user: this.sanitizeUser(user),
|
|
|
|
|
needsPasswordSet: true,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.error("WHMCS linking error", { error: getErrorMessage(error) });
|
|
|
|
|
if (error instanceof BadRequestException || error instanceof UnauthorizedException) {
|
2025-08-20 18:02:50 +09:00
|
|
|
throw error;
|
|
|
|
|
}
|
2025-08-21 15:24:40 +09:00
|
|
|
throw new BadRequestException("Failed to link WHMCS account");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async checkPasswordNeeded(email: string) {
|
2025-08-23 18:02:05 +09:00
|
|
|
const user: PrismaUser | null = await this.usersService.findByEmailInternal(email);
|
2025-08-20 18:02:50 +09:00
|
|
|
if (!user) {
|
|
|
|
|
return { needsPasswordSet: false, userExists: false };
|
|
|
|
|
}
|
2025-08-21 15:24:40 +09:00
|
|
|
|
|
|
|
|
return {
|
2025-08-20 18:02:50 +09:00
|
|
|
needsPasswordSet: !user.passwordHash,
|
|
|
|
|
userExists: true,
|
2025-08-21 15:24:40 +09:00
|
|
|
email: user.email,
|
2025-08-20 18:02:50 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 18:02:05 +09:00
|
|
|
async setPassword(setPasswordData: SetPasswordDto, _request?: Request) {
|
2025-08-20 18:02:50 +09:00
|
|
|
const { email, password } = setPasswordData;
|
|
|
|
|
|
2025-08-23 18:02:05 +09:00
|
|
|
const user: PrismaUser | null = await this.usersService.findByEmailInternal(email);
|
2025-08-20 18:02:50 +09:00
|
|
|
if (!user) {
|
2025-08-21 15:24:40 +09:00
|
|
|
throw new UnauthorizedException("User not found");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if user needs to set password (linked users have null password hash)
|
|
|
|
|
if (user.passwordHash) {
|
2025-08-21 15:24:40 +09:00
|
|
|
throw new BadRequestException("User already has a password set");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hash new password
|
|
|
|
|
const saltRounds = 12; // Use a fixed safe value
|
|
|
|
|
const passwordHash = await bcrypt.hash(password, saltRounds);
|
|
|
|
|
|
|
|
|
|
// Update user with new password
|
2025-08-23 18:02:05 +09:00
|
|
|
const updatedUser: SharedUser = await this.usersService.update(user.id, {
|
2025-08-21 15:24:40 +09:00
|
|
|
passwordHash,
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
// Generate tokens
|
2025-08-27 10:54:05 +09:00
|
|
|
const tokens = this.generateTokens(updatedUser);
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
user: this.sanitizeUser(updatedUser),
|
|
|
|
|
...tokens,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-21 15:24:40 +09:00
|
|
|
async validateUser(
|
|
|
|
|
email: string,
|
|
|
|
|
password: string,
|
2025-08-23 18:02:05 +09:00
|
|
|
_request?: Request
|
|
|
|
|
): Promise<PrismaUser | null> {
|
|
|
|
|
const user: PrismaUser | null = await this.usersService.findByEmailInternal(email);
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
if (!user) {
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.LOGIN_FAILED,
|
|
|
|
|
undefined,
|
2025-08-21 15:24:40 +09:00
|
|
|
{ email, reason: "User not found" },
|
2025-08-22 17:02:49 +09:00
|
|
|
_request,
|
2025-08-20 18:02:50 +09:00
|
|
|
false,
|
2025-08-22 17:02:49 +09:00
|
|
|
"User not found"
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if account is locked
|
|
|
|
|
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.LOGIN_FAILED,
|
|
|
|
|
user.id,
|
2025-08-21 15:24:40 +09:00
|
|
|
{ email, reason: "Account locked" },
|
2025-08-22 17:02:49 +09:00
|
|
|
_request,
|
2025-08-20 18:02:50 +09:00
|
|
|
false,
|
2025-08-22 17:02:49 +09:00
|
|
|
"Account is locked"
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
if (!user.passwordHash) {
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.LOGIN_FAILED,
|
|
|
|
|
user.id,
|
2025-08-21 15:24:40 +09:00
|
|
|
{ email, reason: "No password set" },
|
2025-08-22 17:02:49 +09:00
|
|
|
_request,
|
2025-08-20 18:02:50 +09:00
|
|
|
false,
|
2025-08-22 17:02:49 +09:00
|
|
|
"No password set"
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
if (isPasswordValid) {
|
|
|
|
|
return user;
|
|
|
|
|
} else {
|
|
|
|
|
// Increment failed login attempts
|
2025-08-22 17:02:49 +09:00
|
|
|
await this.handleFailedLogin(user, _request);
|
2025-08-20 18:02:50 +09:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.error("Password validation error", { email, error: getErrorMessage(error) });
|
2025-08-20 18:02:50 +09:00
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.LOGIN_FAILED,
|
|
|
|
|
user.id,
|
|
|
|
|
{ email, error: getErrorMessage(error) },
|
2025-08-22 17:02:49 +09:00
|
|
|
_request,
|
2025-08-20 18:02:50 +09:00
|
|
|
false,
|
2025-08-22 17:02:49 +09:00
|
|
|
getErrorMessage(error)
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-27 10:54:05 +09:00
|
|
|
private async handleFailedLogin(user: PrismaUser, _request?: Request): Promise<void> {
|
2025-08-20 18:02:50 +09:00
|
|
|
const newFailedAttempts = (user.failedLoginAttempts || 0) + 1;
|
|
|
|
|
let lockedUntil = null;
|
|
|
|
|
let isAccountLocked = false;
|
|
|
|
|
|
|
|
|
|
// Lock account if max attempts reached
|
|
|
|
|
if (newFailedAttempts >= this.MAX_LOGIN_ATTEMPTS) {
|
|
|
|
|
lockedUntil = new Date();
|
2025-08-22 17:02:49 +09:00
|
|
|
lockedUntil.setMinutes(lockedUntil.getMinutes() + this.LOCKOUT_DURATION_MINUTES);
|
2025-08-20 18:02:50 +09:00
|
|
|
isAccountLocked = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.usersService.update(user.id, {
|
|
|
|
|
failedLoginAttempts: newFailedAttempts,
|
|
|
|
|
lockedUntil,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Log failed login
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.LOGIN_FAILED,
|
|
|
|
|
user.id,
|
2025-08-21 15:24:40 +09:00
|
|
|
{
|
|
|
|
|
email: user.email,
|
2025-08-20 18:02:50 +09:00
|
|
|
failedAttempts: newFailedAttempts,
|
2025-08-21 15:24:40 +09:00
|
|
|
lockedUntil: lockedUntil?.toISOString(),
|
2025-08-20 18:02:50 +09:00
|
|
|
},
|
2025-08-22 17:02:49 +09:00
|
|
|
_request,
|
2025-08-20 18:02:50 +09:00
|
|
|
false,
|
2025-08-22 17:02:49 +09:00
|
|
|
"Invalid password"
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Log account lock if applicable
|
|
|
|
|
if (isAccountLocked) {
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.ACCOUNT_LOCKED,
|
|
|
|
|
user.id,
|
2025-08-21 15:24:40 +09:00
|
|
|
{
|
|
|
|
|
email: user.email,
|
2025-08-20 18:02:50 +09:00
|
|
|
lockDuration: this.LOCKOUT_DURATION_MINUTES,
|
2025-08-21 15:24:40 +09:00
|
|
|
lockedUntil: lockedUntil?.toISOString(),
|
2025-08-20 18:02:50 +09:00
|
|
|
},
|
2025-08-22 17:02:49 +09:00
|
|
|
_request,
|
2025-08-20 18:02:50 +09:00
|
|
|
false,
|
2025-08-22 17:02:49 +09:00
|
|
|
`Account locked for ${this.LOCKOUT_DURATION_MINUTES} minutes`
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 18:02:05 +09:00
|
|
|
async logout(userId: string, token: string, _request?: Request): Promise<void> {
|
2025-08-20 18:02:50 +09:00
|
|
|
// Blacklist the token
|
|
|
|
|
await this.tokenBlacklistService.blacklistToken(token);
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-22 17:02:49 +09:00
|
|
|
await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, _request, true);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper methods
|
2025-08-23 18:02:05 +09:00
|
|
|
private generateTokens(user: { id: string; email: string; role?: string }) {
|
2025-08-20 18:02:50 +09:00
|
|
|
const payload = { email: user.email, sub: user.id, role: user.role };
|
|
|
|
|
return {
|
|
|
|
|
access_token: this.jwtService.sign(payload),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-22 17:02:49 +09:00
|
|
|
private sanitizeUser<
|
|
|
|
|
T extends {
|
|
|
|
|
id: string;
|
|
|
|
|
email: string;
|
|
|
|
|
role?: string;
|
|
|
|
|
passwordHash?: string | null;
|
|
|
|
|
failedLoginAttempts?: number | null;
|
|
|
|
|
lockedUntil?: Date | null;
|
|
|
|
|
},
|
|
|
|
|
>(user: T): Omit<T, "passwordHash" | "failedLoginAttempts" | "lockedUntil"> {
|
|
|
|
|
const {
|
|
|
|
|
passwordHash: _passwordHash,
|
|
|
|
|
failedLoginAttempts: _failedLoginAttempts,
|
|
|
|
|
lockedUntil: _lockedUntil,
|
|
|
|
|
...sanitizedUser
|
|
|
|
|
} = user as T & {
|
|
|
|
|
passwordHash?: string | null;
|
|
|
|
|
failedLoginAttempts?: number | null;
|
|
|
|
|
lockedUntil?: Date | null;
|
|
|
|
|
};
|
2025-08-20 18:02:50 +09:00
|
|
|
return sanitizedUser;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create SSO link to WHMCS for general access
|
|
|
|
|
*/
|
2025-08-21 15:24:40 +09:00
|
|
|
async createSsoLink(
|
|
|
|
|
userId: string,
|
2025-08-22 17:02:49 +09:00
|
|
|
destination?: string
|
2025-08-21 15:24:40 +09:00
|
|
|
): Promise<{ url: string; expiresAt: string }> {
|
2025-08-20 18:02:50 +09:00
|
|
|
try {
|
|
|
|
|
// Production-safe logging - no sensitive data
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.log("Creating SSO link request");
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
// Get WHMCS client ID from user mapping
|
|
|
|
|
const mapping = await this.mappingsService.findByUserId(userId);
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
if (!mapping?.whmcsClientId) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.warn("SSO link creation failed: No WHMCS mapping found");
|
|
|
|
|
throw new UnauthorizedException("WHMCS client mapping not found");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create SSO token using custom redirect for better compatibility
|
2025-08-22 17:02:49 +09:00
|
|
|
const ssoDestination = "sso:custom_redirect";
|
|
|
|
|
const ssoRedirectPath = destination || "clientarea.php";
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
const result = await this.whmcsService.createSsoToken(
|
|
|
|
|
mapping.whmcsClientId,
|
|
|
|
|
ssoDestination,
|
2025-08-22 17:02:49 +09:00
|
|
|
ssoRedirectPath
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
2025-08-21 15:24:40 +09:00
|
|
|
|
|
|
|
|
this.logger.log("SSO link created successfully");
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
return result;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Production-safe error logging - no sensitive data or stack traces
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("SSO link creation failed", {
|
|
|
|
|
errorType: error instanceof Error ? error.constructor.name : "Unknown",
|
|
|
|
|
message: getErrorMessage(error),
|
2025-08-20 18:02:50 +09:00
|
|
|
});
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-22 17:02:49 +09:00
|
|
|
|
2025-08-23 17:24:37 +09:00
|
|
|
async requestPasswordReset(email: string): Promise<void> {
|
|
|
|
|
const user = await this.usersService.findByEmailInternal(email);
|
|
|
|
|
// Always act as if successful to avoid account enumeration
|
|
|
|
|
if (!user) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create a short-lived signed token (JWT) containing user id and purpose
|
|
|
|
|
const token = this.jwtService.sign(
|
|
|
|
|
{ sub: user.id, purpose: "password_reset" },
|
|
|
|
|
{ expiresIn: "15m" }
|
|
|
|
|
);
|
|
|
|
|
|
2025-08-27 10:54:05 +09:00
|
|
|
const appBase = this.configService.get<string>("APP_BASE_URL", "http://localhost:3000");
|
2025-08-23 17:24:37 +09:00
|
|
|
const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`;
|
|
|
|
|
const templateId = this.configService.get<string>("EMAIL_TEMPLATE_RESET");
|
|
|
|
|
|
|
|
|
|
if (templateId) {
|
|
|
|
|
await this.emailService.sendEmail({
|
|
|
|
|
to: email,
|
|
|
|
|
subject: "Reset your password",
|
|
|
|
|
templateId,
|
|
|
|
|
dynamicTemplateData: { resetUrl },
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
await this.emailService.sendEmail({
|
|
|
|
|
to: email,
|
|
|
|
|
subject: "Reset your Assist Solutions password",
|
|
|
|
|
html: `
|
|
|
|
|
<p>We received a request to reset your password.</p>
|
|
|
|
|
<p><a href="${resetUrl}">Click here to reset your password</a>. This link expires in 15 minutes.</p>
|
|
|
|
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
|
|
|
|
`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async resetPassword(token: string, newPassword: string) {
|
|
|
|
|
try {
|
|
|
|
|
const payload = this.jwtService.verify<{ sub: string; purpose: string }>(token);
|
|
|
|
|
if (payload.purpose !== "password_reset") {
|
|
|
|
|
throw new BadRequestException("Invalid token");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const user = await this.usersService.findById(payload.sub);
|
|
|
|
|
if (!user) throw new BadRequestException("Invalid token");
|
|
|
|
|
|
2025-08-27 10:54:05 +09:00
|
|
|
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
|
|
|
|
const saltRounds =
|
|
|
|
|
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
2025-08-23 17:24:37 +09:00
|
|
|
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
|
|
|
|
|
|
|
|
|
|
const updatedUser = await this.usersService.update(user.id, { passwordHash });
|
2025-08-27 10:54:05 +09:00
|
|
|
const tokens = this.generateTokens(updatedUser);
|
2025-08-23 17:24:37 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
user: this.sanitizeUser(updatedUser),
|
|
|
|
|
...tokens,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error("Reset password failed", { error: getErrorMessage(error) });
|
|
|
|
|
throw new BadRequestException("Invalid or expired token");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-02 13:52:13 +09:00
|
|
|
async getAccountStatus(email: string) {
|
|
|
|
|
// Normalize email
|
|
|
|
|
const normalized = email?.toLowerCase().trim();
|
|
|
|
|
if (!normalized || !normalized.includes("@")) {
|
|
|
|
|
throw new BadRequestException("Valid email is required");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let portalUser: PrismaUser | null = null;
|
|
|
|
|
let mapped = false;
|
|
|
|
|
let whmcsExists = false;
|
|
|
|
|
let needsPasswordSet = false;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
portalUser = await this.usersService.findByEmailInternal(normalized);
|
|
|
|
|
if (portalUser) {
|
|
|
|
|
mapped = await this.mappingsService.hasMapping(portalUser.id);
|
|
|
|
|
needsPasswordSet = !portalUser.passwordHash;
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.logger.warn("Account status: portal lookup failed", { error: getErrorMessage(e) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If already mapped, we can assume a WHMCS client exists
|
|
|
|
|
if (mapped) {
|
|
|
|
|
whmcsExists = true;
|
|
|
|
|
} else {
|
|
|
|
|
// Try a direct WHMCS lookup by email (best-effort)
|
|
|
|
|
try {
|
|
|
|
|
const client = await this.whmcsService.getClientDetailsByEmail(normalized);
|
|
|
|
|
whmcsExists = !!client;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Treat not found as no; other errors as unknown (leave whmcsExists false)
|
|
|
|
|
this.logger.debug("Account status: WHMCS lookup", { error: getErrorMessage(e) });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let state: "none" | "portal_only" | "whmcs_only" | "both_mapped" = "none";
|
|
|
|
|
if (portalUser && mapped) state = "both_mapped";
|
|
|
|
|
else if (portalUser) state = "portal_only";
|
|
|
|
|
else if (whmcsExists) state = "whmcs_only";
|
|
|
|
|
|
|
|
|
|
const recommendedAction = (() => {
|
|
|
|
|
switch (state) {
|
|
|
|
|
case "both_mapped":
|
|
|
|
|
return "sign_in" as const;
|
|
|
|
|
case "portal_only":
|
|
|
|
|
return needsPasswordSet ? ("set_password" as const) : ("sign_in" as const);
|
|
|
|
|
case "whmcs_only":
|
|
|
|
|
return "link_account" as const;
|
|
|
|
|
case "none":
|
|
|
|
|
default:
|
|
|
|
|
return "sign_up" as const;
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
state,
|
|
|
|
|
portalUserExists: !!portalUser,
|
|
|
|
|
whmcsClientExists: whmcsExists,
|
|
|
|
|
mapped,
|
|
|
|
|
needsPasswordSet,
|
|
|
|
|
recommendedAction,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async changePassword(userId: string, currentPassword: string, newPassword: string) {
|
|
|
|
|
// Fetch raw user with passwordHash
|
|
|
|
|
const user = await this.usersService.findByIdInternal(userId);
|
|
|
|
|
if (!user) {
|
|
|
|
|
throw new UnauthorizedException("User not found");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!user.passwordHash) {
|
|
|
|
|
throw new BadRequestException("No password set. Please set a password first.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isCurrentValid = await bcrypt.compare(currentPassword, user.passwordHash);
|
|
|
|
|
if (!isCurrentValid) {
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.PASSWORD_CHANGE,
|
|
|
|
|
user.id,
|
|
|
|
|
{ action: "change_password", reason: "invalid_current_password" },
|
|
|
|
|
undefined,
|
|
|
|
|
false,
|
|
|
|
|
"Invalid current password"
|
|
|
|
|
);
|
|
|
|
|
throw new BadRequestException("Current password is incorrect");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate new password strength (reusing signup policy)
|
2025-09-02 16:09:17 +09:00
|
|
|
if (
|
|
|
|
|
newPassword.length < 8 ||
|
|
|
|
|
!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(newPassword)
|
|
|
|
|
) {
|
2025-09-02 13:52:13 +09:00
|
|
|
throw new BadRequestException(
|
|
|
|
|
"Password must be at least 8 characters and include uppercase, lowercase, number, and special character."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
|
|
|
|
const saltRounds =
|
|
|
|
|
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
|
|
|
|
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
|
|
|
|
|
|
|
|
|
|
const updatedUser = await this.usersService.update(user.id, { passwordHash });
|
|
|
|
|
|
|
|
|
|
await this.auditService.logAuthEvent(
|
|
|
|
|
AuditAction.PASSWORD_CHANGE,
|
|
|
|
|
user.id,
|
|
|
|
|
{ action: "change_password" },
|
|
|
|
|
undefined,
|
|
|
|
|
true
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Issue fresh tokens
|
|
|
|
|
const tokens = this.generateTokens(updatedUser);
|
|
|
|
|
return {
|
|
|
|
|
user: this.sanitizeUser(updatedUser),
|
|
|
|
|
...tokens,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-22 17:02:49 +09:00
|
|
|
private validateSignupData(signupData: SignupDto) {
|
|
|
|
|
const { email, password, firstName, lastName } = signupData;
|
|
|
|
|
|
|
|
|
|
if (!email || !password || !firstName || !lastName) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
"Email, password, firstName, and lastName are required for signup."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!email.includes("@")) {
|
|
|
|
|
throw new BadRequestException("Invalid email address.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (password.length < 8) {
|
|
|
|
|
throw new BadRequestException("Password must be at least 8 characters long.");
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-02 13:52:13 +09:00
|
|
|
// Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character
|
|
|
|
|
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(password)) {
|
2025-08-22 17:02:49 +09:00
|
|
|
throw new BadRequestException(
|
2025-09-02 13:52:13 +09:00
|
|
|
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."
|
2025-08-22 17:02:49 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|