refactor: streamline authentication workflows and remove legacy services
- Replace SignupWorkflowService and GetStartedWorkflowService with new coordinator services for improved modularity and clarity. - Update auth controller to utilize the new GetStartedCoordinator. - Refactor account status handling in the GetStartedForm component to leverage XState for state management. - Introduce new hooks for managing the get-started flow, enhancing the overall user experience. - Remove deprecated services and clean up related imports to maintain code hygiene.
This commit is contained in:
parent
912582caf7
commit
9941250cb5
@ -57,23 +57,24 @@ export class WhmcsAccountDiscoveryService {
|
||||
this.logger.log(`Discovered client by email: ${email}`);
|
||||
return client;
|
||||
} catch (error) {
|
||||
// Handle "Not Found" specifically
|
||||
// Handle "Not Found" specifically — this is expected for discovery
|
||||
if (
|
||||
error instanceof NotFoundException ||
|
||||
(error instanceof Error && error.message.includes("not found"))
|
||||
(error instanceof Error && error.message.toLowerCase().includes("not found"))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Log other errors but don't crash - return null to indicate lookup failed safely
|
||||
this.logger.warn(
|
||||
// Re-throw all other errors (auth failures, network issues, timeouts, etc.)
|
||||
// to avoid silently masking problems like 403 permission errors
|
||||
this.logger.error(
|
||||
{
|
||||
email,
|
||||
error: extractErrorMessage(error),
|
||||
},
|
||||
"Failed to discover client by email"
|
||||
);
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,14 +120,24 @@ export class WhmcsAccountDiscoveryService {
|
||||
clientId: Number(clientAssociation.id),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
// Handle "Not Found" specifically — this is expected for discovery
|
||||
if (
|
||||
error instanceof NotFoundException ||
|
||||
(error instanceof Error && error.message.toLowerCase().includes("not found"))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Re-throw all other errors (auth failures, network issues, timeouts, etc.)
|
||||
// to avoid silently masking problems like 403 permission errors
|
||||
this.logger.error(
|
||||
{
|
||||
email,
|
||||
error: extractErrorMessage(error),
|
||||
},
|
||||
"Failed to discover user by email"
|
||||
);
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -9,8 +9,6 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util.js";
|
||||
import {
|
||||
type SignupRequest,
|
||||
type ValidateSignupRequest,
|
||||
type LinkWhmcsRequest,
|
||||
type SetPasswordRequest,
|
||||
type ChangePasswordRequest,
|
||||
@ -22,7 +20,6 @@ import type { Request } from "express";
|
||||
import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js";
|
||||
import { AuthTokenService } from "../infra/token/token.service.js";
|
||||
import { AuthRateLimitService } from "../infra/rate-limiting/auth-rate-limit.service.js";
|
||||
import { SignupWorkflowService } from "../infra/workflows/signup-workflow.service.js";
|
||||
import { PasswordWorkflowService } from "../infra/workflows/password-workflow.service.js";
|
||||
import { WhmcsLinkWorkflowService } from "../infra/workflows/whmcs-link-workflow.service.js";
|
||||
// mapPrismaUserToDomain removed - usersService.update now returns profile directly
|
||||
@ -36,7 +33,6 @@ import { AuthLoginService } from "./auth-login.service.js";
|
||||
* Delegates to specialized services for specific functionality:
|
||||
* - AuthHealthService: Health checks
|
||||
* - AuthLoginService: Login validation
|
||||
* - SignupWorkflowService: Signup flow
|
||||
* - PasswordWorkflowService: Password operations
|
||||
* - WhmcsLinkWorkflowService: WHMCS account linking
|
||||
*/
|
||||
@ -50,7 +46,6 @@ export class AuthOrchestrator {
|
||||
private readonly salesforceService: SalesforceFacade,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly tokenBlacklistService: TokenBlacklistService,
|
||||
private readonly signupWorkflow: SignupWorkflowService,
|
||||
private readonly passwordWorkflow: PasswordWorkflowService,
|
||||
private readonly whmcsLinkWorkflow: WhmcsLinkWorkflowService,
|
||||
private readonly tokenService: AuthTokenService,
|
||||
@ -64,14 +59,6 @@ export class AuthOrchestrator {
|
||||
return this.healthService.check();
|
||||
}
|
||||
|
||||
async validateSignup(validateData: ValidateSignupRequest, request?: Request) {
|
||||
return this.signupWorkflow.validateSignup(validateData, request);
|
||||
}
|
||||
|
||||
async signup(signupData: SignupRequest, request?: Request) {
|
||||
return this.signupWorkflow.signup(signupData, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Original login method - validates credentials and completes login
|
||||
* Used by LocalAuthGuard flow
|
||||
@ -313,11 +300,4 @@ export class AuthOrchestrator {
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight validation for signup
|
||||
*/
|
||||
async signupPreflight(signupData: SignupRequest) {
|
||||
return this.signupWorkflow.signupPreflight(signupData);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,7 +16,6 @@ import { PasswordResetTokenService } from "./infra/token/password-reset-token.se
|
||||
import { CacheModule } from "@bff/infra/cache/cache.module.js";
|
||||
import { AuthTokenService } from "./infra/token/token.service.js";
|
||||
import { JoseJwtService } from "./infra/token/jose-jwt.service.js";
|
||||
import { SignupWorkflowService } from "./infra/workflows/signup-workflow.service.js";
|
||||
import { PasswordWorkflowService } from "./infra/workflows/password-workflow.service.js";
|
||||
import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js";
|
||||
import { FailedLoginThrottleGuard } from "./presentation/http/guards/failed-login-throttle.guard.js";
|
||||
@ -29,7 +28,20 @@ import { SignupUserCreationService } from "./infra/workflows/signup/signup-user-
|
||||
// Get Started flow
|
||||
import { OtpService } from "./infra/otp/otp.service.js";
|
||||
import { GetStartedSessionService } from "./infra/otp/get-started-session.service.js";
|
||||
import { GetStartedWorkflowService } from "./infra/workflows/get-started-workflow.service.js";
|
||||
import { GetStartedCoordinator } from "./infra/workflows/get-started-coordinator.service.js";
|
||||
import { VerificationWorkflowService } from "./infra/workflows/verification-workflow.service.js";
|
||||
import { GuestEligibilityWorkflowService } from "./infra/workflows/guest-eligibility-workflow.service.js";
|
||||
import { NewCustomerSignupWorkflowService } from "./infra/workflows/new-customer-signup-workflow.service.js";
|
||||
import { SfCompletionWorkflowService } from "./infra/workflows/sf-completion-workflow.service.js";
|
||||
import { WhmcsMigrationWorkflowService } from "./infra/workflows/whmcs-migration-workflow.service.js";
|
||||
import {
|
||||
ResolveSalesforceAccountStep,
|
||||
CreateWhmcsClientStep,
|
||||
CreatePortalUserStep,
|
||||
UpdateSalesforceFlagsStep,
|
||||
GenerateAuthResultStep,
|
||||
CreateEligibilityCaseStep,
|
||||
} from "./infra/workflows/steps/index.js";
|
||||
import { GetStartedController } from "./presentation/http/get-started.controller.js";
|
||||
import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
|
||||
// Login OTP flow
|
||||
@ -53,8 +65,7 @@ import { TrustedDeviceService } from "./infra/trusted-device/trusted-device.serv
|
||||
AuthTokenService,
|
||||
JoseJwtService,
|
||||
PasswordResetTokenService,
|
||||
// Signup workflow services
|
||||
SignupWorkflowService,
|
||||
// Signup shared services (reused by get-started workflows)
|
||||
SignupAccountResolverService,
|
||||
SignupValidationService,
|
||||
SignupWhmcsService,
|
||||
@ -65,7 +76,19 @@ import { TrustedDeviceService } from "./infra/trusted-device/trusted-device.serv
|
||||
// Get Started flow services
|
||||
OtpService,
|
||||
GetStartedSessionService,
|
||||
GetStartedWorkflowService,
|
||||
GetStartedCoordinator,
|
||||
VerificationWorkflowService,
|
||||
GuestEligibilityWorkflowService,
|
||||
NewCustomerSignupWorkflowService,
|
||||
SfCompletionWorkflowService,
|
||||
WhmcsMigrationWorkflowService,
|
||||
// Shared step services
|
||||
ResolveSalesforceAccountStep,
|
||||
CreateWhmcsClientStep,
|
||||
CreatePortalUserStep,
|
||||
UpdateSalesforceFlagsStep,
|
||||
GenerateAuthResultStep,
|
||||
CreateEligibilityCaseStep,
|
||||
// Login OTP flow services
|
||||
LoginSessionService,
|
||||
LoginOtpWorkflowService,
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type {
|
||||
SendVerificationCodeRequest,
|
||||
SendVerificationCodeResponse,
|
||||
VerifyCodeRequest,
|
||||
VerifyCodeResponse,
|
||||
GuestEligibilityRequest,
|
||||
GuestEligibilityResponse,
|
||||
CompleteAccountRequest,
|
||||
SignupWithEligibilityRequest,
|
||||
MigrateWhmcsAccountRequest,
|
||||
} from "@customer-portal/domain/get-started";
|
||||
|
||||
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
|
||||
|
||||
import { VerificationWorkflowService } from "./verification-workflow.service.js";
|
||||
import { GuestEligibilityWorkflowService } from "./guest-eligibility-workflow.service.js";
|
||||
import { NewCustomerSignupWorkflowService } from "./new-customer-signup-workflow.service.js";
|
||||
import { SfCompletionWorkflowService } from "./sf-completion-workflow.service.js";
|
||||
import { WhmcsMigrationWorkflowService } from "./whmcs-migration-workflow.service.js";
|
||||
|
||||
/**
|
||||
* Get Started Coordinator
|
||||
*
|
||||
* Thin routing layer that delegates to focused workflow services.
|
||||
* Method signatures match the previous god class so the controller
|
||||
* requires minimal changes.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GetStartedCoordinator {
|
||||
constructor(
|
||||
private readonly verification: VerificationWorkflowService,
|
||||
private readonly guestEligibility: GuestEligibilityWorkflowService,
|
||||
private readonly newCustomerSignup: NewCustomerSignupWorkflowService,
|
||||
private readonly sfCompletion: SfCompletionWorkflowService,
|
||||
private readonly whmcsMigration: WhmcsMigrationWorkflowService
|
||||
) {}
|
||||
|
||||
async sendVerificationCode(
|
||||
request: SendVerificationCodeRequest,
|
||||
fingerprint?: string
|
||||
): Promise<SendVerificationCodeResponse> {
|
||||
return this.verification.sendCode(request, fingerprint);
|
||||
}
|
||||
|
||||
async verifyCode(request: VerifyCodeRequest, fingerprint?: string): Promise<VerifyCodeResponse> {
|
||||
return this.verification.verifyCode(request, fingerprint);
|
||||
}
|
||||
|
||||
async guestEligibilityCheck(
|
||||
request: GuestEligibilityRequest,
|
||||
fingerprint?: string
|
||||
): Promise<GuestEligibilityResponse> {
|
||||
return this.guestEligibility.execute(request, fingerprint);
|
||||
}
|
||||
|
||||
async completeAccount(request: CompleteAccountRequest): Promise<AuthResultInternal> {
|
||||
return this.sfCompletion.execute(request);
|
||||
}
|
||||
|
||||
async signupWithEligibility(request: SignupWithEligibilityRequest): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
eligibilityRequestId?: string;
|
||||
authResult?: AuthResultInternal;
|
||||
}> {
|
||||
return this.newCustomerSignup.execute(request);
|
||||
}
|
||||
|
||||
async migrateWhmcsAccount(request: MigrateWhmcsAccountRequest): Promise<AuthResultInternal> {
|
||||
return this.whmcsMigration.execute(request);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,168 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
import {
|
||||
type GuestEligibilityRequest,
|
||||
type GuestEligibilityResponse,
|
||||
} from "@customer-portal/domain/get-started";
|
||||
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import {
|
||||
PORTAL_SOURCE_INTERNET_ELIGIBILITY,
|
||||
PORTAL_STATUS_NOT_YET,
|
||||
} from "@bff/modules/auth/constants/portal.constants.js";
|
||||
|
||||
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
|
||||
import { CreateEligibilityCaseStep } from "./steps/index.js";
|
||||
|
||||
/**
|
||||
* Remove undefined properties from an object (for exactOptionalPropertyTypes compatibility)
|
||||
*/
|
||||
function removeUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([, value]) => value !== undefined)
|
||||
) as Partial<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest Eligibility Workflow Service
|
||||
*
|
||||
* Handles guest eligibility checks — creates SF Account + eligibility case
|
||||
* without OTP verification. Uses shared CreateEligibilityCaseStep.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GuestEligibilityWorkflowService {
|
||||
constructor(
|
||||
private readonly sessionService: GetStartedSessionService,
|
||||
private readonly salesforceAccountService: SalesforceAccountService,
|
||||
private readonly salesforceFacade: SalesforceFacade,
|
||||
private readonly lockService: DistributedLockService,
|
||||
private readonly eligibilityCaseStep: CreateEligibilityCaseStep,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Guest eligibility check — NO email verification required
|
||||
*
|
||||
* Allows users to check availability without verifying email first.
|
||||
* Creates SF Account + eligibility case immediately.
|
||||
*/
|
||||
async execute(
|
||||
request: GuestEligibilityRequest,
|
||||
fingerprint?: string
|
||||
): Promise<GuestEligibilityResponse> {
|
||||
const { email, firstName, lastName, address, phone, continueToAccount } = request;
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
this.logger.log(
|
||||
{ email: normalizedEmail, continueToAccount, fingerprint },
|
||||
"Guest eligibility check initiated"
|
||||
);
|
||||
|
||||
const lockKey = `guest-eligibility:${normalizedEmail}`;
|
||||
|
||||
try {
|
||||
return await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
// Check if SF account already exists for this email
|
||||
let sfAccountId: string;
|
||||
|
||||
const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail);
|
||||
|
||||
if (existingSf) {
|
||||
sfAccountId = existingSf.id;
|
||||
this.logger.log(
|
||||
{ email: normalizedEmail, sfAccountId },
|
||||
"Using existing SF account for guest eligibility check"
|
||||
);
|
||||
|
||||
await this.salesforceFacade.updateAccountPortalFields(sfAccountId, {
|
||||
status: PORTAL_STATUS_NOT_YET,
|
||||
source: PORTAL_SOURCE_INTERNET_ELIGIBILITY,
|
||||
});
|
||||
} else {
|
||||
const { accountId } = await this.salesforceAccountService.createAccount({
|
||||
firstName,
|
||||
lastName,
|
||||
email: normalizedEmail,
|
||||
phone: phone ?? "",
|
||||
portalSource: PORTAL_SOURCE_INTERNET_ELIGIBILITY,
|
||||
});
|
||||
sfAccountId = accountId;
|
||||
this.logger.log(
|
||||
{ email: normalizedEmail, sfAccountId },
|
||||
"Created SF account for guest eligibility check"
|
||||
);
|
||||
}
|
||||
|
||||
// Save Japanese address to SF Contact (if Japanese address fields provided)
|
||||
if (address.prefectureJa || address.cityJa || address.townJa) {
|
||||
await this.salesforceAccountService.updateContactAddress(sfAccountId, {
|
||||
mailingStreet: `${address.townJa || ""}${address.streetAddress || ""}`.trim(),
|
||||
mailingCity: address.cityJa || address.city,
|
||||
mailingState: address.prefectureJa || address.state,
|
||||
mailingPostalCode: address.postcode,
|
||||
mailingCountry: "Japan",
|
||||
buildingName: address.buildingName ?? null,
|
||||
roomNumber: address.roomNumber ?? null,
|
||||
});
|
||||
this.logger.debug({ sfAccountId }, "Updated SF Contact with Japanese address");
|
||||
}
|
||||
|
||||
// Create eligibility case via shared step
|
||||
const { caseId } = await this.eligibilityCaseStep.execute({
|
||||
sfAccountId,
|
||||
address: {
|
||||
address1: address.address1,
|
||||
...(address.address2 ? { address2: address.address2 } : {}),
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postcode: address.postcode,
|
||||
...(address.country ? { country: address.country } : {}),
|
||||
...(address.streetAddress ? { streetAddress: address.streetAddress } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// If user wants to continue to account creation, generate a handoff token
|
||||
let handoffToken: string | undefined;
|
||||
if (continueToAccount) {
|
||||
handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, {
|
||||
firstName,
|
||||
lastName,
|
||||
address: removeUndefined(address),
|
||||
...(phone && { phone }),
|
||||
sfAccountId,
|
||||
});
|
||||
this.logger.debug(
|
||||
{ email: normalizedEmail, handoffToken },
|
||||
"Created handoff token for account creation"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
submitted: true,
|
||||
requestId: caseId,
|
||||
sfAccountId,
|
||||
handoffToken,
|
||||
message: "Eligibility check submitted. We'll notify you of the results.",
|
||||
};
|
||||
},
|
||||
{ ttlMs: 30_000 }
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ error: extractErrorMessage(error), email: normalizedEmail },
|
||||
"Guest eligibility check failed"
|
||||
);
|
||||
|
||||
return {
|
||||
submitted: false,
|
||||
message: "Failed to submit eligibility check. Please try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,309 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
import { type SignupWithEligibilityRequest } from "@customer-portal/domain/get-started";
|
||||
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { PORTAL_SOURCE_INTERNET_ELIGIBILITY } from "@bff/modules/auth/constants/portal.constants.js";
|
||||
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
|
||||
|
||||
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
|
||||
import {
|
||||
ResolveSalesforceAccountStep,
|
||||
CreateEligibilityCaseStep,
|
||||
CreateWhmcsClientStep,
|
||||
CreatePortalUserStep,
|
||||
UpdateSalesforceFlagsStep,
|
||||
GenerateAuthResultStep,
|
||||
} from "./steps/index.js";
|
||||
import { classifyError } from "./workflow-error.util.js";
|
||||
|
||||
/**
|
||||
* New Customer Signup Workflow Service
|
||||
*
|
||||
* Handles NEW_CUSTOMER signup path (and signupWithEligibility).
|
||||
* Creates SF Account + Case + WHMCS client + Portal user in a
|
||||
* distributed transaction with compensating rollbacks.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NewCustomerSignupWorkflowService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly sessionService: GetStartedSessionService,
|
||||
private readonly lockService: DistributedLockService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly whmcsDiscovery: WhmcsAccountDiscoveryService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly sfStep: ResolveSalesforceAccountStep,
|
||||
private readonly caseStep: CreateEligibilityCaseStep,
|
||||
private readonly whmcsStep: CreateWhmcsClientStep,
|
||||
private readonly portalUserStep: CreatePortalUserStep,
|
||||
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
|
||||
private readonly authResultStep: GenerateAuthResultStep,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Full signup with eligibility check — creates everything in one operation.
|
||||
*
|
||||
* Flow: acquire session → lock by email → distributed transaction
|
||||
*/
|
||||
async execute(request: SignupWithEligibilityRequest): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
errorCategory?: string;
|
||||
eligibilityRequestId?: string;
|
||||
authResult?: AuthResultInternal;
|
||||
}> {
|
||||
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
|
||||
request.sessionToken,
|
||||
"signup_with_eligibility"
|
||||
);
|
||||
|
||||
if (!sessionResult.success) {
|
||||
return { success: false, message: sessionResult.reason };
|
||||
}
|
||||
|
||||
const normalizedEmail = sessionResult.session.email;
|
||||
this.logger.log({ email: normalizedEmail }, "Starting signup with eligibility");
|
||||
|
||||
const lockKey = `signup-email:${normalizedEmail}`;
|
||||
|
||||
try {
|
||||
return await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => this.executeSignup(request, normalizedEmail),
|
||||
{ ttlMs: 60_000 }
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ error: extractErrorMessage(error), email: normalizedEmail },
|
||||
"Signup with eligibility failed"
|
||||
);
|
||||
|
||||
const classified = classifyError(error);
|
||||
return {
|
||||
success: false,
|
||||
errorCategory: classified.errorCategory,
|
||||
message: classified.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async executeSignup(
|
||||
request: SignupWithEligibilityRequest,
|
||||
email: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
errorCategory?: string;
|
||||
eligibilityRequestId?: string;
|
||||
authResult?: AuthResultInternal;
|
||||
}> {
|
||||
// Check for existing accounts
|
||||
const existingCheck = await this.checkExistingAccounts(email);
|
||||
if (existingCheck) {
|
||||
return existingCheck;
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(request.password);
|
||||
|
||||
return this.executeWithSteps(request, email, passwordHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the signup flow with steps and manual rollback coordination.
|
||||
*
|
||||
* Uses DistributedTransactionService for coordinated rollback while
|
||||
* passing data between steps.
|
||||
*/
|
||||
private async executeWithSteps(
|
||||
request: SignupWithEligibilityRequest,
|
||||
email: string,
|
||||
passwordHash: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
errorCategory?: string;
|
||||
eligibilityRequestId?: string;
|
||||
authResult?: AuthResultInternal;
|
||||
}> {
|
||||
const { firstName, lastName, address, phone, password, dateOfBirth, gender } = request;
|
||||
|
||||
// Step 1: Resolve SF account (CRITICAL)
|
||||
const sfResult = await this.sfStep.execute({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
source: PORTAL_SOURCE_INTERNET_ELIGIBILITY,
|
||||
updateSourceIfExists: true,
|
||||
});
|
||||
|
||||
// Step 2: Create eligibility case (DEGRADABLE)
|
||||
let eligibilityRequestId: string | undefined;
|
||||
try {
|
||||
const caseResult = await this.caseStep.execute({
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
address: {
|
||||
address1: address.address1,
|
||||
...(address.address2 && { address2: address.address2 }),
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postcode: address.postcode,
|
||||
...(address.country ? { country: address.country } : {}),
|
||||
},
|
||||
});
|
||||
eligibilityRequestId = caseResult.caseId;
|
||||
} catch (caseError) {
|
||||
this.logger.warn(
|
||||
{ error: extractErrorMessage(caseError), email },
|
||||
"Eligibility case creation failed (non-critical, continuing)"
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Create WHMCS client (CRITICAL, has rollback)
|
||||
const whmcsResult = await this.whmcsStep.execute({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
phone: phone ?? "",
|
||||
address: {
|
||||
address1: address.address1,
|
||||
...(address.address2 && { address2: address.address2 }),
|
||||
city: address.city,
|
||||
state: address.state ?? "",
|
||||
postcode: address.postcode,
|
||||
country: address.country ?? "Japan",
|
||||
},
|
||||
customerNumber: sfResult.customerNumber ?? null,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
});
|
||||
|
||||
// Step 4: Create portal user (CRITICAL, has rollback)
|
||||
let portalUserResult: { userId: string };
|
||||
try {
|
||||
portalUserResult = await this.portalUserStep.execute({
|
||||
email,
|
||||
passwordHash,
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
whmcsClientId: whmcsResult.whmcsClientId,
|
||||
});
|
||||
} catch (portalError) {
|
||||
// Rollback WHMCS client
|
||||
await this.whmcsStep.rollback(whmcsResult.whmcsClientId, email);
|
||||
throw portalError;
|
||||
}
|
||||
|
||||
// Step 5: Update SF flags (DEGRADABLE)
|
||||
try {
|
||||
await this.sfFlagsStep.execute({
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
whmcsClientId: whmcsResult.whmcsClientId,
|
||||
});
|
||||
} catch (flagsError) {
|
||||
this.logger.warn(
|
||||
{ error: extractErrorMessage(flagsError), email },
|
||||
"SF flags update failed (non-critical, continuing)"
|
||||
);
|
||||
}
|
||||
|
||||
// Step 6: Generate auth result + finalize
|
||||
const authResult = await this.authResultStep.execute({
|
||||
userId: portalUserResult.userId,
|
||||
email,
|
||||
auditSource: "signup_with_eligibility",
|
||||
auditDetails: { whmcsClientId: whmcsResult.whmcsClientId },
|
||||
});
|
||||
|
||||
await this.sessionService.invalidate(request.sessionToken);
|
||||
await this.sendWelcomeWithEligibilityEmail(email, firstName, eligibilityRequestId);
|
||||
|
||||
this.logger.log(
|
||||
{ email, userId: portalUserResult.userId, eligibilityRequestId },
|
||||
"Signup with eligibility completed successfully"
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...(eligibilityRequestId != null && { eligibilityRequestId }),
|
||||
authResult,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkExistingAccounts(
|
||||
email: string
|
||||
): Promise<{ success: false; message: string } | null> {
|
||||
const [portalUser, whmcsClient] = await Promise.all([
|
||||
this.usersService.findByEmailInternal(email),
|
||||
this.whmcsDiscovery.findClientByEmail(email),
|
||||
]);
|
||||
|
||||
if (portalUser) {
|
||||
return {
|
||||
success: false,
|
||||
message: "An account already exists with this email. Please log in.",
|
||||
};
|
||||
}
|
||||
|
||||
if (whmcsClient) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
"A billing account already exists with this email. Please use account linking instead.",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async sendWelcomeWithEligibilityEmail(
|
||||
email: string,
|
||||
firstName: string,
|
||||
eligibilityRequestId?: string
|
||||
): Promise<void> {
|
||||
const appBase = this.config.get<string>("APP_BASE_URL", "http://localhost:3000");
|
||||
const templateId = this.config.get<string>("EMAIL_TEMPLATE_WELCOME_WITH_ELIGIBILITY");
|
||||
|
||||
try {
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Welcome! Your account is ready",
|
||||
templateId,
|
||||
dynamicTemplateData: {
|
||||
firstName,
|
||||
portalUrl: appBase,
|
||||
dashboardUrl: `${appBase}/account`,
|
||||
eligibilityRequestId: eligibilityRequestId ?? "",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Welcome! Your account is ready",
|
||||
html: `
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>Welcome! Your account has been created successfully.</p>
|
||||
<p>We're also checking internet availability at your address. We'll notify you of the results within 1-2 business days.</p>
|
||||
${eligibilityRequestId ? `<p>Reference ID: ${eligibilityRequestId}</p>` : ""}
|
||||
<p>Log in to your dashboard: <a href="${appBase}/account">${appBase}/account</a></p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
} catch (emailError) {
|
||||
this.logger.warn(
|
||||
{ error: extractErrorMessage(emailError), email },
|
||||
"Failed to send welcome email (non-critical)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,246 @@
|
||||
import { BadRequestException, ConflictException, Inject, Injectable } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
import { type CompleteAccountRequest } from "@customer-portal/domain/get-started";
|
||||
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { PORTAL_SOURCE_NEW_SIGNUP } from "@bff/modules/auth/constants/portal.constants.js";
|
||||
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
|
||||
|
||||
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
|
||||
import {
|
||||
ResolveSalesforceAccountStep,
|
||||
CreateWhmcsClientStep,
|
||||
CreatePortalUserStep,
|
||||
UpdateSalesforceFlagsStep,
|
||||
GenerateAuthResultStep,
|
||||
} from "./steps/index.js";
|
||||
|
||||
/**
|
||||
* SF Completion Workflow Service
|
||||
*
|
||||
* Handles account completion for SF_UNMAPPED and NEW_CUSTOMER paths
|
||||
* (the completeAccount endpoint). Creates WHMCS client + Portal user
|
||||
* and links to an existing or new SF account.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SfCompletionWorkflowService {
|
||||
constructor(
|
||||
private readonly sessionService: GetStartedSessionService,
|
||||
private readonly lockService: DistributedLockService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly whmcsDiscovery: WhmcsAccountDiscoveryService,
|
||||
private readonly sfStep: ResolveSalesforceAccountStep,
|
||||
private readonly whmcsStep: CreateWhmcsClientStep,
|
||||
private readonly portalUserStep: CreatePortalUserStep,
|
||||
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
|
||||
private readonly authResultStep: GenerateAuthResultStep,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Complete account for users with SF account but no WHMCS/Portal.
|
||||
* Also handles NEW_CUSTOMER who need a new SF account created.
|
||||
*/
|
||||
async execute(request: CompleteAccountRequest): Promise<AuthResultInternal> {
|
||||
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
|
||||
request.sessionToken,
|
||||
"complete_account"
|
||||
);
|
||||
|
||||
if (!sessionResult.success) {
|
||||
throw new BadRequestException(sessionResult.reason);
|
||||
}
|
||||
|
||||
const session = sessionResult.session;
|
||||
this.validateRequest(request, session);
|
||||
|
||||
const lockKey = `complete-account:${session.email}`;
|
||||
|
||||
try {
|
||||
return await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => this.executeCompletion(request, session),
|
||||
{ ttlMs: 60_000 }
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ error: extractErrorMessage(error), email: session.email },
|
||||
"Account completion failed"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateRequest(
|
||||
request: CompleteAccountRequest,
|
||||
session: { sfAccountId?: string | undefined }
|
||||
): void {
|
||||
const isNewCustomer = !session.sfAccountId;
|
||||
|
||||
if (!isNewCustomer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.firstName || !request.lastName) {
|
||||
throw new BadRequestException("First name and last name are required for new accounts.");
|
||||
}
|
||||
if (!request.address) {
|
||||
throw new BadRequestException("Address is required for new accounts.");
|
||||
}
|
||||
}
|
||||
|
||||
private async executeCompletion(
|
||||
request: CompleteAccountRequest,
|
||||
session: {
|
||||
email: string;
|
||||
sfAccountId?: string | undefined;
|
||||
firstName?: string | undefined;
|
||||
lastName?: string | undefined;
|
||||
address?: Record<string, string | undefined> | undefined;
|
||||
}
|
||||
): Promise<AuthResultInternal> {
|
||||
const {
|
||||
password,
|
||||
phone,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
firstName,
|
||||
lastName,
|
||||
address: requestAddress,
|
||||
} = request;
|
||||
const isNewCustomer = !session.sfAccountId;
|
||||
|
||||
// Check for existing accounts
|
||||
await this.ensureNoExistingAccounts(session.email);
|
||||
|
||||
// Resolve address and names
|
||||
const address = this.resolveAddress(requestAddress, session.address);
|
||||
const { finalFirstName, finalLastName } = this.resolveNames(firstName, lastName, session);
|
||||
|
||||
// Step 1: Resolve SF account (CRITICAL)
|
||||
const sfResult = await this.sfStep.execute({
|
||||
email: session.email,
|
||||
...(session.sfAccountId != null && { existingAccountId: session.sfAccountId }),
|
||||
firstName: finalFirstName,
|
||||
lastName: finalLastName,
|
||||
phone,
|
||||
source: PORTAL_SOURCE_NEW_SIGNUP,
|
||||
});
|
||||
|
||||
// Step 2: Create WHMCS client (CRITICAL, has rollback)
|
||||
const whmcsResult = await this.whmcsStep.execute({
|
||||
firstName: finalFirstName,
|
||||
lastName: finalLastName,
|
||||
email: session.email,
|
||||
password,
|
||||
phone: phone ?? "",
|
||||
address: {
|
||||
address1: address.address1,
|
||||
...(address.address2 && { address2: address.address2 }),
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postcode: address.postcode,
|
||||
country: address.country ?? "Japan",
|
||||
},
|
||||
customerNumber: sfResult.customerNumber ?? null,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
});
|
||||
|
||||
// Step 3: Create portal user (CRITICAL, has rollback)
|
||||
const passwordHash = await argon2.hash(password);
|
||||
let portalUserResult: { userId: string };
|
||||
try {
|
||||
portalUserResult = await this.portalUserStep.execute({
|
||||
email: session.email,
|
||||
passwordHash,
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
whmcsClientId: whmcsResult.whmcsClientId,
|
||||
});
|
||||
} catch (portalError) {
|
||||
await this.whmcsStep.rollback(whmcsResult.whmcsClientId, session.email);
|
||||
throw portalError;
|
||||
}
|
||||
|
||||
// Step 4: Update SF flags (DEGRADABLE)
|
||||
try {
|
||||
await this.sfFlagsStep.execute({
|
||||
sfAccountId: sfResult.sfAccountId,
|
||||
whmcsClientId: whmcsResult.whmcsClientId,
|
||||
});
|
||||
} catch (flagsError) {
|
||||
this.logger.warn(
|
||||
{ error: extractErrorMessage(flagsError), email: session.email },
|
||||
"SF flags update failed (non-critical, continuing)"
|
||||
);
|
||||
}
|
||||
|
||||
// Step 5: Generate auth result
|
||||
const authResult = await this.authResultStep.execute({
|
||||
userId: portalUserResult.userId,
|
||||
email: session.email,
|
||||
auditSource: isNewCustomer ? "get_started_new_customer" : "get_started_complete_account",
|
||||
auditDetails: { whmcsClientId: whmcsResult.whmcsClientId },
|
||||
});
|
||||
|
||||
await this.sessionService.invalidate(request.sessionToken);
|
||||
|
||||
this.logger.log(
|
||||
{ email: session.email, userId: portalUserResult.userId, isNewCustomer },
|
||||
"Account completed successfully"
|
||||
);
|
||||
|
||||
return authResult;
|
||||
}
|
||||
|
||||
private async ensureNoExistingAccounts(email: string): Promise<void> {
|
||||
const [portalUser, whmcsClient] = await Promise.all([
|
||||
this.usersService.findByEmailInternal(email),
|
||||
this.whmcsDiscovery.findClientByEmail(email),
|
||||
]);
|
||||
|
||||
if (whmcsClient) {
|
||||
throw new ConflictException(
|
||||
"A billing account already exists. Please use the account migration flow."
|
||||
);
|
||||
}
|
||||
if (portalUser) {
|
||||
throw new ConflictException("An account already exists. Please log in.");
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAddress(
|
||||
requestAddress: CompleteAccountRequest["address"] | undefined,
|
||||
sessionAddress: Record<string, string | undefined> | undefined
|
||||
): NonNullable<CompleteAccountRequest["address"]> {
|
||||
const address = requestAddress ?? sessionAddress;
|
||||
|
||||
if (!address || !address.address1 || !address.city || !address.state || !address.postcode) {
|
||||
throw new BadRequestException(
|
||||
"Address information is incomplete. Please ensure all required fields (address, city, prefecture, postcode) are provided."
|
||||
);
|
||||
}
|
||||
|
||||
return address as NonNullable<CompleteAccountRequest["address"]>;
|
||||
}
|
||||
|
||||
private resolveNames(
|
||||
firstName: string | undefined,
|
||||
lastName: string | undefined,
|
||||
session: { firstName?: string | undefined; lastName?: string | undefined }
|
||||
): { finalFirstName: string; finalLastName: string } {
|
||||
const finalFirstName = firstName ?? session.firstName;
|
||||
const finalLastName = lastName ?? session.lastName;
|
||||
|
||||
if (!finalFirstName || !finalLastName) {
|
||||
throw new BadRequestException("Name information is missing. Please provide your name.");
|
||||
}
|
||||
|
||||
return { finalFirstName, finalLastName };
|
||||
}
|
||||
}
|
||||
@ -1,251 +0,0 @@
|
||||
import { ConflictException, Inject, Injectable } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as argon2 from "argon2";
|
||||
import type { Request } from "express";
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
|
||||
import { AuthTokenService } from "../token/token.service.js";
|
||||
import { AuthRateLimitService } from "../rate-limiting/auth-rate-limit.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import {
|
||||
signupRequestSchema,
|
||||
type SignupRequest,
|
||||
type ValidateSignupRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||
import {
|
||||
PORTAL_SOURCE_NEW_SIGNUP,
|
||||
PORTAL_STATUS_ACTIVE,
|
||||
type PortalRegistrationSource,
|
||||
} from "@bff/modules/auth/constants/portal.constants.js";
|
||||
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
|
||||
import {
|
||||
SignupAccountResolverService,
|
||||
SignupValidationService,
|
||||
SignupWhmcsService,
|
||||
SignupUserCreationService,
|
||||
} from "./signup/index.js";
|
||||
|
||||
/**
|
||||
* Signup Workflow Service
|
||||
*
|
||||
* Orchestrates the signup process by coordinating:
|
||||
* - SignupValidationService: Validates customer numbers and preflight checks
|
||||
* - SignupAccountResolverService: Resolves or creates Salesforce accounts and contacts
|
||||
* - SignupWhmcsService: Creates WHMCS clients
|
||||
* - SignupUserCreationService: Creates portal users with ID mappings
|
||||
*/
|
||||
@Injectable()
|
||||
export class SignupWorkflowService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly salesforceService: SalesforceFacade,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly tokenService: AuthTokenService,
|
||||
private readonly authRateLimitService: AuthRateLimitService,
|
||||
private readonly accountResolver: SignupAccountResolverService,
|
||||
private readonly signupValidation: SignupValidationService,
|
||||
private readonly whmcsSignup: SignupWhmcsService,
|
||||
private readonly userCreation: SignupUserCreationService,
|
||||
private readonly lockService: DistributedLockService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate customer number for signup
|
||||
*/
|
||||
async validateSignup(validateData: ValidateSignupRequest, request?: Request) {
|
||||
return this.signupValidation.validateCustomerNumber(validateData, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the full signup workflow
|
||||
*/
|
||||
async signup(signupData: SignupRequest, request?: Request): Promise<AuthResultInternal> {
|
||||
if (request) {
|
||||
await this.authRateLimitService.consumeSignupAttempt(request);
|
||||
}
|
||||
|
||||
// Validate signup data using schema
|
||||
signupRequestSchema.parse(signupData);
|
||||
|
||||
const {
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
phone,
|
||||
address,
|
||||
nationality,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
} = signupData;
|
||||
|
||||
// Check for existing portal user
|
||||
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);
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
const lockKey = `signup:${email.toLowerCase().trim()}`;
|
||||
return await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
try {
|
||||
// Step 1: Check for existing WHMCS client before provisioning in Salesforce
|
||||
await this.whmcsSignup.checkExistingClient(email.toLowerCase().trim());
|
||||
|
||||
// Step 2: Validate WHMCS data requirements
|
||||
this.whmcsSignup.validateAddressData(signupData);
|
||||
|
||||
// Step 3: Resolve or create Salesforce account
|
||||
const { snapshot: accountSnapshot, customerNumber: customerNumberForWhmcs } =
|
||||
await this.accountResolver.resolveOrCreate(signupData);
|
||||
|
||||
const normalizedCustomerNumber = this.accountResolver.normalizeCustomerNumber(
|
||||
signupData.sfNumber
|
||||
);
|
||||
if (normalizedCustomerNumber) {
|
||||
const existingMapping = await this.mappingsService.findBySfAccountId(
|
||||
accountSnapshot.id
|
||||
);
|
||||
if (existingMapping) {
|
||||
throw new ConflictException(
|
||||
"You already have an account. Please use the login page to access your existing account."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Create WHMCS client
|
||||
// Address fields are validated by validateAddressData, safe to assert non-null
|
||||
const whmcsClient = await this.whmcsSignup.createClient({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
password,
|
||||
...(company ? { company } : {}),
|
||||
phone: phone,
|
||||
address: {
|
||||
address1: address!.address1!,
|
||||
...(address?.address2 ? { address2: address.address2 } : {}),
|
||||
city: address!.city!,
|
||||
state: address!.state!,
|
||||
postcode: address!.postcode!,
|
||||
country: address!.country!,
|
||||
},
|
||||
customerNumber: customerNumberForWhmcs,
|
||||
...(dateOfBirth ? { dateOfBirth } : {}),
|
||||
...(gender ? { gender } : {}),
|
||||
...(nationality ? { nationality } : {}),
|
||||
});
|
||||
|
||||
// Step 5: Create user and mapping in database
|
||||
const { userId } = await this.userCreation.createUserWithMapping({
|
||||
email,
|
||||
passwordHash,
|
||||
whmcsClientId: whmcsClient.clientId,
|
||||
sfAccountId: accountSnapshot.id,
|
||||
});
|
||||
|
||||
// Step 6: Fetch fresh user and generate tokens
|
||||
const freshUser = await this.usersService.findByIdInternal(userId);
|
||||
if (!freshUser) {
|
||||
throw new Error("Failed to load created user");
|
||||
}
|
||||
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
userId,
|
||||
{ email, whmcsClientId: whmcsClient.clientId },
|
||||
request,
|
||||
true
|
||||
);
|
||||
|
||||
const profile = mapPrismaUserToDomain(freshUser);
|
||||
const tokens = await this.tokenService.generateTokenPair({
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
});
|
||||
|
||||
// Step 7: Update Salesforce portal flags (non-blocking)
|
||||
await this.updateSalesforcePortalFlags(
|
||||
accountSnapshot.id,
|
||||
PORTAL_SOURCE_NEW_SIGNUP,
|
||||
whmcsClient.clientId
|
||||
);
|
||||
|
||||
return {
|
||||
user: profile,
|
||||
tokens,
|
||||
};
|
||||
} catch (error) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ email, error: extractErrorMessage(error) },
|
||||
request,
|
||||
false,
|
||||
extractErrorMessage(error)
|
||||
);
|
||||
|
||||
this.logger.error("Signup error", { error: extractErrorMessage(error) });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ ttlMs: 60_000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight check before signup - delegates to SignupValidationService
|
||||
*/
|
||||
async signupPreflight(signupData: SignupRequest) {
|
||||
const { email, sfNumber } = signupData;
|
||||
const preflightResult = await this.signupValidation.preflightCheck(email, sfNumber);
|
||||
|
||||
// Return with additional 'ok' field for API compatibility
|
||||
return {
|
||||
ok: true,
|
||||
...preflightResult,
|
||||
};
|
||||
}
|
||||
|
||||
private async updateSalesforcePortalFlags(
|
||||
accountId: string,
|
||||
source: PortalRegistrationSource,
|
||||
whmcsAccountId?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.salesforceService.updateAccountPortalFields(accountId, {
|
||||
status: PORTAL_STATUS_ACTIVE,
|
||||
source,
|
||||
lastSignedInAt: new Date(),
|
||||
...(whmcsAccountId === undefined ? {} : { whmcsAccountId }),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to update Salesforce portal flags after signup", {
|
||||
accountId,
|
||||
error: extractErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
|
||||
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
|
||||
|
||||
export interface CreateEligibilityCaseAddress {
|
||||
address1: string;
|
||||
address2?: string;
|
||||
city: string;
|
||||
state?: string;
|
||||
postcode: string;
|
||||
country?: string;
|
||||
/** Japanese full street address — used for duplicate detection */
|
||||
streetAddress?: string;
|
||||
}
|
||||
|
||||
export interface CreateEligibilityCaseParams {
|
||||
sfAccountId: string;
|
||||
address: CreateEligibilityCaseAddress;
|
||||
}
|
||||
|
||||
export interface CreateEligibilityCaseResult {
|
||||
caseId: string;
|
||||
caseNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step: Create a Salesforce eligibility case.
|
||||
*
|
||||
* Finds or creates an Opportunity for internet eligibility,
|
||||
* then creates a notification case via WorkflowCaseManager.
|
||||
*
|
||||
* No rollback — cases are idempotent.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CreateEligibilityCaseStep {
|
||||
constructor(
|
||||
private readonly workflowCaseManager: WorkflowCaseManager,
|
||||
private readonly opportunityResolution: OpportunityResolutionService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async execute(params: CreateEligibilityCaseParams): Promise<CreateEligibilityCaseResult> {
|
||||
const { sfAccountId, address } = params;
|
||||
|
||||
// Find or create Opportunity for Internet eligibility
|
||||
const { opportunityId, wasCreated: opportunityCreated } =
|
||||
await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId);
|
||||
|
||||
// Create eligibility case via workflow manager
|
||||
await this.workflowCaseManager.notifyEligibilityCheck({
|
||||
accountId: sfAccountId,
|
||||
opportunityId,
|
||||
opportunityCreated,
|
||||
address: {
|
||||
address1: address.address1,
|
||||
...(address.address2 ? { address2: address.address2 } : {}),
|
||||
city: address.city,
|
||||
state: address.state ?? "",
|
||||
postcode: address.postcode,
|
||||
...(address.country ? { country: address.country } : {}),
|
||||
},
|
||||
...(address.streetAddress ? { streetAddress: address.streetAddress } : {}),
|
||||
});
|
||||
|
||||
// Generate a reference ID for the eligibility request
|
||||
const caseId = `eligibility:${sfAccountId}:${Date.now()}`;
|
||||
const caseNumber = `ELG-${Date.now().toString(36).toUpperCase()}`;
|
||||
|
||||
this.logger.log({ sfAccountId, caseId, opportunityId }, "Eligibility case created");
|
||||
|
||||
return { caseId, caseNumber };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SignupUserCreationService } from "../signup/signup-user-creation.service.js";
|
||||
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
|
||||
export interface CreatePortalUserParams {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
sfAccountId: string;
|
||||
whmcsClientId: number;
|
||||
}
|
||||
|
||||
export interface CreatePortalUserResult {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step: Create a portal user with ID mapping.
|
||||
*
|
||||
* Delegates to SignupUserCreationService for the Prisma transaction
|
||||
* that creates both the User row and the IdMapping row atomically.
|
||||
*
|
||||
* Rollback deletes the user and associated ID mapping.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CreatePortalUserStep {
|
||||
constructor(
|
||||
private readonly signupUserCreation: SignupUserCreationService,
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async execute(params: CreatePortalUserParams): Promise<CreatePortalUserResult> {
|
||||
const { userId } = await this.signupUserCreation.createUserWithMapping({
|
||||
email: params.email,
|
||||
passwordHash: params.passwordHash,
|
||||
whmcsClientId: params.whmcsClientId,
|
||||
sfAccountId: params.sfAccountId,
|
||||
});
|
||||
|
||||
this.logger.log({ userId, email: params.email }, "Portal user created with ID mapping");
|
||||
|
||||
return { userId };
|
||||
}
|
||||
|
||||
async rollback(userId: string): Promise<void> {
|
||||
this.logger.warn({ userId }, "Rolling back portal user creation");
|
||||
try {
|
||||
await this.prisma.$transaction(async tx => {
|
||||
await tx.idMapping.deleteMany({ where: { userId } });
|
||||
await tx.user.delete({ where: { id: userId } });
|
||||
});
|
||||
this.logger.log({ userId }, "Portal user and mapping deleted in rollback");
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ userId, error: extractErrorMessage(error) },
|
||||
"Failed to rollback portal user — manual cleanup required"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SignupWhmcsService } from "../signup/signup-whmcs.service.js";
|
||||
import type { WhmcsCreatedClient } from "../signup/signup-whmcs.service.js";
|
||||
|
||||
export interface CreateWhmcsClientParams {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
address: {
|
||||
address1: string;
|
||||
address2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
country: string;
|
||||
};
|
||||
customerNumber: string | null;
|
||||
company?: string;
|
||||
dateOfBirth?: string;
|
||||
gender?: string;
|
||||
nationality?: string;
|
||||
}
|
||||
|
||||
export interface CreateWhmcsClientResult {
|
||||
whmcsClientId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step: Create a WHMCS billing client.
|
||||
*
|
||||
* Delegates to SignupWhmcsService for the actual API call.
|
||||
* Rollback marks the created client as inactive for manual cleanup.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CreateWhmcsClientStep {
|
||||
constructor(
|
||||
private readonly signupWhmcs: SignupWhmcsService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async execute(params: CreateWhmcsClientParams): Promise<CreateWhmcsClientResult> {
|
||||
const result: WhmcsCreatedClient = await this.signupWhmcs.createClient({
|
||||
firstName: params.firstName,
|
||||
lastName: params.lastName,
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
phone: params.phone,
|
||||
address: params.address,
|
||||
customerNumber: params.customerNumber,
|
||||
...(params.company != null && { company: params.company }),
|
||||
...(params.dateOfBirth != null && { dateOfBirth: params.dateOfBirth }),
|
||||
...(params.gender != null && { gender: params.gender }),
|
||||
...(params.nationality != null && { nationality: params.nationality }),
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
{ email: params.email, whmcsClientId: result.clientId },
|
||||
"WHMCS client created"
|
||||
);
|
||||
|
||||
return { whmcsClientId: result.clientId };
|
||||
}
|
||||
|
||||
async rollback(whmcsClientId: number, email?: string): Promise<void> {
|
||||
this.logger.warn(
|
||||
{ whmcsClientId, email },
|
||||
"Rolling back WHMCS client creation — marking for cleanup"
|
||||
);
|
||||
await this.signupWhmcs.markClientForCleanup(whmcsClientId, email ?? "unknown");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
||||
import { AuthTokenService } from "../../token/token.service.js";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
|
||||
|
||||
export interface GenerateAuthResultParams {
|
||||
userId: string;
|
||||
email: string;
|
||||
auditSource: string;
|
||||
auditDetails?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step: Generate authentication result (tokens + profile).
|
||||
*
|
||||
* Loads the freshly created user, logs an audit event,
|
||||
* and generates a JWT access/refresh token pair.
|
||||
*
|
||||
* No rollback — stateless token generation.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GenerateAuthResultStep {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly authTokenService: AuthTokenService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async execute(params: GenerateAuthResultParams): Promise<AuthResultInternal> {
|
||||
const { userId, email, auditSource, auditDetails } = params;
|
||||
|
||||
// Load fresh user from DB
|
||||
const freshUser = await this.usersService.findByIdInternal(userId);
|
||||
if (!freshUser) {
|
||||
throw new Error("Failed to load created user");
|
||||
}
|
||||
|
||||
// Log audit event
|
||||
await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, {
|
||||
email,
|
||||
source: auditSource,
|
||||
...auditDetails,
|
||||
});
|
||||
|
||||
// Generate token pair
|
||||
const profile = mapPrismaUserToDomain(freshUser);
|
||||
const tokens = await this.authTokenService.generateTokenPair({
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
});
|
||||
|
||||
this.logger.log({ userId, email }, "Auth result generated");
|
||||
|
||||
return { user: profile, tokens };
|
||||
}
|
||||
}
|
||||
27
apps/bff/src/modules/auth/infra/workflows/steps/index.ts
Normal file
27
apps/bff/src/modules/auth/infra/workflows/steps/index.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export { ResolveSalesforceAccountStep } from "./resolve-salesforce-account.step.js";
|
||||
export type {
|
||||
ResolveSalesforceAccountParams,
|
||||
ResolveSalesforceAccountResult,
|
||||
} from "./resolve-salesforce-account.step.js";
|
||||
|
||||
export { CreateWhmcsClientStep } from "./create-whmcs-client.step.js";
|
||||
export type {
|
||||
CreateWhmcsClientParams,
|
||||
CreateWhmcsClientResult,
|
||||
} from "./create-whmcs-client.step.js";
|
||||
|
||||
export { CreatePortalUserStep } from "./create-portal-user.step.js";
|
||||
export type { CreatePortalUserParams, CreatePortalUserResult } from "./create-portal-user.step.js";
|
||||
|
||||
export { UpdateSalesforceFlagsStep } from "./update-salesforce-flags.step.js";
|
||||
export type { UpdateSalesforceFlagsParams } from "./update-salesforce-flags.step.js";
|
||||
|
||||
export { GenerateAuthResultStep } from "./generate-auth-result.step.js";
|
||||
export type { GenerateAuthResultParams } from "./generate-auth-result.step.js";
|
||||
|
||||
export { CreateEligibilityCaseStep } from "./create-eligibility-case.step.js";
|
||||
export type {
|
||||
CreateEligibilityCaseParams,
|
||||
CreateEligibilityCaseAddress,
|
||||
CreateEligibilityCaseResult,
|
||||
} from "./create-eligibility-case.step.js";
|
||||
@ -0,0 +1,81 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
|
||||
|
||||
export interface ResolveSalesforceAccountParams {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
customerNumber?: string;
|
||||
source: string;
|
||||
existingAccountId?: string;
|
||||
updateSourceIfExists?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveSalesforceAccountResult {
|
||||
sfAccountId: string;
|
||||
customerNumber: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step: Resolve or create a Salesforce account.
|
||||
*
|
||||
* Finds an existing SF account by email (or verifies a known ID),
|
||||
* or creates a new one when no match is found.
|
||||
*
|
||||
* No rollback — SF accounts are reusable across retries.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ResolveSalesforceAccountStep {
|
||||
constructor(
|
||||
private readonly salesforceAccountService: SalesforceAccountService,
|
||||
private readonly salesforceFacade: SalesforceFacade,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async execute(params: ResolveSalesforceAccountParams): Promise<ResolveSalesforceAccountResult> {
|
||||
const { email, firstName, lastName, phone, source, existingAccountId, updateSourceIfExists } =
|
||||
params;
|
||||
|
||||
// If an existing SF account ID is provided, verify it still exists
|
||||
if (existingAccountId) {
|
||||
const existing = await this.salesforceAccountService.findByEmail(email);
|
||||
if (!existing || existing.id !== existingAccountId) {
|
||||
throw new BadRequestException("Account verification failed. Please start over.");
|
||||
}
|
||||
return { sfAccountId: existing.id, customerNumber: existing.accountNumber };
|
||||
}
|
||||
|
||||
// Check if SF account exists by email
|
||||
const existingSf = await this.salesforceAccountService.findByEmail(email);
|
||||
if (existingSf) {
|
||||
this.logger.log({ email, sfAccountId: existingSf.id }, "Using existing SF account");
|
||||
|
||||
if (updateSourceIfExists) {
|
||||
await this.salesforceFacade.updateAccountPortalFields(existingSf.id, {
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
return { sfAccountId: existingSf.id, customerNumber: existingSf.accountNumber };
|
||||
}
|
||||
|
||||
// Create new SF Account
|
||||
if (!firstName || !lastName) {
|
||||
throw new BadRequestException("Name is required to create an account.");
|
||||
}
|
||||
|
||||
const { accountId, accountNumber } = await this.salesforceAccountService.createAccount({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone: phone ?? "",
|
||||
portalSource: source,
|
||||
});
|
||||
|
||||
this.logger.log({ email, sfAccountId: accountId }, "Created new SF account");
|
||||
return { sfAccountId: accountId, customerNumber: accountNumber };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import {
|
||||
PORTAL_STATUS_ACTIVE,
|
||||
type PortalRegistrationSource,
|
||||
} from "@bff/modules/auth/constants/portal.constants.js";
|
||||
|
||||
export interface UpdateSalesforceFlagsParams {
|
||||
sfAccountId: string;
|
||||
whmcsClientId?: number;
|
||||
source?: PortalRegistrationSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step: Update Salesforce portal status flags after account creation.
|
||||
*
|
||||
* Sets the account to "Active" and optionally updates the registration source
|
||||
* and WHMCS account link.
|
||||
*
|
||||
* No rollback — this is retry-safe and idempotent.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UpdateSalesforceFlagsStep {
|
||||
constructor(
|
||||
private readonly salesforceFacade: SalesforceFacade,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async execute(params: UpdateSalesforceFlagsParams): Promise<void> {
|
||||
const { sfAccountId, whmcsClientId, source } = params;
|
||||
|
||||
try {
|
||||
await this.salesforceFacade.updateAccountPortalFields(sfAccountId, {
|
||||
status: PORTAL_STATUS_ACTIVE,
|
||||
...(source && { source }),
|
||||
lastSignedInAt: new Date(),
|
||||
...(whmcsClientId !== undefined && { whmcsAccountId: whmcsClientId }),
|
||||
});
|
||||
|
||||
this.logger.log({ sfAccountId, source, whmcsClientId }, "Salesforce portal flags updated");
|
||||
} catch (error) {
|
||||
// Non-critical: log warning but do not throw.
|
||||
// The account is already created; SF flags can be fixed later.
|
||||
this.logger.warn(
|
||||
{ sfAccountId, error: extractErrorMessage(error) },
|
||||
"Failed to update Salesforce portal flags"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,277 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
import {
|
||||
ACCOUNT_STATUS,
|
||||
type AccountStatus,
|
||||
type SendVerificationCodeRequest,
|
||||
type SendVerificationCodeResponse,
|
||||
type VerifyCodeRequest,
|
||||
type VerifyCodeResponse,
|
||||
} from "@customer-portal/domain/get-started";
|
||||
|
||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
|
||||
import { OtpService } from "../otp/otp.service.js";
|
||||
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
|
||||
|
||||
/**
|
||||
* Verification Workflow Service
|
||||
*
|
||||
* Handles OTP send/verify and account status detection.
|
||||
* No distributed transaction needed — simple request-response operations.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VerificationWorkflowService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly otpService: OtpService,
|
||||
private readonly sessionService: GetStartedSessionService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly salesforceAccountService: SalesforceAccountService,
|
||||
private readonly whmcsDiscovery: WhmcsAccountDiscoveryService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send OTP verification code to email
|
||||
*/
|
||||
async sendCode(
|
||||
request: SendVerificationCodeRequest,
|
||||
fingerprint?: string
|
||||
): Promise<SendVerificationCodeResponse> {
|
||||
const { email } = request;
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
try {
|
||||
// Generate OTP and store in Redis (with fingerprint for binding)
|
||||
const code = await this.otpService.generateAndStore(normalizedEmail, fingerprint);
|
||||
|
||||
// Create session for this verification flow (token returned in verifyCode)
|
||||
await this.sessionService.create(normalizedEmail);
|
||||
|
||||
// Send email with OTP code
|
||||
await this.sendOtpEmail(normalizedEmail, code);
|
||||
|
||||
this.logger.log({ email: normalizedEmail }, "OTP verification code sent");
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
message: "Verification code sent to your email",
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ error: extractErrorMessage(error), email: normalizedEmail },
|
||||
"Failed to send verification code"
|
||||
);
|
||||
|
||||
return {
|
||||
sent: false,
|
||||
message: "Failed to send verification code. Please try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify OTP code and determine account status
|
||||
*/
|
||||
async verifyCode(request: VerifyCodeRequest, fingerprint?: string): Promise<VerifyCodeResponse> {
|
||||
const { email, code, handoffToken } = request;
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
// Verify OTP (with fingerprint check - logs warning if different context)
|
||||
const otpResult = await this.otpService.verify(normalizedEmail, code, fingerprint);
|
||||
|
||||
if (!otpResult.valid) {
|
||||
return this.buildOtpErrorResponse(otpResult);
|
||||
}
|
||||
|
||||
// Create verified session
|
||||
const sessionToken = await this.sessionService.create(normalizedEmail);
|
||||
|
||||
// Check account status across all systems
|
||||
const accountStatus = await this.determineAccountStatus(normalizedEmail);
|
||||
|
||||
// Get prefill data (including handoff token data if provided)
|
||||
const prefill = await this.resolvePrefillData(normalizedEmail, accountStatus, handoffToken);
|
||||
|
||||
// Update session with verified status and account info
|
||||
const prefillData = this.buildSessionPrefillData(prefill, accountStatus);
|
||||
await this.sessionService.markEmailVerified(
|
||||
sessionToken,
|
||||
accountStatus.status,
|
||||
Object.keys(prefillData).length > 0 ? prefillData : undefined
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
{ email: normalizedEmail, accountStatus: accountStatus.status, hasHandoff: !!handoffToken },
|
||||
"Email verified and account status determined"
|
||||
);
|
||||
|
||||
return {
|
||||
verified: true,
|
||||
sessionToken,
|
||||
accountStatus: accountStatus.status,
|
||||
prefill,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async sendOtpEmail(email: string, code: string): Promise<void> {
|
||||
const templateId = this.config.get<string>("EMAIL_TEMPLATE_OTP_VERIFICATION");
|
||||
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Your verification code",
|
||||
templateId,
|
||||
dynamicTemplateData: {
|
||||
code,
|
||||
expiresMinutes: "10",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "Your verification code",
|
||||
html: `
|
||||
<p>Your verification code is: <strong>${code}</strong></p>
|
||||
<p>This code expires in 10 minutes.</p>
|
||||
<p>If you didn't request this code, please ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private buildOtpErrorResponse(otpResult: {
|
||||
reason?: "expired" | "invalid" | "max_attempts";
|
||||
attemptsRemaining?: number;
|
||||
}): VerifyCodeResponse {
|
||||
let error: string;
|
||||
switch (otpResult.reason) {
|
||||
case "expired":
|
||||
error = "Code expired. Please request a new one.";
|
||||
break;
|
||||
case "max_attempts":
|
||||
error = "Too many failed attempts. Please request a new code.";
|
||||
break;
|
||||
default:
|
||||
error = "Invalid code. Please try again.";
|
||||
}
|
||||
|
||||
return {
|
||||
verified: false,
|
||||
error,
|
||||
attemptsRemaining: otpResult.attemptsRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
private async determineAccountStatus(
|
||||
email: string
|
||||
): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> {
|
||||
// Check Portal user first
|
||||
const portalUser = await this.usersService.findByEmailInternal(email);
|
||||
if (portalUser) {
|
||||
const hasMapping = await this.mappingsService.hasMapping(portalUser.id);
|
||||
if (hasMapping) {
|
||||
return { status: ACCOUNT_STATUS.PORTAL_EXISTS };
|
||||
}
|
||||
}
|
||||
|
||||
// Check WHMCS client or user (sub-account)
|
||||
const whmcsClient = await this.whmcsDiscovery.findAccountByEmail(email);
|
||||
if (whmcsClient) {
|
||||
const mapping = await this.mappingsService.findByWhmcsClientId(whmcsClient.id);
|
||||
if (mapping) {
|
||||
return { status: ACCOUNT_STATUS.PORTAL_EXISTS };
|
||||
}
|
||||
return { status: ACCOUNT_STATUS.WHMCS_UNMAPPED, whmcsClientId: whmcsClient.id };
|
||||
}
|
||||
|
||||
// Check Salesforce account
|
||||
const sfAccount = await this.salesforceAccountService.findByEmail(email);
|
||||
if (sfAccount) {
|
||||
const mapping = await this.mappingsService.findBySfAccountId(sfAccount.id);
|
||||
if (mapping) {
|
||||
return { status: ACCOUNT_STATUS.PORTAL_EXISTS };
|
||||
}
|
||||
return { status: ACCOUNT_STATUS.SF_UNMAPPED, sfAccountId: sfAccount.id };
|
||||
}
|
||||
|
||||
return { status: ACCOUNT_STATUS.NEW_CUSTOMER };
|
||||
}
|
||||
|
||||
private getPrefillData(
|
||||
email: string,
|
||||
accountStatus: { status: AccountStatus; sfAccountId?: string }
|
||||
): VerifyCodeResponse["prefill"] {
|
||||
if (accountStatus.status === ACCOUNT_STATUS.SF_UNMAPPED && accountStatus.sfAccountId) {
|
||||
return { email };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async resolvePrefillData(
|
||||
email: string,
|
||||
accountStatus: { status: AccountStatus; sfAccountId?: string; whmcsClientId?: number },
|
||||
handoffToken?: string
|
||||
): Promise<VerifyCodeResponse["prefill"]> {
|
||||
let prefill = this.getPrefillData(email, accountStatus);
|
||||
|
||||
if (!handoffToken) {
|
||||
return prefill;
|
||||
}
|
||||
|
||||
const handoffData = await this.sessionService.getGuestHandoffToken(handoffToken, email);
|
||||
|
||||
if (!handoffData) {
|
||||
return prefill;
|
||||
}
|
||||
|
||||
this.logger.debug({ email, handoffToken }, "Applying handoff token data to session");
|
||||
|
||||
prefill = {
|
||||
...prefill,
|
||||
firstName: handoffData.firstName,
|
||||
lastName: handoffData.lastName,
|
||||
phone: handoffData.phone ?? prefill?.phone,
|
||||
address: handoffData.address,
|
||||
};
|
||||
|
||||
if (!accountStatus.sfAccountId && handoffData.sfAccountId) {
|
||||
accountStatus.sfAccountId = handoffData.sfAccountId;
|
||||
}
|
||||
|
||||
await this.sessionService.invalidateHandoffToken(handoffToken);
|
||||
|
||||
return prefill;
|
||||
}
|
||||
|
||||
private buildSessionPrefillData(
|
||||
prefill: VerifyCodeResponse["prefill"],
|
||||
accountStatus: { status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...(prefill?.firstName && { firstName: prefill.firstName }),
|
||||
...(prefill?.lastName && { lastName: prefill.lastName }),
|
||||
...(prefill?.phone && { phone: prefill.phone }),
|
||||
...(prefill?.address && { address: prefill.address }),
|
||||
...(accountStatus.sfAccountId && { sfAccountId: accountStatus.sfAccountId }),
|
||||
...(accountStatus.whmcsClientId !== undefined && {
|
||||
whmcsClientId: accountStatus.whmcsClientId,
|
||||
}),
|
||||
...(prefill?.eligibilityStatus && { eligibilityStatus: prefill.eligibilityStatus }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,241 @@
|
||||
import { BadRequestException, ConflictException, Inject, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
import { type MigrateWhmcsAccountRequest } from "@customer-portal/domain/get-started";
|
||||
import {
|
||||
getCustomFieldValue,
|
||||
serializeWhmcsKeyValueMap,
|
||||
} from "@customer-portal/domain/customer/providers";
|
||||
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
|
||||
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
|
||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { PORTAL_SOURCE_MIGRATED } from "@bff/modules/auth/constants/portal.constants.js";
|
||||
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
|
||||
|
||||
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
|
||||
import { SignupUserCreationService } from "./signup/signup-user-creation.service.js";
|
||||
import { UpdateSalesforceFlagsStep, GenerateAuthResultStep } from "./steps/index.js";
|
||||
|
||||
/**
|
||||
* WHMCS Migration Workflow Service
|
||||
*
|
||||
* Handles WHMCS_UNMAPPED path — passwordless migration for users
|
||||
* who already have a WHMCS account. Email verification serves as
|
||||
* identity proof. Creates Portal user + SF mapping.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WhmcsMigrationWorkflowService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly sessionService: GetStartedSessionService,
|
||||
private readonly lockService: DistributedLockService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly salesforceAccountService: SalesforceAccountService,
|
||||
private readonly salesforceFacade: SalesforceFacade,
|
||||
private readonly whmcsDiscovery: WhmcsAccountDiscoveryService,
|
||||
private readonly whmcsClientService: WhmcsClientService,
|
||||
private readonly userCreation: SignupUserCreationService,
|
||||
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
|
||||
private readonly authResultStep: GenerateAuthResultStep,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Migrate WHMCS account to portal without legacy password validation.
|
||||
*
|
||||
* Flow: validate session → verify WHMCS client → find SF account →
|
||||
* update WHMCS password → create portal user → update SF flags → auth tokens
|
||||
*/
|
||||
async execute(request: MigrateWhmcsAccountRequest): Promise<AuthResultInternal> {
|
||||
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
|
||||
request.sessionToken,
|
||||
"migrate_whmcs_account"
|
||||
);
|
||||
|
||||
if (!sessionResult.success) {
|
||||
throw new BadRequestException(sessionResult.reason);
|
||||
}
|
||||
|
||||
const session = sessionResult.session;
|
||||
|
||||
if (!session.whmcsClientId) {
|
||||
throw new BadRequestException(
|
||||
"No WHMCS account found in session. Please verify your email again."
|
||||
);
|
||||
}
|
||||
|
||||
const { email, whmcsClientId } = session as { email: string; whmcsClientId: number };
|
||||
const { password, dateOfBirth, gender } = request;
|
||||
const lockKey = `migrate-whmcs:${email}`;
|
||||
|
||||
try {
|
||||
return await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () =>
|
||||
this.executeMigration(
|
||||
{ email, whmcsClientId },
|
||||
request.sessionToken,
|
||||
password,
|
||||
dateOfBirth,
|
||||
gender
|
||||
),
|
||||
{ ttlMs: 60_000 }
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error({ error: extractErrorMessage(error), email }, "WHMCS migration failed");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeMigration(
|
||||
session: { email: string; whmcsClientId: number },
|
||||
sessionToken: string,
|
||||
password: string,
|
||||
dateOfBirth?: string,
|
||||
gender?: string
|
||||
): Promise<AuthResultInternal> {
|
||||
const { email, whmcsClientId } = session;
|
||||
|
||||
// Verify WHMCS client still exists and matches session
|
||||
const whmcsClient = await this.whmcsDiscovery.findAccountByEmail(email);
|
||||
if (!whmcsClient || whmcsClient.id !== whmcsClientId) {
|
||||
throw new BadRequestException("WHMCS account verification failed. Please start over.");
|
||||
}
|
||||
|
||||
// Check for existing portal user
|
||||
const existingPortalUser = await this.usersService.findByEmailInternal(email);
|
||||
if (existingPortalUser) {
|
||||
throw new ConflictException("An account already exists. Please log in.");
|
||||
}
|
||||
|
||||
// Find Salesforce account for mapping
|
||||
const sfAccount = await this.findSalesforceAccountForMigration(email, whmcsClientId);
|
||||
if (!sfAccount) {
|
||||
throw new BadRequestException(
|
||||
"Unable to find your Salesforce account. Please contact support."
|
||||
);
|
||||
}
|
||||
|
||||
// Hash password for portal storage
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
// Update WHMCS client with new password (DOB/gender only if provided)
|
||||
await this.updateWhmcsClientForMigration(whmcsClientId, password, dateOfBirth, gender);
|
||||
|
||||
// Create portal user and ID mapping
|
||||
const { userId } = await this.userCreation.createUserWithMapping({
|
||||
email,
|
||||
passwordHash,
|
||||
whmcsClientId,
|
||||
sfAccountId: sfAccount.id,
|
||||
});
|
||||
|
||||
// Update Salesforce portal flags (DEGRADABLE)
|
||||
try {
|
||||
await this.sfFlagsStep.execute({
|
||||
sfAccountId: sfAccount.id,
|
||||
whmcsClientId,
|
||||
source: PORTAL_SOURCE_MIGRATED,
|
||||
});
|
||||
} catch (flagsError) {
|
||||
this.logger.warn(
|
||||
{ error: extractErrorMessage(flagsError), email },
|
||||
"SF flags update failed (non-critical, continuing)"
|
||||
);
|
||||
}
|
||||
|
||||
// Generate auth result
|
||||
const authResult = await this.authResultStep.execute({
|
||||
userId,
|
||||
email,
|
||||
auditSource: "whmcs_migration",
|
||||
auditDetails: { whmcsClientId },
|
||||
});
|
||||
|
||||
// Invalidate session
|
||||
await this.sessionService.invalidate(sessionToken);
|
||||
|
||||
this.logger.log({ email, userId, whmcsClientId }, "WHMCS account migrated successfully");
|
||||
|
||||
return authResult;
|
||||
}
|
||||
|
||||
private async findSalesforceAccountForMigration(
|
||||
email: string,
|
||||
whmcsClientId: number
|
||||
): Promise<{ id: string } | null> {
|
||||
try {
|
||||
// First try by email
|
||||
const sfAccount = await this.salesforceAccountService.findByEmail(email);
|
||||
if (sfAccount) {
|
||||
return { id: sfAccount.id };
|
||||
}
|
||||
|
||||
// Try by customer number from WHMCS
|
||||
const whmcsClient = await this.whmcsClientService.getClientDetails(whmcsClientId);
|
||||
|
||||
const customerNumber =
|
||||
getCustomFieldValue(whmcsClient.customfields, "198")?.trim() ??
|
||||
getCustomFieldValue(whmcsClient.customfields, "Customer Number")?.trim();
|
||||
|
||||
if (!customerNumber) {
|
||||
this.logger.warn(
|
||||
{ whmcsClientId, email },
|
||||
"No customer number found in WHMCS for SF lookup"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sfAccountByNumber =
|
||||
await this.salesforceFacade.findAccountByCustomerNumber(customerNumber);
|
||||
if (sfAccountByNumber) {
|
||||
return { id: sfAccountByNumber.id };
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
{ whmcsClientId, email, customerNumber },
|
||||
"No Salesforce account found for WHMCS migration"
|
||||
);
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ error: extractErrorMessage(error), email, whmcsClientId },
|
||||
"Failed to find Salesforce account for migration"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async updateWhmcsClientForMigration(
|
||||
clientId: number,
|
||||
password: string,
|
||||
dateOfBirth?: string,
|
||||
gender?: string
|
||||
): Promise<void> {
|
||||
const dobFieldId = this.config.get<string>("WHMCS_DOB_FIELD_ID");
|
||||
const genderFieldId = this.config.get<string>("WHMCS_GENDER_FIELD_ID");
|
||||
|
||||
const customfieldsMap: Record<string, string> = {};
|
||||
if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
|
||||
if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
password2: password,
|
||||
};
|
||||
|
||||
if (Object.keys(customfieldsMap).length > 0) {
|
||||
updateData["customfields"] = serializeWhmcsKeyValueMap(customfieldsMap);
|
||||
}
|
||||
|
||||
await this.whmcsClientService.updateClient(clientId, updateData);
|
||||
|
||||
this.logger.log({ clientId }, "Updated WHMCS client with new password and profile data");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import {
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
|
||||
export enum ErrorCategory {
|
||||
/** Network timeout, rate limit — "Try again in a moment" */
|
||||
RETRIABLE = "RETRIABLE",
|
||||
/** Email already registered, invalid data — specific message */
|
||||
PERMANENT = "PERMANENT",
|
||||
/** SF down, WHMCS unavailable — "We're having issues, try later" */
|
||||
TRANSIENT = "TRANSIENT",
|
||||
}
|
||||
|
||||
export interface ClassifiedError {
|
||||
errorCategory: ErrorCategory;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an error into a category for the frontend.
|
||||
*
|
||||
* - ConflictException / BadRequestException / ForbiddenException / UnauthorizedException → PERMANENT
|
||||
* - Timeout / ECONNREFUSED / rate-limit → RETRIABLE
|
||||
* - Everything else → TRANSIENT
|
||||
*/
|
||||
export function classifyError(error: unknown): ClassifiedError {
|
||||
// Permanent: business-rule violations the user can't retry past
|
||||
if (
|
||||
error instanceof ConflictException ||
|
||||
error instanceof BadRequestException ||
|
||||
error instanceof ForbiddenException ||
|
||||
error instanceof UnauthorizedException ||
|
||||
error instanceof NotFoundException
|
||||
) {
|
||||
return {
|
||||
errorCategory: ErrorCategory.PERMANENT,
|
||||
message: extractErrorMessage(error),
|
||||
};
|
||||
}
|
||||
|
||||
const msg = extractErrorMessage(error).toLowerCase();
|
||||
|
||||
// Retriable: transient network issues the user can try again
|
||||
if (
|
||||
msg.includes("timeout") ||
|
||||
msg.includes("econnrefused") ||
|
||||
msg.includes("econnreset") ||
|
||||
msg.includes("rate limit") ||
|
||||
msg.includes("too many requests") ||
|
||||
msg.includes("429")
|
||||
) {
|
||||
return {
|
||||
errorCategory: ErrorCategory.RETRIABLE,
|
||||
message: "A temporary error occurred. Please try again in a moment.",
|
||||
};
|
||||
}
|
||||
|
||||
// Default: infrastructure issues
|
||||
return {
|
||||
errorCategory: ErrorCategory.TRANSIENT,
|
||||
message: "We're experiencing technical difficulties. Please try again later.",
|
||||
};
|
||||
}
|
||||
@ -46,7 +46,6 @@ import { TrustedDeviceService } from "../../infra/trusted-device/trusted-device.
|
||||
|
||||
// Import Zod schemas from domain
|
||||
import {
|
||||
signupRequestSchema,
|
||||
passwordResetRequestSchema,
|
||||
passwordResetSchema,
|
||||
setPasswordRequestSchema,
|
||||
@ -69,7 +68,6 @@ type RequestWithCookies = Omit<Request, "cookies"> & {
|
||||
// Re-export for backward compatibility with tests
|
||||
export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE };
|
||||
|
||||
class SignupRequestDto extends createZodDto(signupRequestSchema) {}
|
||||
class AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {}
|
||||
class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {}
|
||||
class LinkWhmcsRequestDto extends createZodDto(linkWhmcsRequestSchema) {}
|
||||
@ -108,20 +106,6 @@ export class AuthController {
|
||||
return this.authOrchestrator.getAccountStatus(body.email);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("signup")
|
||||
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||||
@RateLimit({ limit: 5, ttl: 900 }) // 5 signups per 15 minutes per IP (reasonable for account creation)
|
||||
async signup(
|
||||
@Body() signupData: SignupRequestDto,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
const result = await this.authOrchestrator.signup(signupData, req);
|
||||
setAuthCookies(res, result.tokens);
|
||||
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/login - Initiate login with credentials
|
||||
*
|
||||
|
||||
@ -2,7 +2,11 @@ import { Controller, Post, Body, UseGuards, Res, Req, HttpCode } from "@nestjs/c
|
||||
import type { Request, Response } from "express";
|
||||
import { createZodDto } from "nestjs-zod";
|
||||
|
||||
import { RateLimitGuard, RateLimit, getRateLimitFingerprint } from "@bff/core/rate-limiting/index.js";
|
||||
import {
|
||||
RateLimitGuard,
|
||||
RateLimit,
|
||||
getRateLimitFingerprint,
|
||||
} from "@bff/core/rate-limiting/index.js";
|
||||
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
|
||||
import { Public } from "../../decorators/public.decorator.js";
|
||||
|
||||
@ -20,7 +24,7 @@ import {
|
||||
} from "@customer-portal/domain/get-started";
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
|
||||
import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js";
|
||||
import { GetStartedCoordinator } from "../../infra/workflows/get-started-coordinator.service.js";
|
||||
import { setAuthCookies, buildSessionInfo, type SessionInfo } from "./utils/auth-cookie.util.js";
|
||||
|
||||
// DTO classes using Zod schemas
|
||||
@ -54,7 +58,7 @@ interface AuthSuccessResponse {
|
||||
*/
|
||||
@Controller("auth/get-started")
|
||||
export class GetStartedController {
|
||||
constructor(private readonly workflow: GetStartedWorkflowService) {}
|
||||
constructor(private readonly workflow: GetStartedCoordinator) {}
|
||||
|
||||
/**
|
||||
* Send OTP verification code to email
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
"@customer-portal/domain": "workspace:*",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"@xstate/react": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.5.1",
|
||||
@ -30,6 +31,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"world-countries": "^5.1.0",
|
||||
"xstate": "^5.28.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
/**
|
||||
* GetStartedForm - Main form component for the unified get-started flow
|
||||
*
|
||||
* Flow: Email → OTP Verification → Account Status → Complete Account → Success
|
||||
* Flow: Email -> OTP Verification -> Account Status -> Complete Account -> Success
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useGetStartedStore, type GetStartedStep } from "../../stores/get-started.store";
|
||||
import { useGetStartedMachine } from "../../hooks/useGetStartedMachine";
|
||||
import {
|
||||
EmailStep,
|
||||
VerificationStep,
|
||||
@ -17,16 +17,9 @@ import {
|
||||
SuccessStep,
|
||||
} from "./steps";
|
||||
|
||||
const stepComponents: Record<GetStartedStep, React.ComponentType> = {
|
||||
email: EmailStep,
|
||||
verification: VerificationStep,
|
||||
"account-status": AccountStatusStep,
|
||||
"complete-account": CompleteAccountStep,
|
||||
"migrate-account": MigrateAccountStep,
|
||||
success: SuccessStep,
|
||||
};
|
||||
type StepName = string;
|
||||
|
||||
const stepTitles: Record<GetStartedStep, { title: string; subtitle: string }> = {
|
||||
const stepTitles: Record<string, { title: string; subtitle: string }> = {
|
||||
email: {
|
||||
title: "Get Started",
|
||||
subtitle: "Enter your email to begin",
|
||||
@ -35,15 +28,19 @@ const stepTitles: Record<GetStartedStep, { title: string; subtitle: string }> =
|
||||
title: "Verify Your Email",
|
||||
subtitle: "Enter the code we sent to your email",
|
||||
},
|
||||
"account-status": {
|
||||
accountStatus: {
|
||||
title: "Welcome",
|
||||
subtitle: "Let's get you set up",
|
||||
},
|
||||
"complete-account": {
|
||||
loginRedirect: {
|
||||
title: "Welcome",
|
||||
subtitle: "Let's get you set up",
|
||||
},
|
||||
completeAccount: {
|
||||
title: "Create Your Account",
|
||||
subtitle: "Just a few more details",
|
||||
},
|
||||
"migrate-account": {
|
||||
migrateAccount: {
|
||||
title: "Set Up Your Account",
|
||||
subtitle: "Create your portal password",
|
||||
},
|
||||
@ -53,36 +50,91 @@ const stepTitles: Record<GetStartedStep, { title: string; subtitle: string }> =
|
||||
},
|
||||
};
|
||||
|
||||
function getTopLevelState(stateValue: unknown): string {
|
||||
if (typeof stateValue === "string") return stateValue;
|
||||
if (typeof stateValue === "object" && stateValue !== null) {
|
||||
return Object.keys(stateValue)[0] ?? "idle";
|
||||
}
|
||||
return "idle";
|
||||
}
|
||||
|
||||
interface GetStartedFormProps {
|
||||
/** Callback when step changes (for parent to update title) */
|
||||
onStepChange?: (step: GetStartedStep, meta: { title: string; subtitle: string }) => void;
|
||||
onStepChange?: (step: StepName, meta: { title: string; subtitle: string }) => void;
|
||||
}
|
||||
|
||||
export function GetStartedForm({ onStepChange }: GetStartedFormProps) {
|
||||
const { step, reset } = useGetStartedStore();
|
||||
const { state, send } = useGetStartedMachine();
|
||||
|
||||
const topState = getTopLevelState(state.value);
|
||||
|
||||
// Reset form on mount to ensure clean state (but not if coming from handoff)
|
||||
useEffect(() => {
|
||||
// Check if user is coming from eligibility check handoff
|
||||
const hasHandoffParam = window.location.search.includes("handoff=");
|
||||
const hasHandoffToken = sessionStorage.getItem("get-started-handoff-token");
|
||||
const hasVerifiedParam = window.location.search.includes("verified=");
|
||||
|
||||
// Don't reset if we have handoff data - let GetStartedView pre-fill the form
|
||||
if (!hasHandoffParam && !hasHandoffToken) {
|
||||
reset();
|
||||
if (!hasHandoffParam && !hasHandoffToken && !hasVerifiedParam) {
|
||||
send({ type: "RESET" });
|
||||
}
|
||||
}, [reset]);
|
||||
}, [send]);
|
||||
|
||||
// Notify parent of step changes
|
||||
useEffect(() => {
|
||||
onStepChange?.(step, stepTitles[step]);
|
||||
}, [step, onStepChange]);
|
||||
const meta = stepTitles[topState] ?? stepTitles["email"]!;
|
||||
onStepChange?.(topState, meta);
|
||||
}, [topState, onStepChange]);
|
||||
|
||||
const StepComponent = stepComponents[step];
|
||||
// Auto-start the machine if it's in idle state
|
||||
useEffect(() => {
|
||||
if (topState === "idle") {
|
||||
send({ type: "START" });
|
||||
}
|
||||
}, [topState, send]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<StepComponent />
|
||||
</div>
|
||||
);
|
||||
switch (topState) {
|
||||
case "email":
|
||||
return (
|
||||
<div className="w-full">
|
||||
<EmailStep />
|
||||
</div>
|
||||
);
|
||||
case "verification":
|
||||
return (
|
||||
<div className="w-full">
|
||||
<VerificationStep />
|
||||
</div>
|
||||
);
|
||||
case "accountStatus":
|
||||
case "loginRedirect":
|
||||
return (
|
||||
<div className="w-full">
|
||||
<AccountStatusStep />
|
||||
</div>
|
||||
);
|
||||
case "completeAccount":
|
||||
return (
|
||||
<div className="w-full">
|
||||
<CompleteAccountStep />
|
||||
</div>
|
||||
);
|
||||
case "migrateAccount":
|
||||
return (
|
||||
<div className="w-full">
|
||||
<MigrateAccountStep />
|
||||
</div>
|
||||
);
|
||||
case "success":
|
||||
return (
|
||||
<div className="w-full">
|
||||
<SuccessStep />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="w-full">
|
||||
<EmailStep />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,248 +1,58 @@
|
||||
/**
|
||||
* AccountStatusStep - Shows account status and routes to appropriate next step
|
||||
* AccountStatusStep - Shows login UI for portal_exists accounts
|
||||
*
|
||||
* Routes based on account status:
|
||||
* - portal_exists: Show login form inline (or redirect link in full-page mode)
|
||||
* - whmcs_unmapped: Go to migrate-account step (passwordless, email verification = identity proof)
|
||||
* - sf_unmapped: Go to complete-account step (pre-filled form)
|
||||
* - new_customer: Go to complete-account step (full signup)
|
||||
* With XState's `always` transitions on accountStatus, only portal_exists
|
||||
* reaches this component (via loginRedirect state). Other statuses
|
||||
* (whmcs_unmapped, sf_unmapped, new_customer) auto-route to their
|
||||
* respective steps (migrateAccount, completeAccount).
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
UserCircleIcon,
|
||||
ArrowRightIcon,
|
||||
DocumentCheckIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { CheckCircleIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import { LoginForm } from "@/features/auth/components/LoginForm/LoginForm";
|
||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||
import { useGetStartedStore } from "../../../stores/get-started.store";
|
||||
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
|
||||
|
||||
export function AccountStatusStep() {
|
||||
const { accountStatus, formData, goToStep, prefill, inline, redirectTo, serviceContext } =
|
||||
useGetStartedStore();
|
||||
const { state } = useGetStartedMachine();
|
||||
const { formData, inline, redirectTo, serviceContext } = state.context;
|
||||
|
||||
// Compute effective redirect URL from store state (with validation)
|
||||
const effectiveRedirectTo = getSafeRedirect(
|
||||
redirectTo || serviceContext?.redirectTo,
|
||||
"/account/dashboard"
|
||||
);
|
||||
// Compute effective redirect URL from machine context (with validation)
|
||||
const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account");
|
||||
|
||||
// Portal exists - show login form inline or redirect to login page
|
||||
if (accountStatus === "portal_exists") {
|
||||
// Inline mode: render login form directly
|
||||
if (inline) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center">
|
||||
<CheckCircleIcon className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
<h3 className="text-lg font-semibold text-foreground">Account Found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You already have a portal account with this email. Please log in to continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginForm
|
||||
initialEmail={formData.email}
|
||||
redirectTo={effectiveRedirectTo}
|
||||
showSignupLink={false}
|
||||
showForgotPasswordLink={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full-page mode: redirect to login page
|
||||
const loginUrl = `/auth/login?email=${encodeURIComponent(formData.email)}&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center">
|
||||
<CheckCircleIcon className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">Account Found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You already have a portal account with this email. Please log in to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
as="a"
|
||||
href={loginUrl}
|
||||
className="w-full h-11"
|
||||
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// WHMCS exists but not mapped - go to migrate-account step (passwordless)
|
||||
// Email verification already proves identity - no legacy password needed
|
||||
if (accountStatus === "whmcs_unmapped") {
|
||||
// Inline mode: render login form directly
|
||||
if (inline) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<UserCircleIcon className="h-8 w-8 text-primary" />
|
||||
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center">
|
||||
<CheckCircleIcon className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"}
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground">Account Found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We found your existing billing account. Set up your new portal password to continue.
|
||||
You already have a portal account with this email. Please log in to continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show what's pre-filled vs what's needed */}
|
||||
<div className="p-4 rounded-xl bg-muted/50 border border-border text-left space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Your account info:</p>
|
||||
<ul className="space-y-1.5">
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Email verified</span>
|
||||
</li>
|
||||
{(prefill?.firstName || prefill?.lastName) && (
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Name on file</span>
|
||||
</li>
|
||||
)}
|
||||
{prefill?.phone && (
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Phone number on file</span>
|
||||
</li>
|
||||
)}
|
||||
{prefill?.address && (
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Address on file</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">What you'll add:</p>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-4 text-center">•</span>
|
||||
<span>New portal password</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => goToStep("migrate-account")}
|
||||
className="w-full h-11"
|
||||
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<LoginForm
|
||||
initialEmail={formData.email}
|
||||
redirectTo={effectiveRedirectTo}
|
||||
showSignupLink={false}
|
||||
showForgotPasswordLink={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SF exists but not mapped - complete account with pre-filled data
|
||||
if (accountStatus === "sf_unmapped") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<DocumentCheckIcon className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We found your information from a previous inquiry. Complete a few more details to
|
||||
activate your account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show what's pre-filled vs what's needed */}
|
||||
<div className="p-4 rounded-xl bg-muted/50 border border-border text-left space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">What we have:</p>
|
||||
<ul className="space-y-1.5">
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Name and email verified</span>
|
||||
</li>
|
||||
{prefill?.address && (
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Address from your inquiry</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">What you'll add:</p>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-4 text-center">•</span>
|
||||
<span>Phone number</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-4 text-center">•</span>
|
||||
<span>Date of birth</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-4 text-center">•</span>
|
||||
<span>Password</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prefill?.eligibilityStatus && (
|
||||
<p className="text-sm font-medium text-success text-center">
|
||||
Eligibility Status: {prefill.eligibilityStatus}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => goToStep("complete-account")}
|
||||
className="w-full h-11"
|
||||
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// New customer - proceed to full signup
|
||||
// Full-page mode: redirect to login page
|
||||
const loginUrl = `/auth/login?email=${encodeURIComponent(formData.email)}&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="flex justify-center">
|
||||
@ -252,18 +62,19 @@ export function AccountStatusStep() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">Email Verified!</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground">Account Found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Great! Let's set up your account so you can access all our services.
|
||||
You already have a portal account with this email. Please log in to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => goToStep("complete-account")}
|
||||
as="a"
|
||||
href={loginUrl}
|
||||
className="w-full h-11"
|
||||
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||
>
|
||||
Continue
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
import { Button } from "@/components/atoms";
|
||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||
import { TermsCheckbox, MarketingCheckbox } from "@/features/auth/components";
|
||||
import { useGetStartedStore } from "../../../stores/get-started.store";
|
||||
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
PrefilledUserInfo,
|
||||
@ -21,27 +21,16 @@ import {
|
||||
PasswordSection,
|
||||
useCompleteAccountForm,
|
||||
} from "./complete-account";
|
||||
import type { GetStartedFormData } from "../../../machines/get-started.types";
|
||||
|
||||
export function CompleteAccountStep() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
formData,
|
||||
updateFormData,
|
||||
completeAccount,
|
||||
prefill,
|
||||
accountStatus,
|
||||
loading,
|
||||
error,
|
||||
clearError,
|
||||
goBack,
|
||||
redirectTo,
|
||||
serviceContext,
|
||||
} = useGetStartedStore();
|
||||
const { state, send } = useGetStartedMachine();
|
||||
|
||||
const effectiveRedirectTo = getSafeRedirect(
|
||||
redirectTo || serviceContext?.redirectTo,
|
||||
"/account/dashboard"
|
||||
);
|
||||
const { formData, prefill, accountStatus, redirectTo, serviceContext, error } = state.context;
|
||||
const loading = state.matches({ completeAccount: "loading" });
|
||||
|
||||
const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account");
|
||||
|
||||
const isNewCustomer = accountStatus === "new_customer";
|
||||
const hasPrefill = !!(prefill?.firstName || prefill?.lastName);
|
||||
@ -57,6 +46,10 @@ export function CompleteAccountStep() {
|
||||
const isSfUnmappedWithIncompleteAddress = accountStatus === "sf_unmapped" && !hasCompleteAddress;
|
||||
const needsAddress = isNewCustomer || isSfUnmappedWithIncompleteAddress;
|
||||
|
||||
const updateFormData = (data: Partial<GetStartedFormData>) => {
|
||||
send({ type: "UPDATE_FORM_DATA", data });
|
||||
};
|
||||
|
||||
const form = useCompleteAccountForm({
|
||||
initialValues: {
|
||||
firstName: formData.firstName || prefill?.firstName,
|
||||
@ -72,15 +65,18 @@ export function CompleteAccountStep() {
|
||||
updateFormData,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
clearError();
|
||||
const handleSubmit = () => {
|
||||
if (!form.validate()) return;
|
||||
|
||||
updateFormData(form.getFormData());
|
||||
const result = await completeAccount();
|
||||
if (result) router.push(effectiveRedirectTo);
|
||||
const completeFormData = { ...formData, ...form.getFormData() };
|
||||
send({ type: "COMPLETE", formData: completeFormData as GetStartedFormData });
|
||||
};
|
||||
|
||||
// Redirect on success
|
||||
if (state.matches("success")) {
|
||||
router.push(effectiveRedirectTo);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
@ -173,7 +169,7 @@ export function CompleteAccountStep() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={goBack}
|
||||
onClick={() => send({ type: "GO_BACK" })}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
|
||||
@ -6,20 +6,23 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Input, Label } from "@/components/atoms";
|
||||
import { useGetStartedStore } from "../../../stores/get-started.store";
|
||||
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
|
||||
|
||||
export function EmailStep() {
|
||||
const { formData, sendVerificationCode, loading, error, clearError } = useGetStartedStore();
|
||||
const [email, setEmail] = useState(formData.email);
|
||||
const { state, send } = useGetStartedMachine();
|
||||
const [email, setEmail] = useState(state.context.formData.email);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const loading = state.matches({ email: "loading" });
|
||||
const error = state.context.error;
|
||||
|
||||
const validateEmail = (value: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(value);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
clearError();
|
||||
const handleSubmit = () => {
|
||||
send({ type: "UPDATE_FORM_DATA", data: { email: "" } }); // clear stale error via re-render
|
||||
setLocalError(null);
|
||||
|
||||
const trimmedEmail = email.trim().toLowerCase();
|
||||
@ -34,7 +37,7 @@ export function EmailStep() {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendVerificationCode(trimmedEmail);
|
||||
send({ type: "SEND_CODE", email: trimmedEmail });
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@ -58,7 +61,6 @@ export function EmailStep() {
|
||||
onChange={e => {
|
||||
setEmail(e.target.value);
|
||||
setLocalError(null);
|
||||
clearError();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={loading}
|
||||
|
||||
@ -14,7 +14,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Button, Input, Label } from "@/components/atoms";
|
||||
import { Checkbox } from "@/components/atoms/checkbox";
|
||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||
import { useGetStartedStore } from "../../../stores/get-started.store";
|
||||
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
|
||||
|
||||
interface FormErrors {
|
||||
password?: string | undefined;
|
||||
@ -24,24 +24,13 @@ interface FormErrors {
|
||||
|
||||
export function MigrateAccountStep() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
formData,
|
||||
updateFormData,
|
||||
migrateWhmcsAccount,
|
||||
prefill,
|
||||
loading,
|
||||
error,
|
||||
clearError,
|
||||
goBack,
|
||||
redirectTo,
|
||||
serviceContext,
|
||||
} = useGetStartedStore();
|
||||
const { state, send } = useGetStartedMachine();
|
||||
|
||||
// Compute effective redirect URL from store state (with validation)
|
||||
const effectiveRedirectTo = getSafeRedirect(
|
||||
redirectTo || serviceContext?.redirectTo,
|
||||
"/account/dashboard"
|
||||
);
|
||||
const { formData, prefill, redirectTo, serviceContext, error } = state.context;
|
||||
const loading = state.matches({ migrateAccount: "loading" });
|
||||
|
||||
// Compute effective redirect URL from machine context (with validation)
|
||||
const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account");
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
@ -78,28 +67,26 @@ export function MigrateAccountStep() {
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
clearError();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateFormData({
|
||||
acceptTerms,
|
||||
marketingConsent,
|
||||
send({
|
||||
type: "UPDATE_FORM_DATA",
|
||||
data: { acceptTerms, marketingConsent },
|
||||
});
|
||||
|
||||
// Pass password directly to avoid race condition between updateFormData and API call
|
||||
const result = await migrateWhmcsAccount({ password });
|
||||
if (result) {
|
||||
// Redirect to the effective redirect URL on success
|
||||
router.push(effectiveRedirectTo);
|
||||
}
|
||||
send({ type: "MIGRATE", password });
|
||||
};
|
||||
|
||||
const canSubmit = password && confirmPassword && acceptTerms;
|
||||
|
||||
// Redirect on success
|
||||
if (state.matches("success")) {
|
||||
router.push(effectiveRedirectTo);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@ -288,7 +275,7 @@ export function MigrateAccountStep() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={goBack}
|
||||
onClick={() => send({ type: "GO_BACK" })}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
|
||||
@ -7,19 +7,17 @@
|
||||
import { Button } from "@/components/atoms";
|
||||
import { CheckCircleIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||
import { useGetStartedStore } from "../../../stores/get-started.store";
|
||||
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
|
||||
|
||||
export function SuccessStep() {
|
||||
const { redirectTo, serviceContext } = useGetStartedStore();
|
||||
const { state } = useGetStartedMachine();
|
||||
const { redirectTo, serviceContext } = state.context;
|
||||
|
||||
// Compute effective redirect URL from store state (with validation)
|
||||
const effectiveRedirectTo = getSafeRedirect(
|
||||
redirectTo || serviceContext?.redirectTo,
|
||||
"/account/dashboard"
|
||||
);
|
||||
// Compute effective redirect URL from machine context (with validation)
|
||||
const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account");
|
||||
|
||||
// Determine if redirecting to dashboard (default) or a specific service
|
||||
const isDefaultRedirect = effectiveRedirectTo === "/account/dashboard";
|
||||
const isDefaultRedirect = effectiveRedirectTo === "/account";
|
||||
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
|
||||
@ -7,47 +7,47 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { OtpInput } from "@/components/molecules";
|
||||
import { useGetStartedStore } from "../../../stores/get-started.store";
|
||||
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
|
||||
|
||||
export function VerificationStep() {
|
||||
const {
|
||||
formData,
|
||||
verifyCode,
|
||||
sendVerificationCode,
|
||||
loading,
|
||||
error,
|
||||
clearError,
|
||||
attemptsRemaining,
|
||||
goBack,
|
||||
} = useGetStartedStore();
|
||||
const { state, send } = useGetStartedMachine();
|
||||
|
||||
const loading = state.matches({ verification: "loading" });
|
||||
const error = state.context.error;
|
||||
const attemptsRemaining = state.context.attemptsRemaining;
|
||||
const email = state.context.formData.email;
|
||||
|
||||
const [code, setCode] = useState("");
|
||||
const [resending, setResending] = useState(false);
|
||||
|
||||
const handleCodeChange = (value: string) => {
|
||||
setCode(value);
|
||||
clearError();
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
const handleVerify = () => {
|
||||
if (code.length === 6) {
|
||||
await verifyCode(code);
|
||||
send({ type: "VERIFY_CODE", code });
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
const handleResend = () => {
|
||||
setResending(true);
|
||||
setCode("");
|
||||
clearError();
|
||||
await sendVerificationCode(formData.email);
|
||||
setResending(false);
|
||||
send({ type: "SEND_CODE", email });
|
||||
// Reset resending state after a short delay (the machine handles the actual async)
|
||||
setTimeout(() => setResending(false), 2000);
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
send({ type: "RESET" });
|
||||
send({ type: "START" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Enter the 6-digit code sent to</p>
|
||||
<p className="font-medium text-foreground">{formData.email}</p>
|
||||
<p className="font-medium text-foreground">{email}</p>
|
||||
</div>
|
||||
|
||||
<OtpInput
|
||||
@ -79,7 +79,7 @@ export function VerificationStep() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={goBack}
|
||||
onClick={handleGoBack}
|
||||
disabled={loading}
|
||||
className="text-sm"
|
||||
>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* InlineGetStartedSection - Inline email-first registration for service pages
|
||||
*
|
||||
* Uses the get-started store flow (email → OTP → status → form) inline on service pages
|
||||
* Uses the XState get-started machine (email -> OTP -> status -> form) inline on service pages
|
||||
* like the SIM configure page. Supports service context to track plan selection through the flow.
|
||||
*
|
||||
* The email-first approach auto-detects the user's account status after OTP verification:
|
||||
@ -15,7 +15,11 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||
import { useGetStartedStore, type ServiceContext } from "../../stores/get-started.store";
|
||||
import {
|
||||
useGetStartedMachineRoot,
|
||||
GetStartedMachineProvider,
|
||||
} from "../../hooks/useGetStartedMachine";
|
||||
import type { ServiceContext } from "../../machines/get-started.types";
|
||||
import { EmailStep } from "../GetStartedForm/steps/EmailStep";
|
||||
import { VerificationStep } from "../GetStartedForm/steps/VerificationStep";
|
||||
import { AccountStatusStep } from "../GetStartedForm/steps/AccountStatusStep";
|
||||
@ -36,6 +40,14 @@ interface InlineGetStartedSectionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getTopLevelState(stateValue: unknown): string {
|
||||
if (typeof stateValue === "string") return stateValue;
|
||||
if (typeof stateValue === "object" && stateValue !== null) {
|
||||
return Object.keys(stateValue)[0] ?? "idle";
|
||||
}
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function InlineGetStartedSection({
|
||||
title,
|
||||
description,
|
||||
@ -45,45 +57,49 @@ export function InlineGetStartedSection({
|
||||
className = "",
|
||||
}: InlineGetStartedSectionProps) {
|
||||
const router = useRouter();
|
||||
const safeRedirect = getSafeRedirect(redirectTo, "/account/dashboard");
|
||||
const safeRedirect = getSafeRedirect(redirectTo, "/account");
|
||||
|
||||
const { step, setServiceContext, setRedirectTo, setInline } = useGetStartedStore();
|
||||
const { state, send } = useGetStartedMachineRoot({
|
||||
inline: true,
|
||||
serviceContext: serviceContext ? { ...serviceContext, redirectTo: safeRedirect } : undefined,
|
||||
});
|
||||
|
||||
// Set inline mode and redirect URL when component mounts
|
||||
// Set redirect URL when component mounts
|
||||
useEffect(() => {
|
||||
setInline(true);
|
||||
setRedirectTo(safeRedirect);
|
||||
send({ type: "SET_REDIRECT", redirectTo: safeRedirect });
|
||||
}, [safeRedirect, send]);
|
||||
|
||||
if (serviceContext) {
|
||||
setServiceContext({
|
||||
...serviceContext,
|
||||
redirectTo: safeRedirect,
|
||||
});
|
||||
const topState = getTopLevelState(state.value);
|
||||
|
||||
// Redirect on success
|
||||
useEffect(() => {
|
||||
if (topState === "success") {
|
||||
router.push(safeRedirect);
|
||||
}
|
||||
}, [topState, safeRedirect, router]);
|
||||
|
||||
return () => {
|
||||
// Clear inline mode when unmounting
|
||||
setInline(false);
|
||||
setServiceContext(null);
|
||||
};
|
||||
}, [serviceContext, safeRedirect, setServiceContext, setRedirectTo, setInline]);
|
||||
// Auto-start the machine if it's in idle state
|
||||
useEffect(() => {
|
||||
if (topState === "idle") {
|
||||
send({ type: "START" });
|
||||
}
|
||||
}, [topState, send]);
|
||||
|
||||
// Render the current step
|
||||
const renderStep = () => {
|
||||
switch (step) {
|
||||
switch (topState) {
|
||||
case "email":
|
||||
return <EmailStep />;
|
||||
case "verification":
|
||||
return <VerificationStep />;
|
||||
case "account-status":
|
||||
case "accountStatus":
|
||||
case "loginRedirect":
|
||||
return <AccountStatusStep />;
|
||||
case "complete-account":
|
||||
case "completeAccount":
|
||||
return <CompleteAccountStep />;
|
||||
case "migrate-account":
|
||||
case "migrateAccount":
|
||||
return <MigrateAccountStep />;
|
||||
case "success":
|
||||
// Redirect on success
|
||||
router.push(safeRedirect);
|
||||
return null;
|
||||
default:
|
||||
return <EmailStep />;
|
||||
@ -91,30 +107,32 @@ export function InlineGetStartedSection({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-muted/50 border border-border rounded-2xl p-6 md:p-8 ${className}`}>
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground max-w-2xl mx-auto">{description}</p>
|
||||
<GetStartedMachineProvider value={{ state, send }}>
|
||||
<div className={`bg-muted/50 border border-border rounded-2xl p-6 md:p-8 ${className}`}>
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground max-w-2xl mx-auto">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl p-5 sm:p-6 shadow-[var(--cp-shadow-1)]">
|
||||
{renderStep()}
|
||||
</div>
|
||||
|
||||
{highlights.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-border">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
|
||||
{highlights.map(item => (
|
||||
<div key={item.title}>
|
||||
<div className="text-sm font-medium text-foreground mb-1">{item.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl p-5 sm:p-6 shadow-[var(--cp-shadow-1)]">
|
||||
{renderStep()}
|
||||
</div>
|
||||
|
||||
{highlights.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-border">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
|
||||
{highlights.map(item => (
|
||||
<div key={item.title}>
|
||||
<div className="text-sm font-medium text-foreground mb-1">{item.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GetStartedMachineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Get Started Machine — React Hook + Context
|
||||
*
|
||||
* Provides the XState machine via React context so all child components
|
||||
* in the get-started flow share the same machine instance.
|
||||
*
|
||||
* Usage:
|
||||
* // In parent (GetStartedView or InlineGetStartedSection):
|
||||
* const machine = useGetStartedMachineRoot({ inline: true });
|
||||
* <GetStartedMachineProvider value={machine}>...</GetStartedMachineProvider>
|
||||
*
|
||||
* // In child (any step component):
|
||||
* const { state, send } = useGetStartedMachine();
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { useMachine } from "@xstate/react";
|
||||
import { getStartedMachine } from "../machines/get-started.machine";
|
||||
import type { ServiceContext } from "../machines/get-started.types";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type MachineState = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type MachineSend = any;
|
||||
|
||||
interface GetStartedMachineContextValue {
|
||||
state: MachineState;
|
||||
send: MachineSend;
|
||||
}
|
||||
|
||||
const GetStartedMachineContext = createContext<GetStartedMachineContextValue | null>(null);
|
||||
|
||||
export const GetStartedMachineProvider = GetStartedMachineContext.Provider;
|
||||
|
||||
interface UseGetStartedMachineOptions {
|
||||
inline?: boolean | undefined;
|
||||
serviceContext?: ServiceContext | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new machine instance. Call once at the root of the flow.
|
||||
*/
|
||||
export function useGetStartedMachineRoot(options?: UseGetStartedMachineOptions) {
|
||||
const [state, send] = useMachine(getStartedMachine, {
|
||||
input: {
|
||||
inline: options?.inline,
|
||||
serviceContext: options?.serviceContext,
|
||||
},
|
||||
});
|
||||
|
||||
return { state, send };
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the machine from context. Call in child step components.
|
||||
*/
|
||||
export function useGetStartedMachine(): GetStartedMachineContextValue {
|
||||
const ctx = useContext(GetStartedMachineContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useGetStartedMachine must be used within GetStartedMachineProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@ -16,13 +16,13 @@ export { GetStartedForm, InlineGetStartedSection } from "./components";
|
||||
// OtpInput moved to @/components/molecules - import from there
|
||||
export { OtpInput } from "@/components/molecules";
|
||||
|
||||
// Store
|
||||
// Machine
|
||||
export {
|
||||
useGetStartedStore,
|
||||
type GetStartedStep,
|
||||
type GetStartedState,
|
||||
type ServiceContext,
|
||||
} from "./stores/get-started.store";
|
||||
useGetStartedMachine,
|
||||
useGetStartedMachineRoot,
|
||||
GetStartedMachineProvider,
|
||||
} from "./hooks/useGetStartedMachine";
|
||||
export type { ServiceContext, GetStartedFormData } from "./machines/get-started.types";
|
||||
|
||||
// API
|
||||
export * as getStartedApi from "./api/get-started.api";
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Get Started Machine — Actors
|
||||
*
|
||||
* Async API call actors invoked by XState states.
|
||||
* Each actor wraps an existing API function from the get-started API client.
|
||||
*/
|
||||
|
||||
import { fromPromise } from "xstate";
|
||||
import * as getStartedApi from "../api/get-started.api";
|
||||
import type {
|
||||
SendCodeInput,
|
||||
SendCodeOutput,
|
||||
VerifyCodeInput,
|
||||
VerifyCodeOutput,
|
||||
CompleteAccountInput,
|
||||
CompleteAccountOutput,
|
||||
MigrateAccountInput,
|
||||
MigrateAccountOutput,
|
||||
} from "./get-started.types";
|
||||
|
||||
/**
|
||||
* Send OTP verification code to email
|
||||
*/
|
||||
export const sendCodeActor = fromPromise<SendCodeOutput, SendCodeInput>(async ({ input }) => {
|
||||
return getStartedApi.sendVerificationCode({ email: input.email });
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify OTP code and get account status
|
||||
*/
|
||||
export const verifyCodeActor = fromPromise<VerifyCodeOutput, VerifyCodeInput>(async ({ input }) => {
|
||||
return getStartedApi.verifyCode({
|
||||
email: input.email,
|
||||
code: input.code,
|
||||
...(input.handoffToken && { handoffToken: input.handoffToken }),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Complete account (new customer or SF-unmapped)
|
||||
*/
|
||||
export const completeAccountActor = fromPromise<CompleteAccountOutput, CompleteAccountInput>(
|
||||
async ({ input }) => {
|
||||
const { sessionToken, formData, accountStatus } = input;
|
||||
const isNewCustomer = accountStatus === "new_customer";
|
||||
|
||||
return getStartedApi.completeAccount({
|
||||
sessionToken,
|
||||
password: formData.password,
|
||||
phone: formData.phone,
|
||||
dateOfBirth: formData.dateOfBirth,
|
||||
gender: formData.gender as "male" | "female" | "other",
|
||||
acceptTerms: formData.acceptTerms,
|
||||
marketingConsent: formData.marketingConsent,
|
||||
...(isNewCustomer || formData.firstName ? { firstName: formData.firstName } : {}),
|
||||
...(isNewCustomer || formData.lastName ? { lastName: formData.lastName } : {}),
|
||||
...(isNewCustomer || formData.address?.address1
|
||||
? {
|
||||
address: {
|
||||
address1: formData.address.address1 || "",
|
||||
address2: formData.address.address2,
|
||||
city: formData.address.city || "",
|
||||
state: formData.address.state || "",
|
||||
postcode: formData.address.postcode || "",
|
||||
country: formData.address.country || "JP",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Migrate WHMCS account to portal
|
||||
*/
|
||||
export const migrateAccountActor = fromPromise<MigrateAccountOutput, MigrateAccountInput>(
|
||||
async ({ input }) => {
|
||||
return getStartedApi.migrateWhmcsAccount({
|
||||
sessionToken: input.sessionToken,
|
||||
password: input.password,
|
||||
acceptTerms: input.acceptTerms,
|
||||
marketingConsent: input.marketingConsent,
|
||||
});
|
||||
}
|
||||
);
|
||||
@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Get Started Machine — XState Definition
|
||||
*
|
||||
* State machine managing the get-started flow:
|
||||
* idle -> email -> verification -> account-status -> (complete-account | migrate-account) -> success
|
||||
*
|
||||
* Replaces the implicit state machine in the Zustand store with enforced
|
||||
* transitions, guards, and typed events.
|
||||
*/
|
||||
|
||||
import { setup, assign } from "xstate";
|
||||
import { getErrorMessage } from "@/shared/utils";
|
||||
import {
|
||||
sendCodeActor,
|
||||
verifyCodeActor,
|
||||
completeAccountActor,
|
||||
migrateAccountActor,
|
||||
} from "./get-started.actors";
|
||||
import type {
|
||||
GetStartedContext,
|
||||
GetStartedEvent,
|
||||
GetStartedFormData,
|
||||
GetStartedMachineInput,
|
||||
} from "./get-started.types";
|
||||
|
||||
// ============================================================================
|
||||
// Initial form data (matches store defaults)
|
||||
// ============================================================================
|
||||
|
||||
const initialFormData: GetStartedFormData = {
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
phone: "",
|
||||
address: {},
|
||||
dateOfBirth: "",
|
||||
gender: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
acceptTerms: false,
|
||||
marketingConsent: false,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Machine Definition
|
||||
// ============================================================================
|
||||
|
||||
export const getStartedMachine = setup({
|
||||
types: {
|
||||
context: {} as GetStartedContext,
|
||||
events: {} as GetStartedEvent,
|
||||
input: {} as GetStartedMachineInput,
|
||||
},
|
||||
actors: {
|
||||
sendCode: sendCodeActor,
|
||||
verifyCode: verifyCodeActor,
|
||||
completeAccount: completeAccountActor,
|
||||
migrateAccount: migrateAccountActor,
|
||||
},
|
||||
guards: {
|
||||
hasSessionToken: ({ context }) => context.sessionToken !== null,
|
||||
isNewOrSfUnmapped: ({ context }) =>
|
||||
context.accountStatus === "new_customer" || context.accountStatus === "sf_unmapped",
|
||||
isWhmcsUnmapped: ({ context }) => context.accountStatus === "whmcs_unmapped",
|
||||
isPortalExists: ({ context }) => context.accountStatus === "portal_exists",
|
||||
},
|
||||
actions: {
|
||||
clearError: assign({ error: null }),
|
||||
resetMachine: assign(({ context }) => ({
|
||||
email: null,
|
||||
sessionToken: null,
|
||||
accountStatus: null,
|
||||
formData: initialFormData,
|
||||
prefill: null,
|
||||
handoffToken: null,
|
||||
serviceContext: context.serviceContext,
|
||||
redirectTo: context.redirectTo,
|
||||
inline: context.inline,
|
||||
error: null,
|
||||
codeSent: false,
|
||||
attemptsRemaining: null,
|
||||
authResponse: null,
|
||||
})),
|
||||
},
|
||||
}).createMachine({
|
||||
id: "getStarted",
|
||||
context: ({ input }) => ({
|
||||
email: null,
|
||||
sessionToken: null,
|
||||
accountStatus: null,
|
||||
formData: initialFormData,
|
||||
prefill: null,
|
||||
handoffToken: null,
|
||||
serviceContext: input?.serviceContext ?? null,
|
||||
redirectTo: null,
|
||||
inline: input?.inline ?? false,
|
||||
error: null,
|
||||
codeSent: false,
|
||||
attemptsRemaining: null,
|
||||
authResponse: null,
|
||||
}),
|
||||
initial: "idle",
|
||||
on: {
|
||||
RESET: {
|
||||
target: ".idle",
|
||||
actions: "resetMachine",
|
||||
},
|
||||
UPDATE_FORM_DATA: {
|
||||
actions: assign({
|
||||
formData: ({ context, event }) => ({
|
||||
...context.formData,
|
||||
...event.data,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
SET_SERVICE_CONTEXT: {
|
||||
actions: assign({
|
||||
serviceContext: ({ event }) => event.context,
|
||||
}),
|
||||
},
|
||||
SET_REDIRECT: {
|
||||
actions: assign({
|
||||
redirectTo: ({ event }) => event.redirectTo,
|
||||
}),
|
||||
},
|
||||
},
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
START: { target: "email" },
|
||||
SET_EMAIL: {
|
||||
target: "email",
|
||||
actions: assign({
|
||||
email: ({ event }) => event.email,
|
||||
formData: ({ context, event }) => ({
|
||||
...context.formData,
|
||||
email: event.email,
|
||||
}),
|
||||
handoffToken: ({ event }) => event.handoffToken ?? null,
|
||||
}),
|
||||
},
|
||||
RESTORE_VERIFIED_SESSION: {
|
||||
target: "accountStatus",
|
||||
actions: assign({
|
||||
sessionToken: ({ event }) => event.sessionToken,
|
||||
accountStatus: ({ event }) => event.accountStatus,
|
||||
prefill: ({ event }) => event.prefill ?? null,
|
||||
formData: ({ context, event }) => {
|
||||
const prefill = event.prefill;
|
||||
if (!prefill) return context.formData;
|
||||
return {
|
||||
...context.formData,
|
||||
firstName: prefill.firstName ?? context.formData.firstName,
|
||||
lastName: prefill.lastName ?? context.formData.lastName,
|
||||
phone: prefill.phone ?? context.formData.phone,
|
||||
address: prefill.address
|
||||
? {
|
||||
address1: prefill.address.address1,
|
||||
address2: prefill.address.address2,
|
||||
city: prefill.address.city,
|
||||
state: prefill.address.state,
|
||||
postcode: prefill.address.postcode,
|
||||
country: prefill.address.country,
|
||||
countryCode: prefill.address.countryCode,
|
||||
}
|
||||
: context.formData.address,
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
email: {
|
||||
initial: "idle",
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
SEND_CODE: {
|
||||
target: "loading",
|
||||
actions: assign({
|
||||
email: ({ event }) => event.email,
|
||||
formData: ({ context, event }) => ({
|
||||
...context.formData,
|
||||
email: event.email,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
invoke: {
|
||||
src: "sendCode",
|
||||
input: ({ context }) => ({
|
||||
email: context.formData.email || context.email || "",
|
||||
}),
|
||||
onDone: [
|
||||
{
|
||||
guard: ({ event }) => event.output.sent === true,
|
||||
target: "#getStarted.verification",
|
||||
actions: assign({
|
||||
codeSent: true,
|
||||
error: null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
target: "error",
|
||||
actions: assign({
|
||||
error: ({ event }) => event.output.message,
|
||||
}),
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: "error",
|
||||
actions: assign({
|
||||
error: ({ event }) => getErrorMessage(event.error),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
error: {
|
||||
on: {
|
||||
SEND_CODE: {
|
||||
target: "loading",
|
||||
actions: assign({
|
||||
email: ({ event }) => event.email,
|
||||
formData: ({ context, event }) => ({
|
||||
...context.formData,
|
||||
email: event.email,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
verification: {
|
||||
initial: "idle",
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
VERIFY_CODE: { target: "loading" },
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
invoke: {
|
||||
src: "verifyCode",
|
||||
input: ({ context, event }) => ({
|
||||
email: context.formData.email || context.email || "",
|
||||
code: event.type === "VERIFY_CODE" ? event.code : "",
|
||||
...(context.handoffToken && { handoffToken: context.handoffToken }),
|
||||
}),
|
||||
onDone: [
|
||||
{
|
||||
guard: ({ event }) =>
|
||||
event.output.verified === true &&
|
||||
event.output.sessionToken !== undefined &&
|
||||
event.output.accountStatus !== undefined,
|
||||
target: "#getStarted.accountStatus",
|
||||
actions: assign({
|
||||
sessionToken: ({ event }) => event.output.sessionToken ?? null,
|
||||
accountStatus: ({ event }) => event.output.accountStatus ?? null,
|
||||
prefill: ({ event }) => event.output.prefill ?? null,
|
||||
error: null,
|
||||
formData: ({ context, event }) => {
|
||||
const prefill = event.output.prefill;
|
||||
if (!prefill) return context.formData;
|
||||
return {
|
||||
...context.formData,
|
||||
firstName: prefill.firstName ?? context.formData.firstName,
|
||||
lastName: prefill.lastName ?? context.formData.lastName,
|
||||
phone: prefill.phone ?? context.formData.phone,
|
||||
address: prefill.address
|
||||
? {
|
||||
address1: prefill.address.address1,
|
||||
address2: prefill.address.address2,
|
||||
city: prefill.address.city,
|
||||
state: prefill.address.state,
|
||||
postcode: prefill.address.postcode,
|
||||
country: prefill.address.country,
|
||||
countryCode: prefill.address.countryCode,
|
||||
}
|
||||
: context.formData.address,
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
target: "error",
|
||||
actions: assign({
|
||||
error: ({ event }) => event.output.error ?? "Verification failed",
|
||||
attemptsRemaining: ({ event }) => event.output.attemptsRemaining ?? null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: "error",
|
||||
actions: assign({
|
||||
error: ({ event }) => getErrorMessage(event.error),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
error: {
|
||||
on: {
|
||||
VERIFY_CODE: { target: "loading" },
|
||||
SEND_CODE: {
|
||||
target: "#getStarted.email.loading",
|
||||
actions: assign({
|
||||
email: ({ event }) => event.email,
|
||||
formData: ({ context, event }) => ({
|
||||
...context.formData,
|
||||
email: event.email,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
accountStatus: {
|
||||
always: [
|
||||
{
|
||||
guard: "isPortalExists",
|
||||
target: "loginRedirect",
|
||||
},
|
||||
{
|
||||
guard: "isNewOrSfUnmapped",
|
||||
target: "completeAccount",
|
||||
},
|
||||
{
|
||||
guard: "isWhmcsUnmapped",
|
||||
target: "migrateAccount",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
loginRedirect: {
|
||||
type: "final",
|
||||
},
|
||||
|
||||
completeAccount: {
|
||||
initial: "idle",
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
COMPLETE: {
|
||||
target: "loading",
|
||||
guard: "hasSessionToken",
|
||||
actions: assign({
|
||||
formData: ({ context, event }) => ({
|
||||
...context.formData,
|
||||
...event.formData,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
GO_BACK: { target: "#getStarted.accountStatus" },
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
invoke: {
|
||||
src: "completeAccount",
|
||||
input: ({ context }) => ({
|
||||
sessionToken: context.sessionToken!,
|
||||
formData: context.formData,
|
||||
accountStatus: context.accountStatus,
|
||||
}),
|
||||
onDone: {
|
||||
target: "#getStarted.success",
|
||||
actions: assign({
|
||||
authResponse: ({ event }) => event.output,
|
||||
sessionToken: null,
|
||||
error: null,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: "error",
|
||||
actions: assign({
|
||||
error: ({ event }) => getErrorMessage(event.error),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
error: {
|
||||
on: {
|
||||
COMPLETE: {
|
||||
target: "loading",
|
||||
guard: "hasSessionToken",
|
||||
actions: assign({
|
||||
formData: ({ context, event }) => ({
|
||||
...context.formData,
|
||||
...event.formData,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
GO_BACK: { target: "#getStarted.accountStatus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
migrateAccount: {
|
||||
initial: "idle",
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
MIGRATE: {
|
||||
target: "loading",
|
||||
guard: "hasSessionToken",
|
||||
},
|
||||
GO_BACK: { target: "#getStarted.accountStatus" },
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
invoke: {
|
||||
src: "migrateAccount",
|
||||
input: ({ context, event }) => ({
|
||||
sessionToken: context.sessionToken!,
|
||||
password:
|
||||
event.type === "MIGRATE" && event.password
|
||||
? event.password
|
||||
: context.formData.password,
|
||||
acceptTerms: context.formData.acceptTerms,
|
||||
marketingConsent: context.formData.marketingConsent,
|
||||
}),
|
||||
onDone: {
|
||||
target: "#getStarted.success",
|
||||
actions: assign({
|
||||
authResponse: ({ event }) => event.output,
|
||||
sessionToken: null,
|
||||
error: null,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: "error",
|
||||
actions: assign({
|
||||
error: ({ event }) => getErrorMessage(event.error),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
error: {
|
||||
on: {
|
||||
MIGRATE: {
|
||||
target: "loading",
|
||||
guard: "hasSessionToken",
|
||||
},
|
||||
GO_BACK: { target: "#getStarted.accountStatus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
success: {
|
||||
type: "final",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type GetStartedMachine = typeof getStartedMachine;
|
||||
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Get Started Machine — Types
|
||||
*
|
||||
* Context, events, and guard types for the XState state machine.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AccountStatus,
|
||||
VerifyCodeResponse,
|
||||
SendVerificationCodeResponse,
|
||||
} from "@customer-portal/domain/get-started";
|
||||
import type { AuthResponse } from "@customer-portal/domain/auth";
|
||||
|
||||
// ============================================================================
|
||||
// Form & Service Types (mirrored from store for compatibility)
|
||||
// ============================================================================
|
||||
|
||||
export interface ServiceContext {
|
||||
type: "sim" | "internet" | "vpn" | null;
|
||||
planSku?: string | undefined;
|
||||
redirectTo?: string | undefined;
|
||||
}
|
||||
|
||||
export interface GetStartedAddress {
|
||||
address1?: string | undefined;
|
||||
address2?: string | undefined;
|
||||
city?: string | undefined;
|
||||
state?: string | undefined;
|
||||
postcode?: string | undefined;
|
||||
country?: string | undefined;
|
||||
countryCode?: string | undefined;
|
||||
}
|
||||
|
||||
export interface GetStartedFormData {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
address: GetStartedAddress;
|
||||
dateOfBirth: string;
|
||||
gender: "male" | "female" | "other" | "";
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
marketingConsent: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Machine Context
|
||||
// ============================================================================
|
||||
|
||||
export interface GetStartedContext {
|
||||
email: string | null;
|
||||
sessionToken: string | null;
|
||||
accountStatus: AccountStatus | null;
|
||||
formData: GetStartedFormData;
|
||||
prefill: VerifyCodeResponse["prefill"] | null;
|
||||
handoffToken: string | null;
|
||||
serviceContext: ServiceContext | null;
|
||||
redirectTo: string | null;
|
||||
inline: boolean;
|
||||
error: string | null;
|
||||
codeSent: boolean;
|
||||
attemptsRemaining: number | null;
|
||||
authResponse: AuthResponse | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Machine Events
|
||||
// ============================================================================
|
||||
|
||||
export type GetStartedEvent =
|
||||
| { type: "START" }
|
||||
| { type: "SEND_CODE"; email: string }
|
||||
| { type: "VERIFY_CODE"; code: string }
|
||||
| { type: "COMPLETE"; formData: GetStartedFormData }
|
||||
| { type: "MIGRATE"; password?: string | undefined }
|
||||
| { type: "RESET" }
|
||||
| { type: "SET_EMAIL"; email: string; handoffToken?: string | undefined }
|
||||
| {
|
||||
type: "RESTORE_VERIFIED_SESSION";
|
||||
sessionToken: string;
|
||||
accountStatus: AccountStatus;
|
||||
prefill?: VerifyCodeResponse["prefill"] | undefined;
|
||||
}
|
||||
| { type: "UPDATE_FORM_DATA"; data: Partial<GetStartedFormData> }
|
||||
| { type: "SET_SERVICE_CONTEXT"; context: ServiceContext }
|
||||
| { type: "SET_REDIRECT"; redirectTo: string }
|
||||
| { type: "GO_BACK" };
|
||||
|
||||
// ============================================================================
|
||||
// Actor Input Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SendCodeInput {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface VerifyCodeInput {
|
||||
email: string;
|
||||
code: string;
|
||||
handoffToken?: string;
|
||||
}
|
||||
|
||||
export interface CompleteAccountInput {
|
||||
sessionToken: string;
|
||||
formData: GetStartedFormData;
|
||||
accountStatus: AccountStatus | null;
|
||||
}
|
||||
|
||||
export interface MigrateAccountInput {
|
||||
sessionToken: string;
|
||||
password: string;
|
||||
acceptTerms: boolean;
|
||||
marketingConsent: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Actor Output Types
|
||||
// ============================================================================
|
||||
|
||||
export type SendCodeOutput = SendVerificationCodeResponse;
|
||||
|
||||
export type VerifyCodeOutput = VerifyCodeResponse;
|
||||
|
||||
export type CompleteAccountOutput = AuthResponse;
|
||||
|
||||
export type MigrateAccountOutput = AuthResponse;
|
||||
|
||||
// ============================================================================
|
||||
// Machine Input (for useMachine)
|
||||
// ============================================================================
|
||||
|
||||
export interface GetStartedMachineInput {
|
||||
inline?: boolean | undefined;
|
||||
serviceContext?: ServiceContext | undefined;
|
||||
}
|
||||
@ -1,396 +0,0 @@
|
||||
/**
|
||||
* Get Started Store
|
||||
*
|
||||
* Manages state for the unified get-started flow (account creation).
|
||||
* For eligibility check without account creation, see the Internet service page.
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { logger } from "@/core/logger";
|
||||
import { getErrorMessage, withRetry } from "@/shared/utils";
|
||||
import type { AccountStatus, VerifyCodeResponse } from "@customer-portal/domain/get-started";
|
||||
import type { AuthResponse } from "@customer-portal/domain/auth";
|
||||
import * as api from "../api/get-started.api";
|
||||
|
||||
export type GetStartedStep =
|
||||
| "email"
|
||||
| "verification"
|
||||
| "account-status"
|
||||
| "complete-account"
|
||||
| "migrate-account"
|
||||
| "success";
|
||||
|
||||
/**
|
||||
* Service context for tracking which service flow the user came from
|
||||
* (e.g., SIM plan selection)
|
||||
*/
|
||||
export interface ServiceContext {
|
||||
type: "sim" | "internet" | "vpn" | null;
|
||||
planSku?: string | undefined;
|
||||
redirectTo?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address data format used in the get-started form
|
||||
*/
|
||||
export interface GetStartedAddress {
|
||||
address1?: string | undefined;
|
||||
address2?: string | undefined;
|
||||
city?: string | undefined;
|
||||
state?: string | undefined;
|
||||
postcode?: string | undefined;
|
||||
country?: string | undefined;
|
||||
countryCode?: string | undefined;
|
||||
}
|
||||
|
||||
export interface GetStartedFormData {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
address: GetStartedAddress;
|
||||
dateOfBirth: string;
|
||||
gender: "male" | "female" | "other" | "";
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
marketingConsent: boolean;
|
||||
}
|
||||
|
||||
export interface GetStartedState {
|
||||
// Current step
|
||||
step: GetStartedStep;
|
||||
|
||||
// Session
|
||||
sessionToken: string | null;
|
||||
emailVerified: boolean;
|
||||
|
||||
// Account status (after verification)
|
||||
accountStatus: AccountStatus | null;
|
||||
|
||||
// Form data
|
||||
formData: GetStartedFormData;
|
||||
|
||||
// Prefill data from existing account
|
||||
prefill: VerifyCodeResponse["prefill"] | null;
|
||||
|
||||
// Handoff token from eligibility check (for pre-filling data after OTP verification)
|
||||
handoffToken: string | null;
|
||||
|
||||
// Service context for tracking which service flow the user came from
|
||||
serviceContext: ServiceContext | null;
|
||||
|
||||
// Redirect URL (centralized for inline and full-page flows)
|
||||
redirectTo: string | null;
|
||||
|
||||
// Whether rendering inline (e.g., on service configure page)
|
||||
inline: boolean;
|
||||
|
||||
// Loading and error states
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Verification state
|
||||
codeSent: boolean;
|
||||
attemptsRemaining: number | null;
|
||||
|
||||
// Actions
|
||||
sendVerificationCode: (email: string) => Promise<boolean>;
|
||||
verifyCode: (code: string) => Promise<AccountStatus | null>;
|
||||
completeAccount: () => Promise<AuthResponse | null>;
|
||||
migrateWhmcsAccount: (overrides?: { password?: string }) => Promise<AuthResponse | null>;
|
||||
|
||||
// Navigation
|
||||
goToStep: (step: GetStartedStep) => void;
|
||||
goBack: () => void;
|
||||
|
||||
// Form updates
|
||||
updateFormData: (data: Partial<GetStartedFormData>) => void;
|
||||
|
||||
// Setters for handoff from eligibility check
|
||||
setAccountStatus: (status: AccountStatus) => void;
|
||||
setPrefill: (prefill: VerifyCodeResponse["prefill"]) => void;
|
||||
setSessionToken: (token: string | null) => void;
|
||||
setHandoffToken: (token: string | null) => void;
|
||||
setServiceContext: (context: ServiceContext | null) => void;
|
||||
setRedirectTo: (url: string | null) => void;
|
||||
setInline: (inline: boolean) => void;
|
||||
|
||||
// Reset
|
||||
reset: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const initialFormData: GetStartedFormData = {
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
phone: "",
|
||||
address: {},
|
||||
dateOfBirth: "",
|
||||
gender: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
acceptTerms: false,
|
||||
marketingConsent: false,
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
step: "email" as GetStartedStep,
|
||||
sessionToken: null,
|
||||
emailVerified: false,
|
||||
accountStatus: null,
|
||||
formData: initialFormData,
|
||||
prefill: null,
|
||||
handoffToken: null,
|
||||
serviceContext: null as ServiceContext | null,
|
||||
redirectTo: null as string | null,
|
||||
inline: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
codeSent: false,
|
||||
attemptsRemaining: null,
|
||||
};
|
||||
|
||||
export const useGetStartedStore = create<GetStartedState>()((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
sendVerificationCode: async (email: string) => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await withRetry(() => api.sendVerificationCode({ email }));
|
||||
|
||||
if (result.sent) {
|
||||
set({
|
||||
loading: false,
|
||||
codeSent: true,
|
||||
formData: { ...get().formData, email },
|
||||
step: "verification",
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
set({ loading: false, error: result.message });
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
logger.error("Failed to send verification code", { error: message });
|
||||
set({ loading: false, error: message });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
verifyCode: async (code: string) => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const { handoffToken } = get();
|
||||
const result = await api.verifyCode({
|
||||
email: get().formData.email,
|
||||
code,
|
||||
// Pass handoff token if available (from eligibility check flow)
|
||||
...(handoffToken && { handoffToken }),
|
||||
});
|
||||
|
||||
if (result.verified && result.sessionToken && result.accountStatus) {
|
||||
// Apply prefill data if available
|
||||
const prefill = result.prefill;
|
||||
const currentFormData = get().formData;
|
||||
|
||||
set({
|
||||
loading: false,
|
||||
emailVerified: true,
|
||||
sessionToken: result.sessionToken,
|
||||
accountStatus: result.accountStatus,
|
||||
prefill,
|
||||
formData: {
|
||||
...currentFormData,
|
||||
firstName: prefill?.firstName ?? currentFormData.firstName,
|
||||
lastName: prefill?.lastName ?? currentFormData.lastName,
|
||||
phone: prefill?.phone ?? currentFormData.phone,
|
||||
address: prefill?.address
|
||||
? {
|
||||
address1: prefill.address.address1,
|
||||
address2: prefill.address.address2,
|
||||
city: prefill.address.city,
|
||||
state: prefill.address.state,
|
||||
postcode: prefill.address.postcode,
|
||||
country: prefill.address.country,
|
||||
countryCode: prefill.address.countryCode,
|
||||
}
|
||||
: currentFormData.address,
|
||||
},
|
||||
step: "account-status",
|
||||
});
|
||||
|
||||
return result.accountStatus;
|
||||
} else {
|
||||
set({
|
||||
loading: false,
|
||||
error: result.error ?? "Verification failed",
|
||||
attemptsRemaining: result.attemptsRemaining ?? null,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
logger.error("Failed to verify code", { error: message });
|
||||
set({ loading: false, error: message });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
completeAccount: async () => {
|
||||
const { sessionToken, formData, accountStatus } = get();
|
||||
|
||||
if (!sessionToken) {
|
||||
set({ error: "Session expired. Please start over." });
|
||||
return null;
|
||||
}
|
||||
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
// For new customers, include name and address in request
|
||||
// For SF-only users, these come from session (via handoff token)
|
||||
const isNewCustomer = accountStatus === "new_customer";
|
||||
|
||||
const result = await withRetry(() =>
|
||||
api.completeAccount({
|
||||
sessionToken,
|
||||
password: formData.password,
|
||||
phone: formData.phone,
|
||||
dateOfBirth: formData.dateOfBirth,
|
||||
gender: formData.gender as "male" | "female" | "other",
|
||||
acceptTerms: formData.acceptTerms,
|
||||
marketingConsent: formData.marketingConsent,
|
||||
// Include name and address for new customers (or if provided)
|
||||
...(isNewCustomer || formData.firstName ? { firstName: formData.firstName } : {}),
|
||||
...(isNewCustomer || formData.lastName ? { lastName: formData.lastName } : {}),
|
||||
...(isNewCustomer || formData.address?.address1
|
||||
? {
|
||||
address: {
|
||||
address1: formData.address.address1 || "",
|
||||
address2: formData.address.address2,
|
||||
city: formData.address.city || "",
|
||||
state: formData.address.state || "",
|
||||
postcode: formData.address.postcode || "",
|
||||
country: formData.address.country || "JP",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
);
|
||||
|
||||
set({ loading: false, step: "success", sessionToken: null });
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
logger.error("Failed to complete account", { error: message });
|
||||
set({ loading: false, error: message });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
migrateWhmcsAccount: async (overrides?: { password?: string }) => {
|
||||
const { sessionToken, formData } = get();
|
||||
|
||||
if (!sessionToken) {
|
||||
set({ error: "Session expired. Please start over." });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use password from overrides if provided, otherwise fall back to formData
|
||||
// This avoids race condition between updateFormData and reading formData
|
||||
const passwordToUse = overrides?.password ?? formData.password;
|
||||
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await withRetry(() =>
|
||||
api.migrateWhmcsAccount({
|
||||
sessionToken,
|
||||
password: passwordToUse,
|
||||
acceptTerms: formData.acceptTerms,
|
||||
marketingConsent: formData.marketingConsent,
|
||||
})
|
||||
);
|
||||
|
||||
set({ loading: false, step: "success", sessionToken: null });
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
logger.error("Failed to migrate WHMCS account", { error: message });
|
||||
set({ loading: false, error: message });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
goToStep: (step: GetStartedStep) => {
|
||||
set({ step, error: null });
|
||||
},
|
||||
|
||||
goBack: () => {
|
||||
const { step } = get();
|
||||
// Both complete-account and migrate-account go back to account-status
|
||||
const stepOrder: GetStartedStep[] = [
|
||||
"email",
|
||||
"verification",
|
||||
"account-status",
|
||||
"complete-account",
|
||||
];
|
||||
// For migrate-account, go back to account-status directly
|
||||
if (step === "migrate-account") {
|
||||
set({ step: "account-status", error: null });
|
||||
return;
|
||||
}
|
||||
const currentIndex = stepOrder.indexOf(step);
|
||||
const prevStep = stepOrder[currentIndex - 1];
|
||||
if (currentIndex > 0 && prevStep) {
|
||||
set({ step: prevStep, error: null });
|
||||
}
|
||||
},
|
||||
|
||||
updateFormData: (data: Partial<GetStartedFormData>) => {
|
||||
set({ formData: { ...get().formData, ...data } });
|
||||
},
|
||||
|
||||
setAccountStatus: (status: AccountStatus) => {
|
||||
set({ accountStatus: status });
|
||||
},
|
||||
|
||||
setPrefill: (prefill: VerifyCodeResponse["prefill"]) => {
|
||||
set({ prefill });
|
||||
},
|
||||
|
||||
setSessionToken: (token: string | null) => {
|
||||
set({ sessionToken: token, emailVerified: token !== null });
|
||||
},
|
||||
|
||||
setHandoffToken: (token: string | null) => {
|
||||
set({ handoffToken: token });
|
||||
},
|
||||
|
||||
setServiceContext: (context: ServiceContext | null) => {
|
||||
set({ serviceContext: context });
|
||||
},
|
||||
|
||||
setRedirectTo: (url: string | null) => {
|
||||
set({ redirectTo: url });
|
||||
},
|
||||
|
||||
setInline: (inline: boolean) => {
|
||||
set({ inline });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null });
|
||||
},
|
||||
}));
|
||||
@ -22,11 +22,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { AuthLayout } from "@/components/templates/AuthLayout";
|
||||
import { GetStartedForm } from "../components";
|
||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||
import {
|
||||
useGetStartedStore,
|
||||
type GetStartedStep,
|
||||
type GetStartedAddress,
|
||||
} from "../stores/get-started.store";
|
||||
import { useGetStartedMachineRoot, GetStartedMachineProvider } from "../hooks/useGetStartedMachine";
|
||||
import type { AccountStatus, VerifyCodeResponse } from "@customer-portal/domain/get-started";
|
||||
|
||||
// Session data staleness threshold (15 minutes)
|
||||
@ -81,17 +77,11 @@ function parsePrefillData(prefillRaw: string | null): VerifyCodeResponse["prefil
|
||||
}
|
||||
}
|
||||
|
||||
type StepName = string;
|
||||
|
||||
export function GetStartedView(): React.JSX.Element {
|
||||
const searchParams = useSearchParams();
|
||||
const {
|
||||
updateFormData,
|
||||
goToStep,
|
||||
setHandoffToken,
|
||||
setSessionToken,
|
||||
setAccountStatus,
|
||||
setPrefill,
|
||||
setRedirectTo,
|
||||
} = useGetStartedStore();
|
||||
const { state, send } = useGetStartedMachineRoot();
|
||||
const [meta, setMeta] = useState({
|
||||
title: "Get Started",
|
||||
subtitle: "Enter your email to begin",
|
||||
@ -111,8 +101,8 @@ export function GetStartedView(): React.JSX.Element {
|
||||
function handleRedirectParam(): void {
|
||||
const redirectParam = searchParams.get("redirect");
|
||||
if (redirectParam) {
|
||||
const safeRedirect = getSafeRedirect(redirectParam, "/account/dashboard");
|
||||
setRedirectTo(safeRedirect);
|
||||
const safeRedirect = getSafeRedirect(redirectParam, "/account");
|
||||
send({ type: "SET_REDIRECT", redirectTo: safeRedirect });
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,26 +117,23 @@ export function GetStartedView(): React.JSX.Element {
|
||||
return false;
|
||||
}
|
||||
|
||||
setSessionToken(sessionData.sessionToken);
|
||||
if (sessionData.accountStatus) {
|
||||
setAccountStatus(sessionData.accountStatus as AccountStatus);
|
||||
}
|
||||
|
||||
const prefill = parsePrefillData(sessionData.prefillRaw);
|
||||
if (prefill) {
|
||||
setPrefill(prefill);
|
||||
updateFormData({
|
||||
email: sessionData.email || prefill.email || "",
|
||||
firstName: prefill.firstName || "",
|
||||
lastName: prefill.lastName || "",
|
||||
phone: prefill.phone || "",
|
||||
address: (prefill.address as GetStartedAddress) || {},
|
||||
|
||||
// If we have email in session data, update form data
|
||||
if (sessionData.email) {
|
||||
send({
|
||||
type: "UPDATE_FORM_DATA",
|
||||
data: { email: sessionData.email },
|
||||
});
|
||||
} else if (sessionData.email) {
|
||||
updateFormData({ email: sessionData.email });
|
||||
}
|
||||
|
||||
goToStep("complete-account");
|
||||
send({
|
||||
type: "RESTORE_VERIFIED_SESSION",
|
||||
sessionToken: sessionData.sessionToken,
|
||||
accountStatus: (sessionData.accountStatus ?? "new_customer") as AccountStatus,
|
||||
prefill: prefill ?? undefined,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -163,37 +150,32 @@ export function GetStartedView(): React.JSX.Element {
|
||||
const isHandoff = handoffParam === "true" || !!storedHandoffToken;
|
||||
|
||||
if (email && isHandoff) {
|
||||
updateFormData({ email });
|
||||
if (storedHandoffToken) {
|
||||
setHandoffToken(storedHandoffToken);
|
||||
}
|
||||
send({
|
||||
type: "SET_EMAIL",
|
||||
email,
|
||||
handoffToken: storedHandoffToken ?? undefined,
|
||||
});
|
||||
} else if (email) {
|
||||
updateFormData({ email });
|
||||
send({ type: "UPDATE_FORM_DATA", data: { email } });
|
||||
send({ type: "START" });
|
||||
}
|
||||
}
|
||||
}, [
|
||||
initialized,
|
||||
searchParams,
|
||||
updateFormData,
|
||||
setHandoffToken,
|
||||
goToStep,
|
||||
setSessionToken,
|
||||
setAccountStatus,
|
||||
setPrefill,
|
||||
setRedirectTo,
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialized]);
|
||||
|
||||
const handleStepChange = useCallback(
|
||||
(_step: GetStartedStep, stepMeta: { title: string; subtitle: string }) => {
|
||||
(_step: StepName, stepMeta: { title: string; subtitle: string }) => {
|
||||
setMeta(stepMeta);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthLayout title={meta.title} subtitle={meta.subtitle} wide>
|
||||
<GetStartedForm onStepChange={handleStepChange} />
|
||||
</AuthLayout>
|
||||
<GetStartedMachineProvider value={{ state, send }}>
|
||||
<AuthLayout title={meta.title} subtitle={meta.subtitle} wide>
|
||||
<GetStartedForm onStepChange={handleStepChange} />
|
||||
</AuthLayout>
|
||||
</GetStartedMachineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
24
packages/domain/common/providers/whmcs-utils/coerce.ts
Normal file
24
packages/domain/common/providers/whmcs-utils/coerce.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* WHMCS Coercion Utilities (domain-internal)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Coerce boolean-like values from WHMCS into actual booleans.
|
||||
*
|
||||
* Handles all shapes that WHMCS returns for boolean fields:
|
||||
* - boolean → pass-through
|
||||
* - number → 1 is true, everything else false
|
||||
* - string → "1", "true", "yes", "on" are true (case-insensitive)
|
||||
* - null / undefined → false
|
||||
*/
|
||||
export function coerceBoolean(value: boolean | number | string | null | undefined): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value === 1;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
71
pnpm-lock.yaml
generated
71
pnpm-lock.yaml
generated
@ -215,6 +215,9 @@ importers:
|
||||
"@tanstack/react-query":
|
||||
specifier: ^5.90.20
|
||||
version: 5.90.20(react@19.2.4)
|
||||
"@xstate/react":
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0(@types/react@19.2.10)(react@19.2.4)(xstate@5.28.0)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@ -242,12 +245,15 @@ importers:
|
||||
world-countries:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0
|
||||
xstate:
|
||||
specifier: ^5.28.0
|
||||
version: 5.28.0
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
zustand:
|
||||
specifier: ^5.0.11
|
||||
version: 5.0.11(@types/react@19.2.10)(react@19.2.4)
|
||||
version: 5.0.11(@types/react@19.2.10)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||
devDependencies:
|
||||
"@next/bundle-analyzer":
|
||||
specifier: ^16.1.6
|
||||
@ -3366,6 +3372,18 @@ packages:
|
||||
}
|
||||
engines: { node: ^14.14.0 || >=16.0.0 }
|
||||
|
||||
"@xstate/react@6.0.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-xXlLpFJxqLhhmecAXclBECgk+B4zYSrDTl8hTfPZBogkn82OHKbm9zJxox3Z/YXoOhAQhKFTRLMYGdlbhc6T9A==,
|
||||
}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
xstate: ^5.20.0
|
||||
peerDependenciesMeta:
|
||||
xstate:
|
||||
optional: true
|
||||
|
||||
"@xtuc/ieee754@1.2.0":
|
||||
resolution:
|
||||
{
|
||||
@ -8158,6 +8176,26 @@ packages:
|
||||
integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==,
|
||||
}
|
||||
|
||||
use-isomorphic-layout-effect@1.2.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@types/react": "*"
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==,
|
||||
}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution:
|
||||
{
|
||||
@ -8354,6 +8392,12 @@ packages:
|
||||
}
|
||||
engines: { node: ">=4.0" }
|
||||
|
||||
xstate@5.28.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-Iaqq6ZrUzqeUtA3hC5LQKZfR8ZLzEFTImMHJM3jWEdVvXWdKvvVLXZEiNQWm3SCA9ZbEou/n5rcsna1wb9t28A==,
|
||||
}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution:
|
||||
{
|
||||
@ -10333,6 +10377,16 @@ snapshots:
|
||||
arch: 3.0.0
|
||||
optional: true
|
||||
|
||||
"@xstate/react@6.0.0(@types/react@19.2.10)(react@19.2.4)(xstate@5.28.0)":
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.10)(react@19.2.4)
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
xstate: 5.28.0
|
||||
transitivePeerDependencies:
|
||||
- "@types/react"
|
||||
|
||||
"@xtuc/ieee754@1.2.0": {}
|
||||
|
||||
"@xtuc/long@4.2.2": {}
|
||||
@ -13187,6 +13241,16 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
use-isomorphic-layout-effect@1.2.1(@types/react@19.2.10)(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
"@types/react": 19.2.10
|
||||
|
||||
use-sync-external-store@1.6.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@11.1.0: {}
|
||||
@ -13318,6 +13382,8 @@ snapshots:
|
||||
|
||||
xmlbuilder@11.0.1: {}
|
||||
|
||||
xstate@5.28.0: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
@ -13359,7 +13425,8 @@ snapshots:
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
zustand@5.0.11(@types/react@19.2.10)(react@19.2.4):
|
||||
zustand@5.0.11(@types/react@19.2.10)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
||||
optionalDependencies:
|
||||
"@types/react": 19.2.10
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user