import { Injectable, UnauthorizedException, ConflictException, BadRequestException, NotFoundException, Inject, } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; import { ConfigService } from "@nestjs/config"; import * as bcrypt from "bcrypt"; import { UsersService } from "@bff/modules/users/users.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service"; import { TokenBlacklistService } from "./services/token-blacklist.service"; import { SignupDto } from "./dto/signup.dto"; import { LinkWhmcsDto } from "./dto/link-whmcs.dto"; import { ValidateSignupDto } from "./dto/validate-signup.dto"; import { SetPasswordDto } from "./dto/set-password.dto"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util"; import { EmailService } from "@bff/infra/email/email.service"; import { User as SharedUser } from "@customer-portal/domain"; import type { User as PrismaUser } from "@prisma/client"; import type { Request } from "express"; import { WhmcsClientResponse } from "@bff/integrations/whmcs/types/whmcs-api.types"; import { PrismaService } from "@bff/infra/database/prisma.service"; @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, private emailService: EmailService, private prisma: PrismaService, @Inject(Logger) private readonly logger: Logger ) {} 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 { health.whmcs = await this.whmcsService.healthCheck(); } 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" ); throw new ConflictException( "You already have an account. Please use the login page to access your existing account." ); } // 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, { sfNumber, sfAccountId: sfAccount.id, whAccount: accountDetails.WH_Account__c, reason: "WH Account not empty", }, request, false, "Account already has WHMCS integration" ); throw new ConflictException( "You already have an account. Please use the login page to access your existing account." ); } // Log successful validation await this.auditService.logAuthEvent( AuditAction.SIGNUP, undefined, { sfNumber, sfAccountId: sfAccount.id, step: "validation" }, request, true ); return { valid: true, sfAccountId: sfAccount.id, message: "Customer number validated successfully", }; } 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"); } } async signup(signupData: SignupDto, request?: Request) { const { email, password, firstName, lastName, company, phone, sfNumber, address, nationality, dateOfBirth, gender, } = signupData; // Enhanced input validation this.validateSignupData(signupData); // Check if a portal user already exists (do not create anything yet) const existingUser = await this.usersService.findByEmailInternal(email); if (existingUser) { 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."; await this.auditService.logAuthEvent( AuditAction.SIGNUP, existingUser.id, { email, reason: mapped ? "mapped_user_exists" : "unmapped_user_exists" }, request, false, message ); throw new ConflictException(message); } // Hash password with environment-based configuration (computed ahead, used after WHMCS success) const saltRoundsConfig = this.configService.get("BCRYPT_ROUNDS", 12); const saltRounds = typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig; const passwordHash = await bcrypt.hash(password, saltRounds); try { // 0. Lookup Salesforce Account by Customer Number (SF Number) const sfAccount: { id: string } | null = await this.salesforceService.findAccountByCustomerNumber(sfNumber); if (!sfAccount) { throw new BadRequestException( `Salesforce account not found for Customer Number: ${sfNumber}` ); } // 1. Create client in WHMCS first (no portal user is created yet) let whmcsClient: { clientId: number }; try { // 1.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; } } // Prepare WHMCS custom fields (IDs configurable via env) const customerNumberFieldId = this.configService.get( "WHMCS_CUSTOMER_NUMBER_FIELD_ID", "198" ); const dobFieldId = this.configService.get("WHMCS_DOB_FIELD_ID"); const genderFieldId = this.configService.get("WHMCS_GENDER_FIELD_ID"); const nationalityFieldId = this.configService.get("WHMCS_NATIONALITY_FIELD_ID"); const customfields: Record = {}; 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 if ( !address?.street || !address?.city || !address?.state || !address?.postalCode || !address?.country ) { throw new BadRequestException( "Complete address information is required for billing account creation" ); } 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.street, address2: address.streetLine2 || "", city: address.city, state: address.state, postcode: address.postalCode, country: address.country, password2: password, // WHMCS requires plain password for new clients customfields, }); this.logger.log("WHMCS client created successfully", { clientId: whmcsClient.clientId, email, }); } catch (whmcsError) { this.logger.error("Failed to create WHMCS client", { error: getErrorMessage(whmcsError), email, firstName, lastName, }); throw new BadRequestException( `Failed to create billing account: ${getErrorMessage(whmcsError)}` ); } // 2. Only now create the portal user and mapping atomically const { createdUserId } = await this.prisma.$transaction(async tx => { const created = await tx.user.create({ data: { email, passwordHash, firstName, lastName, company: company || null, phone: phone || null, emailVerified: false, failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: null, }, select: { id: true, email: true }, }); await tx.idMapping.create({ data: { userId: created.id, whmcsClientId: whmcsClient.clientId, sfAccountId: sfAccount.id, }, }); return { createdUserId: created.id }; }); // Fetch sanitized user response const freshUser = await this.usersService.findByIdInternal(createdUserId); // Log successful signup await this.auditService.logAuthEvent( AuditAction.SIGNUP, createdUserId, { email, whmcsClientId: whmcsClient.clientId }, request, true ); const tokens = this.generateTokens({ id: createdUserId, email }); return { user: this.sanitizeUser( freshUser ?? ({ id: createdUserId, email } as unknown as PrismaUser) ), ...tokens, }; } catch (error) { // Log failed signup await this.auditService.logAuthEvent( AuditAction.SIGNUP, undefined, { email, error: getErrorMessage(error) }, request, false, getErrorMessage(error) ); // TODO: Implement rollback logic if any step fails this.logger.error("Signup error", { error: getErrorMessage(error) }); throw new BadRequestException("Failed to create user account"); } } async login( user: { id: string; email: string; role?: string; passwordHash?: string | null; failedLoginAttempts?: number | null; lockedUntil?: Date | null; }, request?: Request ) { // 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, true ); const tokens = this.generateTokens(user); return { user: this.sanitizeUser(user), ...tokens, }; } async linkWhmcsUser(linkData: LinkWhmcsDto, _request?: Request) { const { email, password } = linkData; // Check if user already exists in portal const existingUser = await this.usersService.findByEmailInternal(email); if (existingUser) { // If user exists but has no password (abandoned during setup), allow them to continue if (!existingUser.passwordHash) { this.logger.log( `User ${email} exists but has no password - allowing password setup to continue` ); return { user: this.sanitizeUser(existingUser), needsPasswordSet: true, }; } else { throw new ConflictException( "User already exists in portal and has completed setup. Please use the login page." ); } } try { // 1. First, find the client by email using GetClientsDetails directly let clientDetails: WhmcsClientResponse["client"]; try { clientDetails = await this.whmcsService.getClientDetailsByEmail(email); } catch (error) { this.logger.warn(`WHMCS client lookup failed for email ${email}`, { error: getErrorMessage(error), }); throw new UnauthorizedException("WHMCS client not found with this email address"); } // 1.a If this WHMCS client is already mapped, direct the user to sign in instead try { const existingMapping = await this.mappingsService.findByWhmcsClientId(clientDetails.id); if (existingMapping) { throw new ConflictException("This billing account is already linked. Please sign in."); } } catch (mapErr) { if (mapErr instanceof ConflictException) throw mapErr; // ignore not-found mapping cases; proceed with linking } // 2. Validate the password using ValidateLogin try { this.logger.debug(`About to validate WHMCS password for ${email}`); const validateResult = await this.whmcsService.validateLogin(email, password); this.logger.debug("WHMCS validation successful", { email }); if (!validateResult || !validateResult.userId) { throw new UnauthorizedException("Invalid WHMCS credentials"); } } catch (error) { this.logger.debug("WHMCS validation failed", { email, error: getErrorMessage(error) }); throw new UnauthorizedException("Invalid WHMCS password"); } // 3. Extract Customer Number from field ID 198 const customerNumberField = clientDetails.customfields?.find(field => field.id === 198); const customerNumber = customerNumberField?.value?.trim(); if (!customerNumber) { throw new BadRequestException( `Customer Number not found in WHMCS custom field 198. Please contact support.` ); } this.logger.log( `Found Customer Number: ${customerNumber} for WHMCS client ${clientDetails.id}` ); // 3. Find existing Salesforce account using Customer Number const sfAccount: { id: string } | null = await this.salesforceService.findAccountByCustomerNumber(customerNumber); if (!sfAccount) { throw new BadRequestException( `Salesforce account not found for Customer Number: ${customerNumber}. Please contact support.` ); } // 4. Create portal user (without password initially) const user: SharedUser = await this.usersService.create({ email, passwordHash: null, // No password hash - will be set when user sets password firstName: clientDetails.firstname || "", lastName: clientDetails.lastname || "", company: clientDetails.companyname || "", phone: clientDetails.phonenumber || "", emailVerified: true, // WHMCS users are pre-verified }); // 5. Store ID mappings await this.mappingsService.createMapping({ userId: user.id, whmcsClientId: clientDetails.id, sfAccountId: sfAccount.id, }); return { user: this.sanitizeUser(user), needsPasswordSet: true, }; } catch (error) { this.logger.error("WHMCS linking error", { error: getErrorMessage(error) }); if (error instanceof BadRequestException || error instanceof UnauthorizedException) { throw error; } throw new BadRequestException("Failed to link WHMCS account"); } } async checkPasswordNeeded(email: string) { const user = await this.usersService.findByEmailInternal(email); if (!user) { return { needsPasswordSet: false, userExists: false }; } return { needsPasswordSet: !user.passwordHash, userExists: true, email: user.email, }; } async setPassword(setPasswordData: SetPasswordDto, _request?: Request) { const { email, password } = setPasswordData; const user = await this.usersService.findByEmailInternal(email); if (!user) { throw new UnauthorizedException("User not found"); } // Check if user needs to set password (linked users have null password hash) if (user.passwordHash) { throw new BadRequestException("User already has a password set"); } // Hash new password const saltRounds = 12; // Use a fixed safe value const passwordHash = await bcrypt.hash(password, saltRounds); // Update user with new password const updatedUser: SharedUser = await this.usersService.update(user.id, { passwordHash, }); // Generate tokens const tokens = this.generateTokens(updatedUser); return { user: this.sanitizeUser(updatedUser), ...tokens, }; } async validateUser( email: string, password: string, _request?: Request ): Promise { const user = await this.usersService.findByEmailInternal(email); if (!user) { await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, undefined, { email, reason: "User not found" }, _request, false, "User not found" ); return null; } // Check if account is locked if (user.lockedUntil && user.lockedUntil > new Date()) { await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, user.id, { email, reason: "Account locked" }, _request, false, "Account is locked" ); return null; } if (!user.passwordHash) { await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, user.id, { email, reason: "No password set" }, _request, false, "No password set" ); return null; } try { const isPasswordValid = await bcrypt.compare(password, user.passwordHash); if (isPasswordValid) { return user; } else { // Increment failed login attempts await this.handleFailedLogin(user, _request); return null; } } catch (error) { this.logger.error("Password validation error", { email, error: getErrorMessage(error) }); await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, user.id, { email, error: getErrorMessage(error) }, _request, false, getErrorMessage(error) ); return null; } } private async handleFailedLogin(user: PrismaUser, _request?: Request): Promise { 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(); lockedUntil.setMinutes(lockedUntil.getMinutes() + this.LOCKOUT_DURATION_MINUTES); isAccountLocked = true; } await this.usersService.update(user.id, { failedLoginAttempts: newFailedAttempts, lockedUntil, }); // Log failed login await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, user.id, { email: user.email, failedAttempts: newFailedAttempts, lockedUntil: lockedUntil?.toISOString(), }, _request, false, "Invalid password" ); // Log account lock if applicable if (isAccountLocked) { await this.auditService.logAuthEvent( AuditAction.ACCOUNT_LOCKED, user.id, { email: user.email, lockDuration: this.LOCKOUT_DURATION_MINUTES, lockedUntil: lockedUntil?.toISOString(), }, _request, false, `Account locked for ${this.LOCKOUT_DURATION_MINUTES} minutes` ); } } async logout(userId: string, token: string, _request?: Request): Promise { // Blacklist the token await this.tokenBlacklistService.blacklistToken(token); await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, _request, true); } // Helper methods private generateTokens(user: { id: string; email: string; role?: string }) { const payload = { email: user.email, sub: user.id, role: user.role }; return { access_token: this.jwtService.sign(payload), }; } private sanitizeUser< T extends { id: string; email: string; role?: string; passwordHash?: string | null; failedLoginAttempts?: number | null; lockedUntil?: Date | null; }, >(user: T): Omit { const { passwordHash: _passwordHash, failedLoginAttempts: _failedLoginAttempts, lockedUntil: _lockedUntil, ...sanitizedUser } = user as T & { passwordHash?: string | null; failedLoginAttempts?: number | null; lockedUntil?: Date | null; }; return sanitizedUser; } /** * Create SSO link to WHMCS for general access */ async createSsoLink( userId: string, destination?: string ): Promise<{ url: string; expiresAt: string }> { try { // Production-safe logging - no sensitive data this.logger.log("Creating SSO link request"); // Get WHMCS client ID from user mapping const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { this.logger.warn("SSO link creation failed: No WHMCS mapping found"); throw new UnauthorizedException("WHMCS client mapping not found"); } // Create SSO token using custom redirect for better compatibility const ssoDestination = "sso:custom_redirect"; const ssoRedirectPath = this.sanitizeWhmcsRedirectPath(destination); const result = await this.whmcsService.createSsoToken( mapping.whmcsClientId, ssoDestination, ssoRedirectPath ); this.logger.log("SSO link created successfully"); return result; } catch (error) { // Production-safe error logging - no sensitive data or stack traces this.logger.error("SSO link creation failed", { errorType: error instanceof Error ? error.constructor.name : "Unknown", message: getErrorMessage(error), }); throw error; } } /** * Ensure only safe, relative WHMCS paths are allowed for SSO redirects. * Falls back to 'clientarea.php' when input is missing or unsafe. */ private sanitizeWhmcsRedirectPath(path?: string): string { return sanitizeWhmcsRedirectPath(path); } async requestPasswordReset(email: string): Promise { 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" } ); const appBase = this.configService.get("APP_BASE_URL", "http://localhost:3000"); const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`; const templateId = this.configService.get("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: `

We received a request to reset your password.

Click here to reset your password. This link expires in 15 minutes.

If you didn't request this, you can safely ignore this email.

`, }); } } 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"); const saltRoundsConfig = this.configService.get("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 }); const tokens = this.generateTokens(updatedUser); return { user: this.sanitizeUser(updatedUser), ...tokens, }; } catch (error) { this.logger.error("Reset password failed", { error: getErrorMessage(error) }); throw new BadRequestException("Invalid or expired token"); } } 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) if ( newPassword.length < 8 || !/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(newPassword) ) { throw new BadRequestException( "Password must be at least 8 characters and include uppercase, lowercase, number, and special character." ); } const saltRoundsConfig = this.configService.get("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, }; } /** * Preflight validation for signup. No side effects. * Returns a clear nextAction for the UI and detailed flags. */ async signupPreflight(signupData: SignupDto) { const { email, sfNumber } = signupData; const normalizedEmail = email.toLowerCase().trim(); const result: { ok: boolean; canProceed: boolean; nextAction: "proceed_signup" | "link_whmcs" | "login" | "fix_input" | "blocked"; messages: string[]; normalized: { email: string }; portal: { userExists: boolean; needsPasswordSet?: boolean }; salesforce: { accountId?: string; alreadyMapped: boolean }; whmcs: { clientExists: boolean; clientId?: number }; } = { ok: true, canProceed: false, nextAction: "blocked", messages: [], normalized: { email: normalizedEmail }, portal: { userExists: false }, salesforce: { alreadyMapped: false }, whmcs: { clientExists: false }, }; // 0) Portal user existence const portalUser = await this.usersService.findByEmailInternal(normalizedEmail); if (portalUser) { result.portal.userExists = true; const mapped = await this.mappingsService.hasMapping(portalUser.id); if (mapped) { result.nextAction = "login"; result.messages.push("An account already exists. Please sign in."); return result; } // Legacy unmapped user result.portal.needsPasswordSet = !portalUser.passwordHash; result.nextAction = portalUser.passwordHash ? "login" : "fix_input"; result.messages.push( portalUser.passwordHash ? "An account exists without billing link. Please sign in to continue setup." : "An account exists and needs password setup. Please set a password to continue." ); return result; } // 1) Salesforce checks const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber); if (!sfAccount) { result.nextAction = "fix_input"; result.messages.push("Customer number not found in Salesforce"); return result; } result.salesforce.accountId = sfAccount.id; const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id); if (existingMapping) { result.salesforce.alreadyMapped = true; result.nextAction = "login"; result.messages.push("This customer number is already registered. Please sign in."); return result; } // 2) WHMCS checks by email try { const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail); if (client) { result.whmcs.clientExists = true; result.whmcs.clientId = client.id; // If this WHMCS client is already linked to a portal user, direct to login try { const mapped = await this.mappingsService.findByWhmcsClientId(client.id); if (mapped) { result.nextAction = "login"; result.messages.push("This billing account is already linked. Please sign in."); return result; } } catch { // ignore; treat as unmapped } // Client exists but not mapped → suggest linking instead of creating new result.nextAction = "link_whmcs"; result.messages.push( "We found an existing billing account for this email. Please link your account." ); return result; } } catch (err) { // NotFoundException is expected when client doesn't exist. Other errors are reported. if (!(err instanceof NotFoundException)) { this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) }); result.messages.push("Unable to verify billing system. Please try again later."); result.nextAction = "blocked"; return result; } } // If we reach here: no portal user, SF valid and not mapped, no WHMCS client → OK to proceed result.canProceed = true; result.nextAction = "proceed_signup"; result.messages.push("All checks passed. Ready to create your account."); return result; } 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."); } // 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)) { throw new BadRequestException( "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character." ); } } }