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:
barsa 2026-02-24 14:37:23 +09:00
parent 912582caf7
commit 9941250cb5
39 changed files with 3000 additions and 2444 deletions

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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.",
};
}
}
}

View File

@ -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)"
);
}
}
}

View File

@ -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 };
}
}

View File

@ -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),
});
}
}
}

View File

@ -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 };
}
}

View File

@ -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"
);
}
}
}

View File

@ -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");
}
}

View File

@ -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 };
}
}

View 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";

View File

@ -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 };
}
}

View File

@ -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"
);
}
}
}

View File

@ -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 }),
};
}
}

View File

@ -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");
}
}

View File

@ -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.",
};
}

View File

@ -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
*

View File

@ -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

View File

@ -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"
},

View File

@ -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>
);
}
}

View File

@ -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&apos;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&apos;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&apos;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>
);

View File

@ -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"
>

View File

@ -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}

View File

@ -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"
>

View File

@ -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">

View File

@ -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"
>

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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";

View File

@ -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,
});
}
);

View File

@ -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;

View File

@ -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;
}

View File

@ -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 });
},
}));

View File

@ -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>
);
}

View 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
View File

@ -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)