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