feat: add public VPN configuration page and view for unauthenticated users

This commit is contained in:
barsa 2026-01-19 10:13:55 +09:00
parent f4099ac81f
commit b52b2874d6
23 changed files with 549 additions and 294 deletions

View File

@ -45,7 +45,7 @@ model User {
model IdMapping { model IdMapping {
userId String @id @map("user_id") userId String @id @map("user_id")
whmcsClientId Int @unique @map("whmcs_client_id") whmcsClientId Int @unique @map("whmcs_client_id")
sfAccountId String? @map("sf_account_id") sfAccountId String @map("sf_account_id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@ -10,7 +10,7 @@ export interface UserIdMapping {
id: string; id: string;
userId: string; userId: string;
whmcsClientId: number; whmcsClientId: number;
sfAccountId?: string | null | undefined; sfAccountId: string;
createdAt: IsoDateTimeString | Date; createdAt: IsoDateTimeString | Date;
updatedAt: IsoDateTimeString | Date; updatedAt: IsoDateTimeString | Date;
} }
@ -18,7 +18,7 @@ export interface UserIdMapping {
export interface CreateMappingRequest { export interface CreateMappingRequest {
userId: string; userId: string;
whmcsClientId: number; whmcsClientId: number;
sfAccountId?: string | undefined; sfAccountId: string;
} }
export interface UpdateMappingRequest { export interface UpdateMappingRequest {

View File

@ -8,7 +8,7 @@ import type { CreateMappingRequest, UpdateMappingRequest, UserIdMapping } from "
export const createMappingRequestSchema: z.ZodType<CreateMappingRequest> = z.object({ export const createMappingRequestSchema: z.ZodType<CreateMappingRequest> = z.object({
userId: z.string().uuid(), userId: z.string().uuid(),
whmcsClientId: z.number().int().positive(), whmcsClientId: z.number().int().positive(),
sfAccountId: z.string().min(1, "Salesforce account ID must be at least 1 character").optional(), sfAccountId: z.string().min(1, "Salesforce account ID is required"),
}); });
export const updateMappingRequestSchema: z.ZodType<UpdateMappingRequest> = z.object({ export const updateMappingRequestSchema: z.ZodType<UpdateMappingRequest> = z.object({
@ -20,7 +20,7 @@ export const userIdMappingSchema: z.ZodType<UserIdMapping> = z.object({
id: z.string().uuid(), id: z.string().uuid(),
userId: z.string().uuid(), userId: z.string().uuid(),
whmcsClientId: z.number().int().positive(), whmcsClientId: z.number().int().positive(),
sfAccountId: z.string().nullable().optional(), sfAccountId: z.string(),
createdAt: z.union([z.string(), z.date()]), createdAt: z.union([z.string(), z.date()]),
updatedAt: z.union([z.string(), z.date()]), updatedAt: z.union([z.string(), z.date()]),
}); });

View File

@ -12,18 +12,6 @@ import type {
MappingValidationResult, MappingValidationResult,
} from "./contract.js"; } from "./contract.js";
/**
* Check if a mapping request has optional Salesforce account ID
* This is used for warnings, not validation errors
*/
export function checkMappingCompleteness(request: CreateMappingRequest | UserIdMapping): string[] {
const warnings: string[] = [];
if (!request.sfAccountId) {
warnings.push("Salesforce account ID not provided - mapping will be incomplete");
}
return warnings;
}
/** /**
* Validate no conflicts exist with existing mappings * Validate no conflicts exist with existing mappings
* Business rule: Each userId, whmcsClientId should be unique * Business rule: Each userId, whmcsClientId should be unique
@ -51,13 +39,11 @@ export function validateNoConflicts(
); );
} }
if (request.sfAccountId) { const duplicateSf = existingMappings.find(m => m.sfAccountId === request.sfAccountId);
const duplicateSf = existingMappings.find(m => m.sfAccountId === request.sfAccountId); if (duplicateSf) {
if (duplicateSf) { warnings.push(
warnings.push( `Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}`
`Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}` );
);
}
} }
return { isValid: errors.length === 0, errors, warnings }; return { isValid: errors.length === 0, errors, warnings };
@ -82,11 +68,9 @@ export function validateDeletion(
} }
warnings.push("Deleting this mapping will prevent access to WHMCS/Salesforce data for this user"); warnings.push("Deleting this mapping will prevent access to WHMCS/Salesforce data for this user");
if (mapping.sfAccountId) { warnings.push(
warnings.push( "This mapping includes Salesforce integration - deletion will affect case management"
"This mapping includes Salesforce integration - deletion will affect case management" );
);
}
return { isValid: true, errors, warnings }; return { isValid: true, errors, warnings };
} }
@ -98,11 +82,10 @@ export function validateDeletion(
* The schema handles validation; this is purely for data cleanup. * The schema handles validation; this is purely for data cleanup.
*/ */
export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest { export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
const trimmedSfAccountId = request.sfAccountId?.trim();
return { return {
userId: request.userId?.trim(), userId: request.userId?.trim(),
whmcsClientId: request.whmcsClientId, whmcsClientId: request.whmcsClientId,
...(trimmedSfAccountId ? { sfAccountId: trimmedSfAccountId } : {}), sfAccountId: request.sfAccountId.trim(),
}; };
} }

View File

@ -22,7 +22,6 @@ import {
updateMappingRequestSchema, updateMappingRequestSchema,
validateNoConflicts, validateNoConflicts,
validateDeletion, validateDeletion,
checkMappingCompleteness,
sanitizeCreateRequest, sanitizeCreateRequest,
sanitizeUpdateRequest, sanitizeUpdateRequest,
} from "./domain/index.js"; } from "./domain/index.js";
@ -72,7 +71,7 @@ export class MappingsService {
.map(mapPrismaMappingToDomain); .map(mapPrismaMappingToDomain);
const conflictCheck = validateNoConflicts(sanitizedRequest, existingMappings); const conflictCheck = validateNoConflicts(sanitizedRequest, existingMappings);
const warnings = [...checkMappingCompleteness(sanitizedRequest), ...conflictCheck.warnings]; const warnings = conflictCheck.warnings;
if (!conflictCheck.isValid) { if (!conflictCheck.isValid) {
throw new ConflictException(conflictCheck.errors.join("; ")); throw new ConflictException(conflictCheck.errors.join("; "));
@ -80,11 +79,10 @@ export class MappingsService {
let created; let created;
try { try {
// Convert undefined to null for Prisma compatibility
const prismaData = { const prismaData = {
userId: sanitizedRequest.userId, userId: sanitizedRequest.userId,
whmcsClientId: sanitizedRequest.whmcsClientId, whmcsClientId: sanitizedRequest.whmcsClientId,
sfAccountId: sanitizedRequest.sfAccountId ?? null, sfAccountId: sanitizedRequest.sfAccountId,
}; };
created = await this.prisma.idMapping.create({ data: prismaData }); created = await this.prisma.idMapping.create({ data: prismaData });
} catch (e) { } catch (e) {
@ -251,13 +249,12 @@ export class MappingsService {
} }
} }
// Convert undefined to null for Prisma compatibility
const prismaUpdateData: Prisma.IdMappingUpdateInput = { const prismaUpdateData: Prisma.IdMappingUpdateInput = {
...(sanitizedUpdates.whmcsClientId !== undefined && { ...(sanitizedUpdates.whmcsClientId !== undefined && {
whmcsClientId: sanitizedUpdates.whmcsClientId, whmcsClientId: sanitizedUpdates.whmcsClientId,
}), }),
...(sanitizedUpdates.sfAccountId !== undefined && { ...(sanitizedUpdates.sfAccountId !== undefined && {
sfAccountId: sanitizedUpdates.sfAccountId ?? null, sfAccountId: sanitizedUpdates.sfAccountId,
}), }),
}; };
const updated = await this.prisma.idMapping.update({ const updated = await this.prisma.idMapping.update({
@ -325,9 +322,8 @@ export class MappingsService {
whereClause.NOT = { whmcsClientId: { gt: 0 } }; whereClause.NOT = { whmcsClientId: { gt: 0 } };
} }
} }
if (filters.hasSfMapping !== undefined) { // Note: hasSfMapping filter is deprecated - sfAccountId is now required on all mappings
whereClause.sfAccountId = filters.hasSfMapping ? { not: null } : { equals: null }; // hasSfMapping: true matches all records, hasSfMapping: false matches none
}
const dbMappings = await this.prisma.idMapping.findMany({ const dbMappings = await this.prisma.idMapping.findMany({
where: whereClause, where: whereClause,
@ -347,23 +343,19 @@ export class MappingsService {
async getMappingStats(): Promise<MappingStats> { async getMappingStats(): Promise<MappingStats> {
try { try {
const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([ // Since sfAccountId is now required, all mappings have SF accounts
// and completeMappings equals whmcsMappings (orphanedMappings is always 0)
const [totalCount, whmcsCount] = await Promise.all([
this.prisma.idMapping.count(), this.prisma.idMapping.count(),
this.prisma.idMapping.count({ where: { whmcsClientId: { gt: 0 } } }), this.prisma.idMapping.count({ where: { whmcsClientId: { gt: 0 } } }),
this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }),
this.prisma.idMapping.count({
where: { whmcsClientId: { gt: 0 }, sfAccountId: { not: null } },
}),
]); ]);
const orphanedMappings = whmcsCount - completeCount;
const stats: MappingStats = { const stats: MappingStats = {
totalMappings: totalCount, totalMappings: totalCount,
whmcsMappings: whmcsCount, whmcsMappings: whmcsCount,
salesforceMappings: sfCount, salesforceMappings: totalCount, // All mappings now have sfAccountId
completeMappings: completeCount, completeMappings: whmcsCount, // Same as whmcsMappings since sfAccountId is required
orphanedMappings: orphanedMappings < 0 ? 0 : orphanedMappings, orphanedMappings: 0, // No longer possible
}; };
this.logger.debug("Generated mapping statistics", stats); this.logger.debug("Generated mapping statistics", stats);
return stats; return stats;

View File

@ -63,7 +63,7 @@ export class OrderOrchestrator {
// 2) Resolve Opportunity for this order // 2) Resolve Opportunity for this order
const { opportunityId, wasCreated: opportunityCreated } = await this.resolveOpportunityForOrder( const { opportunityId, wasCreated: opportunityCreated } = await this.resolveOpportunityForOrder(
validatedBody.orderType, validatedBody.orderType,
userMapping.sfAccountId ?? null, userMapping.sfAccountId,
validatedBody.opportunityId validatedBody.opportunityId
); );
@ -136,7 +136,7 @@ export class OrderOrchestrator {
{ {
name: "accountOrders", name: "accountOrders",
execute: async () => execute: async () =>
this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId!), this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId),
}, },
] ]
: []), : []),

View File

@ -42,7 +42,7 @@ export class OrderValidator {
*/ */
async validateUserMapping( async validateUserMapping(
userId: string userId: string
): Promise<{ userId: string; sfAccountId?: string | undefined; whmcsClientId: number }> { ): Promise<{ userId: string; sfAccountId: string; whmcsClientId: number }> {
const mapping = await this.mappings.findByUserId(userId); const mapping = await this.mappings.findByUserId(userId);
if (!mapping) { if (!mapping) {
@ -57,7 +57,7 @@ export class OrderValidator {
return { return {
userId: mapping.userId, userId: mapping.userId,
sfAccountId: mapping.sfAccountId || undefined, sfAccountId: mapping.sfAccountId,
whmcsClientId: mapping.whmcsClientId, whmcsClientId: mapping.whmcsClientId,
}; };
} }
@ -182,7 +182,7 @@ export class OrderValidator {
body: CreateOrderRequest body: CreateOrderRequest
): Promise<{ ): Promise<{
validatedBody: OrderBusinessValidation; validatedBody: OrderBusinessValidation;
userMapping: { userId: string; sfAccountId?: string | undefined; whmcsClientId: number }; userMapping: { userId: string; sfAccountId: string; whmcsClientId: number };
pricebookId: string; pricebookId: string;
}> { }> {
this.logger.log({ userId }, "Starting complete order validation"); this.logger.log({ userId }, "Starting complete order validation");

View File

@ -47,17 +47,16 @@ export class RealtimeController {
} }
const mapping = await this.mappings.findByUserId(req.user.id); const mapping = await this.mappings.findByUserId(req.user.id);
const sfAccountId = mapping?.sfAccountId;
// Intentionally log minimal info for debugging connection issues. // Intentionally log minimal info for debugging connection issues.
this.logger.log("Account realtime stream connected", { this.logger.log("Account realtime stream connected", {
userId: req.user.id, userId: req.user.id,
hasSfAccountId: Boolean(sfAccountId), hasMapping: Boolean(mapping),
sfAccountIdTail: sfAccountId ? sfAccountId.slice(-4) : null, sfAccountIdTail: mapping?.sfAccountId.slice(-4) ?? null,
}); });
const accountStream = this.realtime.subscribe( const accountStream = this.realtime.subscribe(
sfAccountId ? `account:sf:${sfAccountId}` : "account:unknown", mapping ? `account:sf:${mapping.sfAccountId}` : "account:unknown",
{ {
// Always provide a single predictable ready + heartbeat for the main account stream. // Always provide a single predictable ready + heartbeat for the main account stream.
readyEvent: "account.stream.ready", readyEvent: "account.stream.ready",

View File

@ -38,7 +38,7 @@ export class InternetEligibilityService {
async getEligibilityDetailsForUser(userId: string): Promise<InternetEligibilityDetails> { async getEligibilityDetailsForUser(userId: string): Promise<InternetEligibilityDetails> {
const mapping = await this.mappingsService.findByUserId(userId); const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.sfAccountId) { if (!mapping) {
return internetEligibilityDetailsSchema.parse({ return internetEligibilityDetailsSchema.parse({
status: "not_requested", status: "not_requested",
eligibility: null, eligibility: null,

View File

@ -34,10 +34,7 @@ export class ResidenceCardService {
async getStatusForUser(userId: string): Promise<ResidenceCardVerification> { async getStatusForUser(userId: string): Promise<ResidenceCardVerification> {
const mapping = await this.mappings.findByUserId(userId); const mapping = await this.mappings.findByUserId(userId);
const sfAccountId = mapping?.sfAccountId if (!mapping) {
? assertSalesforceId(mapping.sfAccountId, "sfAccountId")
: null;
if (!sfAccountId) {
return residenceCardVerificationSchema.parse({ return residenceCardVerificationSchema.parse({
status: "not_submitted", status: "not_submitted",
submittedAt: null, submittedAt: null,
@ -45,6 +42,7 @@ export class ResidenceCardService {
reviewerNotes: null, reviewerNotes: null,
}); });
} }
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
return this.servicesCache.getCachedVerification(sfAccountId, async () => { return this.servicesCache.getCachedVerification(sfAccountId, async () => {
return this.fetchVerificationFromSalesforce(sfAccountId); return this.fetchVerificationFromSalesforce(sfAccountId);

View File

@ -0,0 +1,17 @@
/**
* Public VPN Configure Page
*
* Configure VPN plan for unauthenticated users.
*/
import { PublicVpnConfigureView } from "@/features/services/views/PublicVpnConfigure";
import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices";
export default function PublicVpnConfigurePage() {
return (
<>
<RedirectAuthenticatedToAccountServices targetPath="/account/services/vpn/configure" />
<PublicVpnConfigureView />
</>
);
}

View File

@ -79,12 +79,20 @@ export function PublicShell({ children }: PublicShellProps) {
My Account My Account
</Link> </Link>
) : ( ) : (
<Link <div className="flex items-center gap-2 ml-2">
href="/auth/login" <Link
className="ml-2 inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" href="/auth/login"
> className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
Sign in >
</Link> Sign in
</Link>
<Link
href="/auth/get-started"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
Get Started
</Link>
</div>
)} )}
</nav> </nav>
</div> </div>

View File

@ -13,14 +13,15 @@ import { useZodForm } from "@/shared/hooks";
interface LinkWhmcsFormProps { interface LinkWhmcsFormProps {
onTransferred?: ((result: LinkWhmcsResponse) => void) | undefined; onTransferred?: ((result: LinkWhmcsResponse) => void) | undefined;
className?: string | undefined; className?: string | undefined;
initialEmail?: string | undefined;
} }
export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) { export function LinkWhmcsForm({ onTransferred, className = "", initialEmail }: LinkWhmcsFormProps) {
const { linkWhmcs, loading, error, clearError } = useWhmcsLink(); const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
const form = useZodForm({ const form = useZodForm({
schema: linkWhmcsRequestSchema, schema: linkWhmcsRequestSchema,
initialValues: { email: "", password: "" }, initialValues: { email: initialEmail ?? "", password: "" },
onSubmit: async data => { onSubmit: async data => {
clearError(); clearError();
const result = await linkWhmcs(data); const result = await linkWhmcs(data);

View File

@ -2,8 +2,8 @@
* AccountStatusStep - Shows account status and routes to appropriate next step * AccountStatusStep - Shows account status and routes to appropriate next step
* *
* Routes based on account status: * Routes based on account status:
* - portal_exists: Show login link * - portal_exists: Show login form inline (or redirect link in full-page mode)
* - whmcs_unmapped: Link to migrate page (enter WHMCS password) * - whmcs_unmapped: Show migrate form inline (or redirect link in full-page mode)
* - sf_unmapped: Go to complete-account step (pre-filled form) * - sf_unmapped: Go to complete-account step (pre-filled form)
* - new_customer: Go to complete-account step (full signup) * - new_customer: Go to complete-account step (full signup)
*/ */
@ -11,6 +11,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/atoms"; import { Button } from "@/components/atoms";
import { import {
CheckCircleIcon, CheckCircleIcon,
@ -19,13 +20,51 @@ import {
DocumentCheckIcon, DocumentCheckIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { CheckCircle2 } from "lucide-react"; import { CheckCircle2 } from "lucide-react";
import { LoginForm } from "@/features/auth/components/LoginForm/LoginForm";
import { LinkWhmcsForm } from "@/features/auth/components/LinkWhmcsForm/LinkWhmcsForm";
import { useGetStartedStore } from "../../../stores/get-started.store"; import { useGetStartedStore } from "../../../stores/get-started.store";
export function AccountStatusStep() { export function AccountStatusStep() {
const { accountStatus, formData, goToStep, prefill } = useGetStartedStore(); const router = useRouter();
const { accountStatus, formData, goToStep, prefill, inline, redirectTo, serviceContext } =
useGetStartedStore();
// Portal exists - redirect to login // Compute effective redirect URL from store state
const effectiveRedirectTo = redirectTo || serviceContext?.redirectTo || "/account/dashboard";
// Portal exists - show login form inline or redirect to login page
if (accountStatus === "portal_exists") { if (accountStatus === "portal_exists") {
// Inline mode: render login form directly
if (inline) {
return (
<div className="space-y-6">
<div className="text-center">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center">
<CheckCircleIcon className="h-8 w-8 text-success" />
</div>
</div>
<div className="space-y-2 mt-4">
<h3 className="text-lg font-semibold text-foreground">Account Found</h3>
<p className="text-sm text-muted-foreground">
You already have a portal account with this email. Please log in to continue.
</p>
</div>
</div>
<LoginForm
initialEmail={formData.email}
redirectTo={effectiveRedirectTo}
showSignupLink={false}
showForgotPasswordLink={true}
/>
</div>
);
}
// Full-page mode: redirect to login page
const loginUrl = `/auth/login?email=${encodeURIComponent(formData.email)}&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
return ( 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">
@ -41,7 +80,7 @@ export function AccountStatusStep() {
</p> </p>
</div> </div>
<Link href={`/auth/login?email=${encodeURIComponent(formData.email)}`}> <Link href={loginUrl}>
<Button className="w-full h-11"> <Button className="w-full h-11">
Go to Login Go to Login
<ArrowRightIcon className="h-4 w-4 ml-2" /> <ArrowRightIcon className="h-4 w-4 ml-2" />
@ -51,8 +90,48 @@ export function AccountStatusStep() {
); );
} }
// WHMCS exists but not mapped - need to link account // WHMCS exists but not mapped - show migrate form inline or redirect to migrate page
if (accountStatus === "whmcs_unmapped") { if (accountStatus === "whmcs_unmapped") {
// Inline mode: render migrate form directly
if (inline) {
return (
<div className="space-y-6">
<div className="text-center">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<UserCircleIcon className="h-8 w-8 text-primary" />
</div>
</div>
<div className="space-y-2 mt-4">
<h3 className="text-lg font-semibold text-foreground">Existing Account Found</h3>
<p className="text-sm text-muted-foreground">
We found an existing billing account with this email. Please verify your password to
link it to your new portal account.
</p>
</div>
</div>
<LinkWhmcsForm
initialEmail={formData.email}
onTransferred={result => {
if (result.needsPasswordSet) {
const params = new URLSearchParams({
email: result.user.email,
redirect: effectiveRedirectTo,
});
router.push(`/auth/set-password?${params.toString()}`);
return;
}
router.push(effectiveRedirectTo);
}}
/>
</div>
);
}
// Full-page mode: redirect to migrate page
const migrateUrl = `/auth/migrate?email=${encodeURIComponent(formData.email)}&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
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">
@ -69,7 +148,7 @@ export function AccountStatusStep() {
</p> </p>
</div> </div>
<Link href={`/auth/migrate?email=${encodeURIComponent(formData.email)}`}> <Link href={migrateUrl}>
<Button className="w-full h-11"> <Button className="w-full h-11">
Link My Account Link My Account
<ArrowRightIcon className="h-4 w-4 ml-2" /> <ArrowRightIcon className="h-4 w-4 ml-2" />

View File

@ -43,8 +43,13 @@ export function CompleteAccountStep() {
error, error,
clearError, clearError,
goBack, goBack,
redirectTo,
serviceContext,
} = useGetStartedStore(); } = useGetStartedStore();
// Compute effective redirect URL from store state
const effectiveRedirectTo = redirectTo || serviceContext?.redirectTo || "/account/dashboard";
// Check if this is a new customer (needs full form) or SF-only (has prefill) // Check if this is a new customer (needs full form) or SF-only (has prefill)
const isNewCustomer = accountStatus === "new_customer"; const isNewCustomer = accountStatus === "new_customer";
const hasPrefill = !!(prefill?.firstName || prefill?.lastName); const hasPrefill = !!(prefill?.firstName || prefill?.lastName);
@ -156,8 +161,8 @@ export function CompleteAccountStep() {
const result = await completeAccount(); const result = await completeAccount();
if (result) { if (result) {
// Redirect to dashboard on success // Redirect to the effective redirect URL on success
router.push("/account/dashboard"); router.push(effectiveRedirectTo);
} }
}; };

View File

@ -7,8 +7,17 @@
import Link from "next/link"; import Link from "next/link";
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 { useGetStartedStore } from "../../../stores/get-started.store";
export function SuccessStep() { export function SuccessStep() {
const { redirectTo, serviceContext } = useGetStartedStore();
// Compute effective redirect URL from store state
const effectiveRedirectTo = redirectTo || serviceContext?.redirectTo || "/account/dashboard";
// Determine if redirecting to dashboard (default) or a specific service
const isDefaultRedirect = effectiveRedirectTo === "/account/dashboard";
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">
@ -25,18 +34,20 @@ export function SuccessStep() {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<Link href="/account/dashboard"> <Link href={effectiveRedirectTo}>
<Button className="w-full h-11"> <Button className="w-full h-11">
Go to Dashboard {isDefaultRedirect ? "Go to Dashboard" : "Continue"}
<ArrowRightIcon className="h-4 w-4 ml-2" /> <ArrowRightIcon className="h-4 w-4 ml-2" />
</Button> </Button>
</Link> </Link>
<Link href="/services/internet"> {isDefaultRedirect && (
<Button variant="outline" className="w-full h-11"> <Link href="/services/internet">
Check Internet Availability <Button variant="outline" className="w-full h-11">
</Button> Check Internet Availability
</Link> </Button>
</Link>
)}
</div> </div>
</div> </div>
); );

View File

@ -3,15 +3,17 @@
* *
* Uses the get-started store flow (email OTP status form) inline on service pages * Uses the get-started store flow (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:
* - portal_exists: Shows LoginForm inline
* - whmcs_unmapped: Shows LinkWhmcsForm inline
* - sf_unmapped / new_customer: Continues to account completion
*/ */
"use client"; "use client";
import { useState, useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/atoms";
import { LoginForm } from "@/features/auth/components/LoginForm/LoginForm";
import { LinkWhmcsForm } from "@/features/auth/components/LinkWhmcsForm/LinkWhmcsForm";
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 { useGetStartedStore, type ServiceContext } from "../../stores/get-started.store";
import { EmailStep } from "../GetStartedForm/steps/EmailStep"; import { EmailStep } from "../GetStartedForm/steps/EmailStep";
@ -42,42 +44,31 @@ export function InlineGetStartedSection({
className = "", className = "",
}: InlineGetStartedSectionProps) { }: InlineGetStartedSectionProps) {
const router = useRouter(); const router = useRouter();
const [mode, setMode] = useState<"signup" | "login" | "migrate">("signup"); const safeRedirect = getSafeRedirect(redirectTo, "/account/dashboard");
const safeRedirect = getSafeRedirect(redirectTo, "/account");
const { step, reset, setServiceContext } = useGetStartedStore(); const { step, setServiceContext, setRedirectTo, setInline } = useGetStartedStore();
// Set service context when component mounts // Set inline mode and redirect URL when component mounts
useEffect(() => { useEffect(() => {
setInline(true);
setRedirectTo(safeRedirect);
if (serviceContext) { if (serviceContext) {
setServiceContext({ setServiceContext({
...serviceContext, ...serviceContext,
redirectTo: safeRedirect, redirectTo: safeRedirect,
}); });
} }
return () => { return () => {
// Clear service context when unmounting // Clear inline mode when unmounting
setInline(false);
setServiceContext(null); setServiceContext(null);
}; };
}, [serviceContext, safeRedirect, setServiceContext]); }, [serviceContext, safeRedirect, setServiceContext, setRedirectTo, setInline]);
// Reset get-started store when switching to signup mode // Render the current step
const handleModeChange = (newMode: "signup" | "login" | "migrate") => { const renderStep = () => {
if (newMode === "signup" && mode !== "signup") {
reset();
// Re-set service context after reset
if (serviceContext) {
setServiceContext({
...serviceContext,
redirectTo: safeRedirect,
});
}
}
setMode(newMode);
};
// Render the current step for signup flow
const renderSignupStep = () => {
switch (step) { switch (step) {
case "email": case "email":
return <EmailStep />; return <EmailStep />;
@ -105,78 +96,8 @@ export function InlineGetStartedSection({
)} )}
</div> </div>
<div className="flex justify-center"> <div className="bg-card border border-border rounded-xl p-5 sm:p-6 shadow-[var(--cp-shadow-1)]">
<div className="inline-flex flex-wrap justify-center rounded-full border border-border bg-background p-1 shadow-[var(--cp-shadow-1)] gap-1"> {renderStep()}
<Button
type="button"
size="sm"
variant={mode === "signup" ? "default" : "ghost"}
onClick={() => handleModeChange("signup")}
className="rounded-full"
>
Create account
</Button>
<Button
type="button"
size="sm"
variant={mode === "login" ? "default" : "ghost"}
onClick={() => handleModeChange("login")}
className="rounded-full"
>
Sign in
</Button>
<Button
type="button"
size="sm"
variant={mode === "migrate" ? "default" : "ghost"}
onClick={() => handleModeChange("migrate")}
className="rounded-full"
>
Migrate
</Button>
</div>
</div>
<div className="mt-6">
<div className="bg-card border border-border rounded-xl p-5 sm:p-6 shadow-[var(--cp-shadow-1)]">
{mode === "signup" && (
<>
<h4 className="text-base font-semibold text-foreground mb-2">Create your account</h4>
<p className="text-sm text-muted-foreground mb-4">
Verify your email to get started.
</p>
{renderSignupStep()}
</>
)}
{mode === "login" && (
<>
<h4 className="text-base font-semibold text-foreground mb-2">Sign in</h4>
<p className="text-sm text-muted-foreground mb-4">Access your account to continue.</p>
<LoginForm redirectTo={redirectTo} showSignupLink={false} />
</>
)}
{mode === "migrate" && (
<>
<h4 className="text-base font-semibold text-foreground mb-2">Migrate your account</h4>
<p className="text-sm text-muted-foreground mb-4">
Use your legacy portal credentials to transfer your account.
</p>
<LinkWhmcsForm
onTransferred={result => {
if (result.needsPasswordSet) {
const params = new URLSearchParams({
email: result.user.email,
redirect: safeRedirect,
});
router.push(`/auth/set-password?${params.toString()}`);
return;
}
router.push(safeRedirect);
}}
/>
</>
)}
</div>
</div> </div>
{highlights.length > 0 && ( {highlights.length > 0 && (

View File

@ -24,7 +24,7 @@ export type GetStartedStep =
* (e.g., SIM plan selection) * (e.g., SIM plan selection)
*/ */
export interface ServiceContext { export interface ServiceContext {
type: "sim" | "internet" | null; type: "sim" | "internet" | "vpn" | null;
planSku?: string | undefined; planSku?: string | undefined;
redirectTo?: string | undefined; redirectTo?: string | undefined;
} }
@ -79,6 +79,12 @@ export interface GetStartedState {
// Service context for tracking which service flow the user came from // Service context for tracking which service flow the user came from
serviceContext: ServiceContext | null; 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 and error states
loading: boolean; loading: boolean;
error: string | null; error: string | null;
@ -105,6 +111,8 @@ export interface GetStartedState {
setSessionToken: (token: string | null) => void; setSessionToken: (token: string | null) => void;
setHandoffToken: (token: string | null) => void; setHandoffToken: (token: string | null) => void;
setServiceContext: (context: ServiceContext | null) => void; setServiceContext: (context: ServiceContext | null) => void;
setRedirectTo: (url: string | null) => void;
setInline: (inline: boolean) => void;
// Reset // Reset
reset: () => void; reset: () => void;
@ -134,6 +142,8 @@ const initialState = {
prefill: null, prefill: null,
handoffToken: null, handoffToken: null,
serviceContext: null as ServiceContext | null, serviceContext: null as ServiceContext | null,
redirectTo: null as string | null,
inline: false,
loading: false, loading: false,
error: null, error: null,
codeSent: false, codeSent: false,
@ -304,6 +314,14 @@ export const useGetStartedStore = create<GetStartedState>()((set, get) => ({
set({ serviceContext: context }); set({ serviceContext: context });
}, },
setRedirectTo: (url: string | null) => {
set({ redirectTo: url });
},
setInline: (inline: boolean) => {
set({ inline });
},
reset: () => { reset: () => {
set(initialState); set(initialState);
}, },

View File

@ -21,6 +21,7 @@ import { useState, useCallback, useEffect } from "react";
import { useSearchParams } from "next/navigation"; 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 { import {
useGetStartedStore, useGetStartedStore,
type GetStartedStep, type GetStartedStep,
@ -40,6 +41,7 @@ export function GetStartedView() {
setSessionToken, setSessionToken,
setAccountStatus, setAccountStatus,
setPrefill, setPrefill,
setRedirectTo,
} = useGetStartedStore(); } = useGetStartedStore();
const [meta, setMeta] = useState({ const [meta, setMeta] = useState({
title: "Get Started", title: "Get Started",
@ -61,6 +63,13 @@ export function GetStartedView() {
useEffect(() => { useEffect(() => {
if (initialized) return; if (initialized) return;
// Handle redirect URL param (for full-page flow with redirect support)
const redirectParam = searchParams.get("redirect");
if (redirectParam) {
const safeRedirect = getSafeRedirect(redirectParam, "/account/dashboard");
setRedirectTo(safeRedirect);
}
// Check for verified handoff (user already completed OTP on eligibility page) // Check for verified handoff (user already completed OTP on eligibility page)
const verifiedParam = searchParams.get("verified"); const verifiedParam = searchParams.get("verified");
@ -159,6 +168,7 @@ export function GetStartedView() {
setSessionToken, setSessionToken,
setAccountStatus, setAccountStatus,
setPrefill, setPrefill,
setRedirectTo,
]); ]);
const handleStepChange = useCallback( const handleStepChange = useCallback(

View File

@ -0,0 +1,194 @@
"use client";
import { useSearchParams } from "next/navigation";
import { ShieldCheck, CheckIcon, BoltIcon } from "lucide-react";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { usePublicVpnPlan } from "@/features/services/hooks";
import { InlineGetStartedSection } from "@/features/get-started";
import { CardPricing } from "@/features/services/components/base/CardPricing";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { getVpnRegionConfig } from "@/features/services/utils";
import { cn } from "@/shared/utils/cn";
/**
* Public VPN Configure View
*
* Shows selected VPN plan information and prompts for authentication.
* Simplified design focused on quick signup-to-order flow.
*/
export function PublicVpnConfigureView() {
const servicesBasePath = useServicesBasePath();
const searchParams = useSearchParams();
const planSku = searchParams?.get("planSku");
const { plan, isLoading } = usePublicVpnPlan(planSku || undefined);
const redirectTarget = planSku
? `/account/services/vpn/configure?planSku=${encodeURIComponent(planSku)}`
: "/account/services/vpn";
if (isLoading) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<ServicesBackLink href={`${servicesBasePath}/vpn`} label="Back to VPN plans" />
<div className="mt-8 space-y-6">
<Skeleton className="h-10 w-96 mx-auto" />
<Skeleton className="h-32 w-full" />
</div>
</div>
);
}
if (!plan) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<ServicesBackLink href={`${servicesBasePath}/vpn`} label="Back to VPN plans" />
<AlertBanner variant="error" title="Plan not found">
The selected plan could not be found. Please go back and select a plan.
</AlertBanner>
</div>
);
}
const region = getVpnRegionConfig(plan.name);
const isUS = region.accent === "blue";
const isUK = region.accent === "red";
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<ServicesBackLink href={`${servicesBasePath}/vpn`} label="Back to VPN plans" />
{/* Header */}
<div className="mt-6 mb-8 text-center">
<div className="flex justify-center mb-4">
<div
className={cn(
"flex h-16 w-16 items-center justify-center rounded-2xl border shadow-lg",
isUS &&
"bg-gradient-to-br from-blue-500/20 to-blue-500/5 border-blue-500/20 shadow-blue-500/10",
isUK &&
"bg-gradient-to-br from-red-500/20 to-red-500/5 border-red-500/20 shadow-red-500/10",
!isUS &&
!isUK &&
"bg-gradient-to-br from-purple-500/20 to-purple-500/5 border-purple-500/20 shadow-purple-500/10"
)}
>
<ShieldCheck
className={cn(
"h-8 w-8",
isUS && "text-blue-500",
isUK && "text-red-500",
!isUS && !isUK && "text-purple-500"
)}
/>
</div>
</div>
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-3">
Order Your VPN Router
</h1>
<p className="text-muted-foreground max-w-lg mx-auto">
Create an account to complete your order. Your pre-configured router ships upon
confirmation.
</p>
</div>
{/* Plan Summary Card */}
<div className="mb-8 bg-card border border-border rounded-2xl p-6 shadow-[var(--cp-shadow-1)]">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Selected Plan
</div>
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-xl text-2xl",
isUS && "bg-blue-500/10 border border-blue-500/20",
isUK && "bg-red-500/10 border border-red-500/20",
!isUS && !isUK && "bg-primary/10 border border-primary/20"
)}
role="img"
aria-label={region.flagAlt}
>
{region.flag}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h3 className="text-lg font-semibold text-foreground">{plan.name}</h3>
<p className="text-sm text-muted-foreground mt-1">{region.location}</p>
<div className="flex flex-wrap gap-2 mt-3">
<span
className={cn(
"inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium border",
isUS && "bg-blue-500/10 text-blue-600 border-blue-500/20",
isUK && "bg-red-500/10 text-red-600 border-red-500/20",
!isUS && !isUK && "bg-purple-500/10 text-purple-600 border-purple-500/20"
)}
>
VPN Router
</span>
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-info/10 text-info border border-info/20">
{region.region}
</span>
</div>
</div>
<div className="text-right">
<CardPricing
monthlyPrice={plan.monthlyPrice}
oneTimePrice={plan.oneTimePrice}
size="md"
alignment="right"
/>
</div>
</div>
</div>
</div>
{/* Plan Details */}
<div className="border-t border-border pt-4 mt-4">
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3">
{region.features.map((feature, index) => (
<li key={index} className="flex items-center gap-2">
<CheckIcon
className={cn(
"h-4 w-4 flex-shrink-0",
isUS && "text-blue-500",
isUK && "text-red-500",
!isUS && !isUK && "text-success"
)}
/>
<span className="text-sm text-muted-foreground">{feature}</span>
</li>
))}
</ul>
</div>
</div>
{/* Order process info */}
<div className="mb-8 bg-info/10 border border-info/25 rounded-xl p-4">
<div className="flex items-start gap-3">
<BoltIcon className="h-5 w-5 text-info mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">How ordering works</p>
<p className="text-sm text-muted-foreground mt-1">
After signup, add a payment method and confirm your order. Your pre-configured router
will be shipped and ready to use just plug it in and connect your devices.
</p>
</div>
</div>
</div>
{/* Auth Section */}
<InlineGetStartedSection
title="Create your account to order"
description="Verify your email to get started with your VPN order."
serviceContext={{ type: "vpn", planSku: planSku || undefined }}
redirectTo={redirectTarget}
/>
</div>
);
}
export default PublicVpnConfigureView;

View File

@ -99,6 +99,11 @@ services:
- FREEBIT_OEM_ID=${FREEBIT_OEM_ID:-PASI} - FREEBIT_OEM_ID=${FREEBIT_OEM_ID:-PASI}
- FREEBIT_OEM_KEY=${FREEBIT_OEM_KEY} - FREEBIT_OEM_KEY=${FREEBIT_OEM_KEY}
# Japan Post (Address Lookup)
- JAPAN_POST_API_URL=${JAPAN_POST_API_URL}
- JAPAN_POST_CLIENT_ID=${JAPAN_POST_CLIENT_ID}
- JAPAN_POST_CLIENT_SECRET=${JAPAN_POST_CLIENT_SECRET}
# Email # Email
- EMAIL_ENABLED=${EMAIL_ENABLED:-true} - EMAIL_ENABLED=${EMAIL_ENABLED:-true}
- EMAIL_FROM=${EMAIL_FROM:-no-reply@asolutions.jp} - EMAIL_FROM=${EMAIL_FROM:-no-reply@asolutions.jp}

View File

@ -38,32 +38,68 @@ For customers who want to check internet availability before creating an account
└─→ Step 2: Choose action: └─→ Step 2: Choose action:
├─→ "Send Request Only" ├─→ "Just Submit Request" (secondary action)
│ └─→ SF Account + Case created → Success page │ └─→ SF Account + Opportunity (find/create) + Case created
│ └─→ Success page shows: │ └─→ Case description notes if Opportunity was created or matched
│ ├─→ "Back to Internet Plans" → Return to /services/internet │ └─→ Success page shows "View Internet Plans" → /services/internet
│ └─→ "Create Your Account Now" → /auth/get-started?email=xxx │ └─→ User can return later via SF email to create account
│ (standard OTP flow)
└─→ "Continue to Create Account" └─→ "Create Account & Submit" (primary action)
├─→ SF Account + Case created ├─→ Step 2a: OTP sent to email (inline on same page)
├─→ Inline OTP verification (no redirect) ├─→ Step 2b: User verifies OTP
└─→ On success → /auth/get-started?verified=true ├─→ Step 2c: Complete account form (phone, DOB, password)
(skips email/OTP steps, goes to complete-account) ├─→ Creates SF Account + Opportunity + Case + WHMCS + Portal
├─→ Case description notes if Opportunity was created or matched
└─→ Success page → Auto-redirect to /dashboard (5s countdown)
``` ```
**Key difference from Phase 1:** The "Continue to Create Account" path now includes inline OTP verification directly on the eligibility page, rather than redirecting to `/auth/get-started` for OTP. **Key design:** The entire eligibility check flow is self-contained on `/services/internet/check-availability`. There is no redirect to `/auth/get-started` - all steps (form, OTP, account creation, success) happen on the same page using internal step state.
## Account Status Routing ## Account Status Detection
| Portal | WHMCS | Salesforce | Mapping | → Result | The system checks accounts **in order** and returns the first match:
| ------ | ----- | ---------- | ------- | ------------------------------ |
| ✓ | ✓ | ✓ | ✓ | Go to login | ### Step 1: Portal User with ID Mapping
| ✓ | ✓ | - | ✓ | Go to login |
| - | ✓ | ✓ | - | Link WHMCS account (migrate) | Check if Portal user exists (by email) AND has an ID mapping.
| - | ✓ | - | - | Link WHMCS account (migrate) |
| - | - | ✓ | - | Complete account (pre-filled) | - **Found**: `PORTAL_EXISTS` → Redirect to login
| - | - | - | - | Create new account (full form) | - **Not found**: Continue to step 2
### Step 2: WHMCS Client (Billing Account)
Check if WHMCS client exists (by email).
- **Found**: `WHMCS_UNMAPPED` → "Link account" flow (enter WHMCS password to migrate)
- **Not found**: Continue to step 3
_WHMCS clients are existing billing customers who haven't created a Portal account yet._
### Step 3: Salesforce Account Only
Check if SF Account exists (by email).
- **Found**: `SF_UNMAPPED` → "Complete account" flow (pre-filled form, create WHMCS + Portal)
- **Not found**: Continue to step 4
_SF-only accounts are customers who:_
- _Checked internet eligibility without creating an account_
- _Contacted us via email/phone and we created an SF record_
- _Were created through other CRM workflows_
### Step 4: No Account Found
- `NEW_CUSTOMER` → Full signup form
### Summary Table
| Check Order | System Found | Status | User Flow |
| ----------- | ----------------- | ---------------- | ----------------------------- |
| 1 | Portal + Mapping | `portal_exists` | Go to login |
| 2 | WHMCS (no portal) | `whmcs_unmapped` | Enter WHMCS password to link |
| 3 | SF only | `sf_unmapped` | Complete account (pre-filled) |
| 4 | Nothing | `new_customer` | Full signup form |
## Frontend Structure ## Frontend Structure
@ -89,30 +125,40 @@ apps/portal/src/features/get-started/
**Location:** `apps/portal/src/features/services/views/PublicEligibilityCheck.tsx` **Location:** `apps/portal/src/features/services/views/PublicEligibilityCheck.tsx`
**Route:** `/services/internet/check-availability` **Route:** `/services/internet/check-availability`
**Store:** `apps/portal/src/features/services/stores/eligibility-check.store.ts`
A dedicated page for guests to check internet availability. This approach provides: A dedicated page for guests to check internet availability. This approach provides:
- Better mobile experience with proper form spacing - Better mobile experience with proper form spacing
- Clear user journey with bookmarkable URLs - Clear user journey with bookmarkable URLs
- Natural browser navigation (back button works) - Natural browser navigation (back button works)
- Focused multi-step experience - Self-contained multi-step experience (no redirects to other pages)
**Flow:** **Steps:** `form``otp``complete-account``success`
**Path 1: "Just Submit Request"** (guest, no account):
1. Collects name, email, and address (with Japan ZIP code lookup) 1. Collects name, email, and address (with Japan ZIP code lookup)
2. Verifies email with 6-digit OTP 2. Creates SF Account + Opportunity (find/create) + Eligibility Case
3. Creates SF Account + Eligibility Case immediately on verification 3. Shows success with "View Internet Plans" button
4. Shows success with options: "Create Account Now" or "View Internet Plans"
**Path 2: "Create Account & Submit"** (full account creation):
1. Collects name, email, and address (with Japan ZIP code lookup)
2. Sends OTP, user verifies on same page
3. Collects account details (phone, DOB, password)
4. Creates SF Account + Opportunity + Case + WHMCS client + Portal user
5. Shows success with auto-redirect to dashboard (5s countdown)
## Backend Endpoints ## Backend Endpoints
| Endpoint | Rate Limit | Purpose | | Endpoint | Rate Limit | Purpose |
| ------------------------------------------------ | ---------- | --------------------------------------------- | | ------------------------------------------------ | ---------- | ------------------------------------------------------------------- |
| `POST /auth/get-started/send-code` | 5/5min | Send OTP to email | | `POST /auth/get-started/send-code` | 5/5min | Send OTP to email |
| `POST /auth/get-started/verify-code` | 10/5min | Verify OTP, return account status | | `POST /auth/get-started/verify-code` | 10/5min | Verify OTP, return account status |
| `POST /auth/get-started/guest-eligibility` | 3/15min | Guest eligibility (no OTP, creates SF + Case) | | `POST /auth/get-started/guest-eligibility` | 3/15min | Guest eligibility (no OTP, creates SF Account + Opportunity + Case) |
| `POST /auth/get-started/complete-account` | 5/15min | Complete SF-only account | | `POST /auth/get-started/complete-account` | 5/15min | Complete SF-only account |
| `POST /auth/get-started/signup-with-eligibility` | 5/15min | Full signup with eligibility (OTP verified) | | `POST /auth/get-started/signup-with-eligibility` | 5/15min | Full signup with eligibility (OTP verified) |
## Domain Schemas ## Domain Schemas
@ -134,42 +180,34 @@ Key schemas:
- **Max Attempts**: 3 per code - **Max Attempts**: 3 per code
- **Rate Limits**: 5 codes per 5 minutes - **Rate Limits**: 5 codes per 5 minutes
## Handoff from Eligibility Check ## Eligibility Check Flow Details
### Flow A: "Continue to Create Account" (Inline OTP) ### Path 1: "Just Submit Request" (Guest Flow)
When a user clicks "Continue to Create Account": When a user clicks "Just Submit Request":
1. Eligibility form is submitted (creates SF Account + Case) 1. Calls `guestEligibilityCheck` API with `continueToAccount: false`
2. OTP is sent and verified **inline on the same page** 2. Backend creates SF Account + Opportunity (find/create) + Eligibility Case
3. On successful verification: 3. Frontend navigates to success step with `hasAccount: false`
- Session data stored in sessionStorage with timestamp: 4. Success page shows only "View Internet Plans" button
- `get-started-session-token` 5. User can return later via SF email to create an account at `/auth/get-started`
- `get-started-account-status`
- `get-started-prefill` (JSON with name, address from SF)
- `get-started-email`
- `get-started-timestamp` (for staleness validation)
- Redirect to: `/auth/get-started?verified=true`
4. GetStartedView detects `?verified=true` param and:
- Reads session data from sessionStorage (validates timestamp < 5 min)
- Clears sessionStorage immediately after reading
- Sets session token, account status, and prefill data in Zustand store
- Skips directly to `complete-account` step (no email/OTP required)
- User only needs to add phone, DOB, and password
### Flow B: "Send Request Only" → Return Later ### Path 2: "Create Account & Submit" (Full Account Creation)
When a user clicks "Send Request Only": When a user clicks "Create Account & Submit":
1. Eligibility form is submitted (creates SF Account + Case) 1. **OTP Step**: Calls `sendVerificationCode` API → navigates to OTP step (same page)
2. Success page is shown with two options: 2. **Verify OTP**: User enters code, calls `verifyCode` API → receives session token
- **"Back to Internet Plans"** → Returns to `/services/internet` 3. **Complete Account**: Navigates to complete-account step (same page)
- **"Create Your Account Now"** → Redirects to `/auth/get-started?email=xxx&handoff=true` 4. **Submit**: Calls `signupWithEligibility` API which creates:
3. If user returns later via success page CTA or SF email: - SF Account (find or create)
- Standard flow: Email (pre-filled) → OTP → Account Status → Complete - Opportunity (find or create)
- Backend detects `sf_unmapped` status and returns prefill data - Eligibility Case
- WHMCS client
- Portal user
5. **Success**: Shows success with "Go to Dashboard" button + auto-redirect (5s)
### Salesforce Email Link Format ### Guest Return Flow via SF Email
SF can send "finish your account" emails with this link format: SF can send "finish your account" emails with this link format:
@ -177,32 +215,35 @@ SF can send "finish your account" emails with this link format:
https://portal.example.com/auth/get-started?email={Account.PersonEmail} https://portal.example.com/auth/get-started?email={Account.PersonEmail}
``` ```
- No handoff token needed (SF Account persists) - User goes to `/auth/get-started` (not the eligibility check page)
- User verifies via standard OTP flow on get-started page - Standard flow: Email (pre-filled) → OTP → Account Status → Complete
- Backend detects `sf_unmapped` status and pre-fills form data - Backend detects `sf_unmapped` status and returns prefill data from existing SF Account
## Testing Checklist ## Testing Checklist
### Manual Testing ### Manual Testing - Get Started Page (`/auth/get-started`)
1. **New customer flow**: Enter new email → Verify OTP → Full signup form 1. **New customer flow**: Enter new email → Verify OTP → Full signup form
2. **SF-only flow**: Enter email with SF account → Verify → Pre-filled form (name, address pre-filled, add phone, DOB, password) 2. **SF-only flow**: Enter email with SF account → Verify → Pre-filled form (name, address pre-filled, add phone, DOB, password)
3. **WHMCS migration**: Enter email with WHMCS → Verify → Enter WHMCS password 3. **WHMCS migration**: Enter email with WHMCS → Verify → Enter WHMCS password
4. **Eligibility check - Send Request Only**: 4. **Return flow**: Customer with existing SF account returns, enters same email → Auto-links to SF account
- Click "Check Availability" → Fill form → Click "Send Request Only"
- Verify success page shows "Back to Plans" and "Create Account" buttons ### Manual Testing - Eligibility Check Page (`/services/internet/check-availability`)
- Click "Create Account" → Verify redirect to `/auth/get-started?email=xxx`
- Complete standard OTP flow → Verify sf_unmapped prefill works 5. **Eligibility check - Just Submit Request**:
5. **Eligibility check - Continue to Create Account**: - Click "Check Availability" → Fill form → Click "Just Submit Request"
- Click "Check Availability" → Fill form → Click "Continue to Create Account" - Verify success page shows only "View Internet Plans" button
- Verify inline OTP step appears (no redirect) - Verify SF Account + Opportunity + Case are created
- Complete OTP → Verify redirect to `/auth/get-started?verified=true` 6. **Eligibility check - Create Account & Submit**:
- Verify CompleteAccountStep shows directly (skips email/OTP steps) - Click "Check Availability" → Fill form → Click "Create Account & Submit"
- Verify form is pre-filled with name and address - Verify OTP step appears (same page, no redirect)
6. **Return flow**: Customer returns, enters same email → Auto-links to SF account - Complete OTP → Verify complete-account step appears (same page)
- Fill account details → Submit
- Verify success page with auto-redirect countdown to dashboard
- Verify SF Account + Opportunity + Case + WHMCS + Portal user created
7. **Mobile experience**: Test eligibility check page on mobile viewport 7. **Mobile experience**: Test eligibility check page on mobile viewport
8. **Browser back button**: After OTP success, press back → Verify graceful handling 8. **Browser back button**: After OTP success, press back → Verify graceful handling
9. **Session timeout**: Wait 5+ minutes after OTP → Verify stale data is rejected 9. **Existing account handling**: During OTP verification, if `portal_exists` or `whmcs_unmapped` status returned, verify appropriate error message
### Security Testing ### Security Testing

27
pnpm-lock.yaml generated
View File

@ -167,9 +167,6 @@ importers:
ssh2-sftp-client: ssh2-sftp-client:
specifier: ^12.0.1 specifier: ^12.0.1
version: 12.0.1 version: 12.0.1
swagger-ui-express:
specifier: ^5.0.1
version: 5.0.1(express@5.1.0)
zod: zod:
specifier: "catalog:" specifier: "catalog:"
version: 4.2.1 version: 4.2.1
@ -7830,27 +7827,12 @@ packages:
} }
engines: { node: ">=10" } engines: { node: ">=10" }
swagger-ui-dist@5.21.0:
resolution:
{
integrity: sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==,
}
swagger-ui-dist@5.30.2: swagger-ui-dist@5.30.2:
resolution: resolution:
{ {
integrity: sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==, integrity: sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==,
} }
swagger-ui-express@5.0.1:
resolution:
{
integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==,
}
engines: { node: ">= v0.10.32" }
peerDependencies:
express: ">=4.0.0 || >=5.0.0-beta"
symbol-observable@4.0.0: symbol-observable@4.0.0:
resolution: resolution:
{ {
@ -13079,19 +13061,10 @@ snapshots:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
swagger-ui-dist@5.21.0:
dependencies:
"@scarf/scarf": 1.4.0
swagger-ui-dist@5.30.2: swagger-ui-dist@5.30.2:
dependencies: dependencies:
"@scarf/scarf": 1.4.0 "@scarf/scarf": 1.4.0
swagger-ui-express@5.0.1(express@5.1.0):
dependencies:
express: 5.1.0
swagger-ui-dist: 5.21.0
symbol-observable@4.0.0: {} symbol-observable@4.0.0: {}
tailwind-merge@3.4.0: {} tailwind-merge@3.4.0: {}