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 {
userId String @id @map("user_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")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

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

View File

@ -8,7 +8,7 @@ import type { CreateMappingRequest, UpdateMappingRequest, UserIdMapping } from "
export const createMappingRequestSchema: z.ZodType<CreateMappingRequest> = z.object({
userId: z.string().uuid(),
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({
@ -20,7 +20,7 @@ export const userIdMappingSchema: z.ZodType<UserIdMapping> = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
whmcsClientId: z.number().int().positive(),
sfAccountId: z.string().nullable().optional(),
sfAccountId: z.string(),
createdAt: z.union([z.string(), z.date()]),
updatedAt: z.union([z.string(), z.date()]),
});

View File

@ -12,18 +12,6 @@ import type {
MappingValidationResult,
} 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
* 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);
if (duplicateSf) {
warnings.push(
`Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}`
);
}
const duplicateSf = existingMappings.find(m => m.sfAccountId === request.sfAccountId);
if (duplicateSf) {
warnings.push(
`Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}`
);
}
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");
if (mapping.sfAccountId) {
warnings.push(
"This mapping includes Salesforce integration - deletion will affect case management"
);
}
warnings.push(
"This mapping includes Salesforce integration - deletion will affect case management"
);
return { isValid: true, errors, warnings };
}
@ -98,11 +82,10 @@ export function validateDeletion(
* The schema handles validation; this is purely for data cleanup.
*/
export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
const trimmedSfAccountId = request.sfAccountId?.trim();
return {
userId: request.userId?.trim(),
whmcsClientId: request.whmcsClientId,
...(trimmedSfAccountId ? { sfAccountId: trimmedSfAccountId } : {}),
sfAccountId: request.sfAccountId.trim(),
};
}

View File

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

View File

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

View File

@ -42,7 +42,7 @@ export class OrderValidator {
*/
async validateUserMapping(
userId: string
): Promise<{ userId: string; sfAccountId?: string | undefined; whmcsClientId: number }> {
): Promise<{ userId: string; sfAccountId: string; whmcsClientId: number }> {
const mapping = await this.mappings.findByUserId(userId);
if (!mapping) {
@ -57,7 +57,7 @@ export class OrderValidator {
return {
userId: mapping.userId,
sfAccountId: mapping.sfAccountId || undefined,
sfAccountId: mapping.sfAccountId,
whmcsClientId: mapping.whmcsClientId,
};
}
@ -182,7 +182,7 @@ export class OrderValidator {
body: CreateOrderRequest
): Promise<{
validatedBody: OrderBusinessValidation;
userMapping: { userId: string; sfAccountId?: string | undefined; whmcsClientId: number };
userMapping: { userId: string; sfAccountId: string; whmcsClientId: number };
pricebookId: string;
}> {
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 sfAccountId = mapping?.sfAccountId;
// Intentionally log minimal info for debugging connection issues.
this.logger.log("Account realtime stream connected", {
userId: req.user.id,
hasSfAccountId: Boolean(sfAccountId),
sfAccountIdTail: sfAccountId ? sfAccountId.slice(-4) : null,
hasMapping: Boolean(mapping),
sfAccountIdTail: mapping?.sfAccountId.slice(-4) ?? null,
});
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.
readyEvent: "account.stream.ready",

View File

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

View File

@ -34,10 +34,7 @@ export class ResidenceCardService {
async getStatusForUser(userId: string): Promise<ResidenceCardVerification> {
const mapping = await this.mappings.findByUserId(userId);
const sfAccountId = mapping?.sfAccountId
? assertSalesforceId(mapping.sfAccountId, "sfAccountId")
: null;
if (!sfAccountId) {
if (!mapping) {
return residenceCardVerificationSchema.parse({
status: "not_submitted",
submittedAt: null,
@ -45,6 +42,7 @@ export class ResidenceCardService {
reviewerNotes: null,
});
}
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
return this.servicesCache.getCachedVerification(sfAccountId, async () => {
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
</Link>
) : (
<Link
href="/auth/login"
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"
>
Sign in
</Link>
<div className="flex items-center gap-2 ml-2">
<Link
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>
<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>
</div>

View File

@ -13,14 +13,15 @@ import { useZodForm } from "@/shared/hooks";
interface LinkWhmcsFormProps {
onTransferred?: ((result: LinkWhmcsResponse) => void) | 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 form = useZodForm({
schema: linkWhmcsRequestSchema,
initialValues: { email: "", password: "" },
initialValues: { email: initialEmail ?? "", password: "" },
onSubmit: async data => {
clearError();
const result = await linkWhmcs(data);

View File

@ -2,8 +2,8 @@
* AccountStatusStep - Shows account status and routes to appropriate next step
*
* Routes based on account status:
* - portal_exists: Show login link
* - whmcs_unmapped: Link to migrate page (enter WHMCS password)
* - portal_exists: Show login form inline (or redirect link in full-page mode)
* - whmcs_unmapped: Show migrate form inline (or redirect link in full-page mode)
* - sf_unmapped: Go to complete-account step (pre-filled form)
* - new_customer: Go to complete-account step (full signup)
*/
@ -11,6 +11,7 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/atoms";
import {
CheckCircleIcon,
@ -19,13 +20,51 @@ import {
DocumentCheckIcon,
} from "@heroicons/react/24/outline";
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";
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") {
// 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">
@ -41,7 +80,7 @@ export function AccountStatusStep() {
</p>
</div>
<Link href={`/auth/login?email=${encodeURIComponent(formData.email)}`}>
<Link href={loginUrl}>
<Button className="w-full h-11">
Go to Login
<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") {
// 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 (
<div className="space-y-6 text-center">
<div className="flex justify-center">
@ -69,7 +148,7 @@ export function AccountStatusStep() {
</p>
</div>
<Link href={`/auth/migrate?email=${encodeURIComponent(formData.email)}`}>
<Link href={migrateUrl}>
<Button className="w-full h-11">
Link My Account
<ArrowRightIcon className="h-4 w-4 ml-2" />

View File

@ -43,8 +43,13 @@ export function CompleteAccountStep() {
error,
clearError,
goBack,
redirectTo,
serviceContext,
} = 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)
const isNewCustomer = accountStatus === "new_customer";
const hasPrefill = !!(prefill?.firstName || prefill?.lastName);
@ -156,8 +161,8 @@ export function CompleteAccountStep() {
const result = await completeAccount();
if (result) {
// Redirect to dashboard on success
router.push("/account/dashboard");
// Redirect to the effective redirect URL on success
router.push(effectiveRedirectTo);
}
};

View File

@ -7,8 +7,17 @@
import Link from "next/link";
import { Button } from "@/components/atoms";
import { CheckCircleIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import { useGetStartedStore } from "../../../stores/get-started.store";
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 (
<div className="space-y-6 text-center">
<div className="flex justify-center">
@ -25,18 +34,20 @@ export function SuccessStep() {
</div>
<div className="space-y-3">
<Link href="/account/dashboard">
<Link href={effectiveRedirectTo}>
<Button className="w-full h-11">
Go to Dashboard
{isDefaultRedirect ? "Go to Dashboard" : "Continue"}
<ArrowRightIcon className="h-4 w-4 ml-2" />
</Button>
</Link>
<Link href="/services/internet">
<Button variant="outline" className="w-full h-11">
Check Internet Availability
</Button>
</Link>
{isDefaultRedirect && (
<Link href="/services/internet">
<Button variant="outline" className="w-full h-11">
Check Internet Availability
</Button>
</Link>
)}
</div>
</div>
);

View File

@ -3,15 +3,17 @@
*
* 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.
*
* 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";
import { useState, useEffect } from "react";
import { useEffect } from "react";
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 { useGetStartedStore, type ServiceContext } from "../../stores/get-started.store";
import { EmailStep } from "../GetStartedForm/steps/EmailStep";
@ -42,42 +44,31 @@ export function InlineGetStartedSection({
className = "",
}: InlineGetStartedSectionProps) {
const router = useRouter();
const [mode, setMode] = useState<"signup" | "login" | "migrate">("signup");
const safeRedirect = getSafeRedirect(redirectTo, "/account");
const safeRedirect = getSafeRedirect(redirectTo, "/account/dashboard");
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(() => {
setInline(true);
setRedirectTo(safeRedirect);
if (serviceContext) {
setServiceContext({
...serviceContext,
redirectTo: safeRedirect,
});
}
return () => {
// Clear service context when unmounting
// Clear inline mode when unmounting
setInline(false);
setServiceContext(null);
};
}, [serviceContext, safeRedirect, setServiceContext]);
}, [serviceContext, safeRedirect, setServiceContext, setRedirectTo, setInline]);
// Reset get-started store when switching to signup mode
const handleModeChange = (newMode: "signup" | "login" | "migrate") => {
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 = () => {
// Render the current step
const renderStep = () => {
switch (step) {
case "email":
return <EmailStep />;
@ -105,78 +96,8 @@ export function InlineGetStartedSection({
)}
</div>
<div className="flex justify-center">
<div className="inline-flex flex-wrap justify-center rounded-full border border-border bg-background p-1 shadow-[var(--cp-shadow-1)] gap-1">
<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 className="bg-card border border-border rounded-xl p-5 sm:p-6 shadow-[var(--cp-shadow-1)]">
{renderStep()}
</div>
{highlights.length > 0 && (

View File

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

View File

@ -21,6 +21,7 @@ import { useState, useCallback, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { AuthLayout } from "@/components/templates/AuthLayout";
import { GetStartedForm } from "../components";
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
import {
useGetStartedStore,
type GetStartedStep,
@ -40,6 +41,7 @@ export function GetStartedView() {
setSessionToken,
setAccountStatus,
setPrefill,
setRedirectTo,
} = useGetStartedStore();
const [meta, setMeta] = useState({
title: "Get Started",
@ -61,6 +63,13 @@ export function GetStartedView() {
useEffect(() => {
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)
const verifiedParam = searchParams.get("verified");
@ -159,6 +168,7 @@ export function GetStartedView() {
setSessionToken,
setAccountStatus,
setPrefill,
setRedirectTo,
]);
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_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_ENABLED=${EMAIL_ENABLED:-true}
- 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:
├─→ "Send Request Only"
│ └─→ SF Account + Case created → Success page
│ └─→ Success page shows:
│ ├─→ "Back to Internet Plans" → Return to /services/internet
│ └─→ "Create Your Account Now" → /auth/get-started?email=xxx
│ (standard OTP flow)
├─→ "Just Submit Request" (secondary action)
│ └─→ SF Account + Opportunity (find/create) + Case created
│ └─→ Case description notes if Opportunity was created or matched
│ └─→ Success page shows "View Internet Plans" → /services/internet
│ └─→ User can return later via SF email to create account
└─→ "Continue to Create Account"
├─→ SF Account + Case created
├─→ Inline OTP verification (no redirect)
└─→ On success → /auth/get-started?verified=true
(skips email/OTP steps, goes to complete-account)
└─→ "Create Account & Submit" (primary action)
├─→ Step 2a: OTP sent to email (inline on same page)
├─→ Step 2b: User verifies OTP
├─→ Step 2c: Complete account form (phone, DOB, password)
├─→ 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 |
| ------ | ----- | ---------- | ------- | ------------------------------ |
| ✓ | ✓ | ✓ | ✓ | Go to login |
| ✓ | ✓ | - | ✓ | Go to login |
| - | ✓ | ✓ | - | Link WHMCS account (migrate) |
| - | ✓ | - | - | Link WHMCS account (migrate) |
| - | - | ✓ | - | Complete account (pre-filled) |
| - | - | - | - | Create new account (full form) |
The system checks accounts **in order** and returns the first match:
### Step 1: Portal User with ID Mapping
Check if Portal user exists (by email) AND has an ID mapping.
- **Found**: `PORTAL_EXISTS` → Redirect to login
- **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
@ -89,30 +125,40 @@ apps/portal/src/features/get-started/
**Location:** `apps/portal/src/features/services/views/PublicEligibilityCheck.tsx`
**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:
- Better mobile experience with proper form spacing
- Clear user journey with bookmarkable URLs
- 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)
2. Verifies email with 6-digit OTP
3. Creates SF Account + Eligibility Case immediately on verification
4. Shows success with options: "Create Account Now" or "View Internet Plans"
2. Creates SF Account + Opportunity (find/create) + Eligibility Case
3. Shows success with "View Internet Plans" button
**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
| Endpoint | Rate Limit | Purpose |
| ------------------------------------------------ | ---------- | --------------------------------------------- |
| `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/guest-eligibility` | 3/15min | Guest eligibility (no OTP, creates SF + Case) |
| `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) |
| Endpoint | Rate Limit | Purpose |
| ------------------------------------------------ | ---------- | ------------------------------------------------------------------- |
| `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/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/signup-with-eligibility` | 5/15min | Full signup with eligibility (OTP verified) |
## Domain Schemas
@ -134,42 +180,34 @@ Key schemas:
- **Max Attempts**: 3 per code
- **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)
2. OTP is sent and verified **inline on the same page**
3. On successful verification:
- Session data stored in sessionStorage with timestamp:
- `get-started-session-token`
- `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
1. Calls `guestEligibilityCheck` API with `continueToAccount: false`
2. Backend creates SF Account + Opportunity (find/create) + Eligibility Case
3. Frontend navigates to success step with `hasAccount: false`
4. Success page shows only "View Internet Plans" button
5. User can return later via SF email to create an account at `/auth/get-started`
### 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)
2. Success page is shown with two options:
- **"Back to Internet Plans"** → Returns to `/services/internet`
- **"Create Your Account Now"** → Redirects to `/auth/get-started?email=xxx&handoff=true`
3. If user returns later via success page CTA or SF email:
- Standard flow: Email (pre-filled) → OTP → Account Status → Complete
- Backend detects `sf_unmapped` status and returns prefill data
1. **OTP Step**: Calls `sendVerificationCode` API → navigates to OTP step (same page)
2. **Verify OTP**: User enters code, calls `verifyCode` API → receives session token
3. **Complete Account**: Navigates to complete-account step (same page)
4. **Submit**: Calls `signupWithEligibility` API which creates:
- SF Account (find or create)
- Opportunity (find or create)
- 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:
@ -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}
```
- No handoff token needed (SF Account persists)
- User verifies via standard OTP flow on get-started page
- Backend detects `sf_unmapped` status and pre-fills form data
- User goes to `/auth/get-started` (not the eligibility check page)
- Standard flow: Email (pre-filled) → OTP → Account Status → Complete
- Backend detects `sf_unmapped` status and returns prefill data from existing SF Account
## Testing Checklist
### Manual Testing
### Manual Testing - Get Started Page (`/auth/get-started`)
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)
3. **WHMCS migration**: Enter email with WHMCS → Verify → Enter WHMCS password
4. **Eligibility check - Send Request Only**:
- Click "Check Availability" → Fill form → Click "Send Request Only"
- Verify success page shows "Back to Plans" and "Create Account" buttons
- Click "Create Account" → Verify redirect to `/auth/get-started?email=xxx`
- Complete standard OTP flow → Verify sf_unmapped prefill works
5. **Eligibility check - Continue to Create Account**:
- Click "Check Availability" → Fill form → Click "Continue to Create Account"
- Verify inline OTP step appears (no redirect)
- Complete OTP → Verify redirect to `/auth/get-started?verified=true`
- Verify CompleteAccountStep shows directly (skips email/OTP steps)
- Verify form is pre-filled with name and address
6. **Return flow**: Customer returns, enters same email → Auto-links to SF account
4. **Return flow**: Customer with existing SF account returns, enters same email → Auto-links to SF account
### Manual Testing - Eligibility Check Page (`/services/internet/check-availability`)
5. **Eligibility check - Just Submit Request**:
- Click "Check Availability" → Fill form → Click "Just Submit Request"
- Verify success page shows only "View Internet Plans" button
- Verify SF Account + Opportunity + Case are created
6. **Eligibility check - Create Account & Submit**:
- Click "Check Availability" → Fill form → Click "Create Account & Submit"
- Verify OTP step appears (same page, no redirect)
- 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
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

27
pnpm-lock.yaml generated
View File

@ -167,9 +167,6 @@ importers:
ssh2-sftp-client:
specifier: ^12.0.1
version: 12.0.1
swagger-ui-express:
specifier: ^5.0.1
version: 5.0.1(express@5.1.0)
zod:
specifier: "catalog:"
version: 4.2.1
@ -7830,27 +7827,12 @@ packages:
}
engines: { node: ">=10" }
swagger-ui-dist@5.21.0:
resolution:
{
integrity: sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==,
}
swagger-ui-dist@5.30.2:
resolution:
{
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:
resolution:
{
@ -13079,19 +13061,10 @@ snapshots:
dependencies:
has-flag: 4.0.0
swagger-ui-dist@5.21.0:
dependencies:
"@scarf/scarf": 1.4.0
swagger-ui-dist@5.30.2:
dependencies:
"@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: {}
tailwind-merge@3.4.0: {}