diff --git a/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx b/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx index c4dde8e4..0c7dff1a 100644 --- a/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx +++ b/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx @@ -14,6 +14,29 @@ import { useZodForm } from "@/shared/hooks"; import { passwordResetRequestSchema, passwordResetSchema } from "@customer-portal/domain/auth"; import { z } from "zod"; +/** + * Frontend reset form schema - extends domain passwordResetSchema with confirmPassword + * + * Single source of truth: Domain layer defines validation rules + * Frontend only adds: confirmPassword field and password matching logic + */ +const resetFormSchema = passwordResetSchema + .extend({ + confirmPassword: z.string().min(1, "Please confirm your new password"), + }) + .superRefine((data, ctx) => { + if (data.password !== data.confirmPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["confirmPassword"], + message: "Passwords do not match", + }); + } + }); + +type ResetFormValues = z.infer; +type PasswordResetRequestData = z.infer; + interface PasswordResetFormProps { mode: "request" | "reset"; token?: string | undefined; @@ -33,9 +56,6 @@ export function PasswordResetForm({ }: PasswordResetFormProps) { const { requestPasswordReset, resetPassword, loading, error, clearError } = usePasswordReset(); - // Zod form for password reset request - uses domain schema - type PasswordResetRequestData = z.infer; - const requestForm = useZodForm({ schema: passwordResetRequestSchema, initialValues: { email: "" }, @@ -51,28 +71,6 @@ export function PasswordResetForm({ }, }); - /** - * Frontend reset form schema - extends domain passwordResetSchema with confirmPassword - * - * Single source of truth: Domain layer defines validation rules - * Frontend only adds: confirmPassword field and password matching logic - */ - const resetFormSchema = passwordResetSchema - .extend({ - confirmPassword: z.string().min(1, "Please confirm your new password"), - }) - .superRefine((data, ctx) => { - if (data.password !== data.confirmPassword) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["confirmPassword"], - message: "Passwords do not match", - }); - } - }); - - type ResetFormValues = z.infer; - const resetForm = useZodForm({ schema: resetFormSchema, initialValues: { token: token || "", password: "", confirmPassword: "" }, diff --git a/apps/portal/src/features/auth/stores/auth.store.ts b/apps/portal/src/features/auth/stores/auth.store.ts index 17de86b4..faf10ed0 100644 --- a/apps/portal/src/features/auth/stores/auth.store.ts +++ b/apps/portal/src/features/auth/stores/auth.store.ts @@ -60,6 +60,7 @@ export interface AuthState { checkAuth: () => Promise; clearError: () => void; clearLoading: () => void; + applyAuthResponse: (data: { user: AuthenticatedUser; session: AuthSession }) => void; hydrateUserProfile: (profile: Partial) => void; } @@ -513,6 +514,8 @@ export const useAuthStore = create()((set, get) => { clearLoading: () => set({ loading: false }), + applyAuthResponse, + hydrateUserProfile: profile => { set(state => { if (!state.user) { diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx index fc74ca1c..d6dceff7 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx @@ -1,20 +1,22 @@ /** * GetStartedForm - Main form component for the unified get-started flow * - * Flow: Email -> OTP Verification -> Account Status -> Complete Account -> Success + * Flow: Email -> OTP Verification -> Account Status -> Complete Account -> Dashboard */ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; import { useGetStartedMachine } from "../../hooks/useGetStartedMachine"; +import { useAuthStore } from "@/features/auth/stores/auth.store"; +import { getSafeRedirect } from "@/features/auth/utils/route-protection"; import { EmailStep, VerificationStep, AccountStatusStep, CompleteAccountStep, MigrateAccountStep, - SuccessStep, } from "./steps"; type StepName = string; @@ -65,6 +67,8 @@ interface GetStartedFormProps { export function GetStartedForm({ onStepChange }: GetStartedFormProps) { const { state, send } = useGetStartedMachine(); + const router = useRouter(); + const redirectInitiated = useRef(false); const topState = getTopLevelState(state.value); @@ -92,6 +96,22 @@ export function GetStartedForm({ onStepChange }: GetStartedFormProps) { } }, [topState, send]); + // On success: sync auth store and redirect directly to dashboard + useEffect(() => { + if (topState !== "success" || redirectInitiated.current) return; + redirectInitiated.current = true; + + const { authResponse, redirectTo, serviceContext } = state.context; + + // Sync auth response to global auth store so AppShell recognizes the session + if (authResponse) { + useAuthStore.getState().applyAuthResponse(authResponse); + } + + const destination = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account"); + router.push(destination); + }, [topState, router]); // eslint-disable-line react-hooks/exhaustive-deps -- guarded by redirectInitiated ref; state.context is read but doesn't need to trigger re-runs + switch (topState) { case "email": return ( @@ -125,11 +145,7 @@ export function GetStartedForm({ onStepChange }: GetStartedFormProps) { ); case "success": - return ( -
- -
- ); + return null; default: return (
diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx index 1c26ea33..d3d6e881 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx @@ -9,10 +9,8 @@ "use client"; import { Button } from "@/components/atoms"; -import { getSafeRedirect } from "@/features/auth/utils/route-protection"; import { TermsCheckbox, MarketingCheckbox } from "@/features/auth/components"; import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine"; -import { useRouter } from "next/navigation"; import { PrefilledUserInfo, NewCustomerFields, @@ -24,14 +22,11 @@ import { import type { GetStartedFormData } from "../../../machines/get-started.types"; export function CompleteAccountStep() { - const router = useRouter(); const { state, send } = useGetStartedMachine(); - const { formData, prefill, accountStatus, redirectTo, serviceContext, error } = state.context; + const { formData, prefill, accountStatus, error } = state.context; const loading = state.matches({ completeAccount: "loading" }); - const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account"); - const isNewCustomer = accountStatus === "new_customer"; const hasPrefill = !!(prefill?.firstName || prefill?.lastName); @@ -72,11 +67,6 @@ export function CompleteAccountStep() { send({ type: "COMPLETE", formData: completeFormData as GetStartedFormData }); }; - // Redirect on success - if (state.matches("success")) { - router.push(effectiveRedirectTo); - } - return (
diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/MigrateAccountStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/MigrateAccountStep.tsx index 432b9b5e..b5b49ec6 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/MigrateAccountStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/MigrateAccountStep.tsx @@ -10,10 +10,8 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; import { Button, Input, Label } from "@/components/atoms"; import { Checkbox } from "@/components/atoms/checkbox"; -import { getSafeRedirect } from "@/features/auth/utils/route-protection"; import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine"; interface FormErrors { @@ -23,15 +21,11 @@ interface FormErrors { } export function MigrateAccountStep() { - const router = useRouter(); const { state, send } = useGetStartedMachine(); - const { formData, prefill, redirectTo, serviceContext, error } = state.context; + const { formData, prefill, error } = state.context; const loading = state.matches({ migrateAccount: "loading" }); - // Compute effective redirect URL from machine context (with validation) - const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account"); - const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [acceptTerms, setAcceptTerms] = useState(formData.acceptTerms); @@ -82,11 +76,6 @@ export function MigrateAccountStep() { const canSubmit = password && confirmPassword && acceptTerms; - // Redirect on success - if (state.matches("success")) { - router.push(effectiveRedirectTo); - } - return (
{/* Header */} diff --git a/apps/portal/src/shared/hooks/useZodForm.ts b/apps/portal/src/shared/hooks/useZodForm.ts index 339c0e01..ed6004b1 100644 --- a/apps/portal/src/shared/hooks/useZodForm.ts +++ b/apps/portal/src/shared/hooks/useZodForm.ts @@ -3,7 +3,7 @@ * Provides predictable error and touched state handling for forms */ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import type { FormEvent } from "react"; import { ZodError, type ZodIssue, type ZodType } from "zod"; @@ -222,13 +222,18 @@ export function useZodForm>({ [validate, onSubmit, values] ); + // Use a ref so `reset` is referentially stable regardless of `initialValues` identity. + // This prevents infinite re-render loops when callers pass inline object literals. + const initialValuesRef = useRef(initialValues); + initialValuesRef.current = initialValues; + const reset = useCallback((): void => { - setValues(initialValues); + setValues(initialValuesRef.current); setErrors({}); setTouchedState({}); setSubmitError(null); setIsSubmitting(false); - }, [initialValues]); + }, []); const isValid = useMemo(() => Object.values(errors).every(error => !error), [errors]);