From b52b2874d6ca43aea43ea91f0df367c4e7d5cf63 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 19 Jan 2026 10:13:55 +0900 Subject: [PATCH] feat: add public VPN configuration page and view for unauthenticated users --- apps/bff/prisma/schema.prisma | 2 +- .../modules/id-mappings/domain/contract.ts | 4 +- .../src/modules/id-mappings/domain/schema.ts | 4 +- .../modules/id-mappings/domain/validation.ts | 35 +-- .../modules/id-mappings/mappings.service.ts | 30 +-- .../services/order-orchestrator.service.ts | 4 +- .../services/order-validator.service.ts | 6 +- .../modules/realtime/realtime.controller.ts | 7 +- .../internet-eligibility.service.ts | 2 +- .../verification/residence-card.service.ts | 6 +- .../(site)/services/vpn/configure/page.tsx | 17 ++ .../templates/PublicShell/PublicShell.tsx | 20 +- .../LinkWhmcsForm/LinkWhmcsForm.tsx | 5 +- .../steps/AccountStatusStep.tsx | 93 +++++++- .../steps/CompleteAccountStep.tsx | 9 +- .../GetStartedForm/steps/SuccessStep.tsx | 25 ++- .../InlineGetStartedSection.tsx | 119 ++--------- .../get-started/stores/get-started.store.ts | 20 +- .../get-started/views/GetStartedView.tsx | 10 + .../services/views/PublicVpnConfigure.tsx | 194 +++++++++++++++++ docker/prod-portainer/docker-compose.yml | 5 + docs/features/unified-get-started-flow.md | 199 +++++++++++------- pnpm-lock.yaml | 27 --- 23 files changed, 549 insertions(+), 294 deletions(-) create mode 100644 apps/portal/src/app/(public)/(site)/services/vpn/configure/page.tsx create mode 100644 apps/portal/src/features/services/views/PublicVpnConfigure.tsx diff --git a/apps/bff/prisma/schema.prisma b/apps/bff/prisma/schema.prisma index 76a869c7..5f1f1511 100644 --- a/apps/bff/prisma/schema.prisma +++ b/apps/bff/prisma/schema.prisma @@ -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) diff --git a/apps/bff/src/modules/id-mappings/domain/contract.ts b/apps/bff/src/modules/id-mappings/domain/contract.ts index 5df4e1c4..e28a2434 100644 --- a/apps/bff/src/modules/id-mappings/domain/contract.ts +++ b/apps/bff/src/modules/id-mappings/domain/contract.ts @@ -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 { diff --git a/apps/bff/src/modules/id-mappings/domain/schema.ts b/apps/bff/src/modules/id-mappings/domain/schema.ts index ba4a3994..542fcefe 100644 --- a/apps/bff/src/modules/id-mappings/domain/schema.ts +++ b/apps/bff/src/modules/id-mappings/domain/schema.ts @@ -8,7 +8,7 @@ import type { CreateMappingRequest, UpdateMappingRequest, UserIdMapping } from " export const createMappingRequestSchema: z.ZodType = 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 = z.object({ @@ -20,7 +20,7 @@ export const userIdMappingSchema: z.ZodType = 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()]), }); diff --git a/apps/bff/src/modules/id-mappings/domain/validation.ts b/apps/bff/src/modules/id-mappings/domain/validation.ts index 78f4a4ee..6322eb7b 100644 --- a/apps/bff/src/modules/id-mappings/domain/validation.ts +++ b/apps/bff/src/modules/id-mappings/domain/validation.ts @@ -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(), }; } diff --git a/apps/bff/src/modules/id-mappings/mappings.service.ts b/apps/bff/src/modules/id-mappings/mappings.service.ts index a8a6766a..0d8d35b1 100644 --- a/apps/bff/src/modules/id-mappings/mappings.service.ts +++ b/apps/bff/src/modules/id-mappings/mappings.service.ts @@ -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 { 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; diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts index 032dd1c4..9bb7fabb 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -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), }, ] : []), diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index 741a08f2..c6246cbc 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -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"); diff --git a/apps/bff/src/modules/realtime/realtime.controller.ts b/apps/bff/src/modules/realtime/realtime.controller.ts index b1df53af..2ac00f51 100644 --- a/apps/bff/src/modules/realtime/realtime.controller.ts +++ b/apps/bff/src/modules/realtime/realtime.controller.ts @@ -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", diff --git a/apps/bff/src/modules/services/application/internet-eligibility.service.ts b/apps/bff/src/modules/services/application/internet-eligibility.service.ts index 9e9b0a78..4c6f0d70 100644 --- a/apps/bff/src/modules/services/application/internet-eligibility.service.ts +++ b/apps/bff/src/modules/services/application/internet-eligibility.service.ts @@ -38,7 +38,7 @@ export class InternetEligibilityService { async getEligibilityDetailsForUser(userId: string): Promise { const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.sfAccountId) { + if (!mapping) { return internetEligibilityDetailsSchema.parse({ status: "not_requested", eligibility: null, diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts index 9f471d56..6b720f52 100644 --- a/apps/bff/src/modules/verification/residence-card.service.ts +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -34,10 +34,7 @@ export class ResidenceCardService { async getStatusForUser(userId: string): Promise { 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); diff --git a/apps/portal/src/app/(public)/(site)/services/vpn/configure/page.tsx b/apps/portal/src/app/(public)/(site)/services/vpn/configure/page.tsx new file mode 100644 index 00000000..e1501539 --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/services/vpn/configure/page.tsx @@ -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 ( + <> + + + + ); +} diff --git a/apps/portal/src/components/templates/PublicShell/PublicShell.tsx b/apps/portal/src/components/templates/PublicShell/PublicShell.tsx index 1663ca76..2a8e7652 100644 --- a/apps/portal/src/components/templates/PublicShell/PublicShell.tsx +++ b/apps/portal/src/components/templates/PublicShell/PublicShell.tsx @@ -79,12 +79,20 @@ export function PublicShell({ children }: PublicShellProps) { My Account ) : ( - - Sign in - +
+ + Sign in + + + Get Started + +
)} diff --git a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx index 072c4464..86d4fd59 100644 --- a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx +++ b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx @@ -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); diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx index 794db36c..033d1d85 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx @@ -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 ( +
+
+
+
+ +
+
+ +
+

Account Found

+

+ You already have a portal account with this email. Please log in to continue. +

+
+
+ + +
+ ); + } + + // Full-page mode: redirect to login page + const loginUrl = `/auth/login?email=${encodeURIComponent(formData.email)}&redirect=${encodeURIComponent(effectiveRedirectTo)}`; return (
@@ -41,7 +80,7 @@ export function AccountStatusStep() {

- + - - - + {isDefaultRedirect && ( + + + + )}
); diff --git a/apps/portal/src/features/get-started/components/InlineGetStartedSection/InlineGetStartedSection.tsx b/apps/portal/src/features/get-started/components/InlineGetStartedSection/InlineGetStartedSection.tsx index e6e7ec60..4ba0d0b9 100644 --- a/apps/portal/src/features/get-started/components/InlineGetStartedSection/InlineGetStartedSection.tsx +++ b/apps/portal/src/features/get-started/components/InlineGetStartedSection/InlineGetStartedSection.tsx @@ -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 ; @@ -105,78 +96,8 @@ export function InlineGetStartedSection({ )} -
-
- - - -
-
- -
-
- {mode === "signup" && ( - <> -

Create your account

-

- Verify your email to get started. -

- {renderSignupStep()} - - )} - {mode === "login" && ( - <> -

Sign in

-

Access your account to continue.

- - - )} - {mode === "migrate" && ( - <> -

Migrate your account

-

- Use your legacy portal credentials to transfer your account. -

- { - if (result.needsPasswordSet) { - const params = new URLSearchParams({ - email: result.user.email, - redirect: safeRedirect, - }); - router.push(`/auth/set-password?${params.toString()}`); - return; - } - router.push(safeRedirect); - }} - /> - - )} -
+
+ {renderStep()}
{highlights.length > 0 && ( diff --git a/apps/portal/src/features/get-started/stores/get-started.store.ts b/apps/portal/src/features/get-started/stores/get-started.store.ts index 1d3ce162..12383896 100644 --- a/apps/portal/src/features/get-started/stores/get-started.store.ts +++ b/apps/portal/src/features/get-started/stores/get-started.store.ts @@ -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()((set, get) => ({ set({ serviceContext: context }); }, + setRedirectTo: (url: string | null) => { + set({ redirectTo: url }); + }, + + setInline: (inline: boolean) => { + set({ inline }); + }, + reset: () => { set(initialState); }, diff --git a/apps/portal/src/features/get-started/views/GetStartedView.tsx b/apps/portal/src/features/get-started/views/GetStartedView.tsx index d6fa4fbd..84acd425 100644 --- a/apps/portal/src/features/get-started/views/GetStartedView.tsx +++ b/apps/portal/src/features/get-started/views/GetStartedView.tsx @@ -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( diff --git a/apps/portal/src/features/services/views/PublicVpnConfigure.tsx b/apps/portal/src/features/services/views/PublicVpnConfigure.tsx new file mode 100644 index 00000000..a6b4c798 --- /dev/null +++ b/apps/portal/src/features/services/views/PublicVpnConfigure.tsx @@ -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 ( +
+ +
+ + +
+
+ ); + } + + if (!plan) { + return ( +
+ + + The selected plan could not be found. Please go back and select a plan. + +
+ ); + } + + const region = getVpnRegionConfig(plan.name); + const isUS = region.accent === "blue"; + const isUK = region.accent === "red"; + + return ( +
+ + + {/* Header */} +
+
+
+ +
+
+

+ Order Your VPN Router +

+

+ Create an account to complete your order. Your pre-configured router ships upon + confirmation. +

+
+ + {/* Plan Summary Card */} +
+
+ Selected Plan +
+
+
+
+ {region.flag} +
+
+
+
+
+

{plan.name}

+

{region.location}

+
+ + VPN Router + + + {region.region} + +
+
+
+ +
+
+
+
+ + {/* Plan Details */} +
+
    + {region.features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+
+ + {/* Order process info */} +
+
+ +
+

How ordering works

+

+ 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. +

+
+
+
+ + {/* Auth Section */} + +
+ ); +} + +export default PublicVpnConfigureView; diff --git a/docker/prod-portainer/docker-compose.yml b/docker/prod-portainer/docker-compose.yml index 18442be8..0b3fb66a 100644 --- a/docker/prod-portainer/docker-compose.yml +++ b/docker/prod-portainer/docker-compose.yml @@ -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} diff --git a/docs/features/unified-get-started-flow.md b/docs/features/unified-get-started-flow.md index bad7ed9b..5d6a1a54 100644 --- a/docs/features/unified-get-started-flow.md +++ b/docs/features/unified-get-started-flow.md @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b5a0b4c..bd3e1630 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}