refactor: enhance password reset form validation and auth store integration
- Introduced a new reset form schema that extends the domain passwordResetSchema to include a confirmPassword field with matching logic. - Updated the PasswordResetForm component to utilize the new schema for improved validation. - Added applyAuthResponse method to the auth store for syncing authentication responses. - Refactored GetStartedForm to redirect users to the dashboard upon successful account setup, integrating with the auth store for session management. - Removed unnecessary redirection logic from CompleteAccountStep and MigrateAccountStep components to streamline the flow.
This commit is contained in:
parent
49e9dba3a3
commit
99761b21dd
@ -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<typeof resetFormSchema>;
|
||||
type PasswordResetRequestData = z.infer<typeof passwordResetRequestSchema>;
|
||||
|
||||
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<typeof passwordResetRequestSchema>;
|
||||
|
||||
const requestForm = useZodForm<PasswordResetRequestData>({
|
||||
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<typeof resetFormSchema>;
|
||||
|
||||
const resetForm = useZodForm<ResetFormValues>({
|
||||
schema: resetFormSchema,
|
||||
initialValues: { token: token || "", password: "", confirmPassword: "" },
|
||||
|
||||
@ -60,6 +60,7 @@ export interface AuthState {
|
||||
checkAuth: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
clearLoading: () => void;
|
||||
applyAuthResponse: (data: { user: AuthenticatedUser; session: AuthSession }) => void;
|
||||
hydrateUserProfile: (profile: Partial<AuthenticatedUser>) => void;
|
||||
}
|
||||
|
||||
@ -513,6 +514,8 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
|
||||
clearLoading: () => set({ loading: false }),
|
||||
|
||||
applyAuthResponse,
|
||||
|
||||
hydrateUserProfile: profile => {
|
||||
set(state => {
|
||||
if (!state.user) {
|
||||
|
||||
@ -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) {
|
||||
</div>
|
||||
);
|
||||
case "success":
|
||||
return (
|
||||
<div className="w-full">
|
||||
<SuccessStep />
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
default:
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
|
||||
@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
|
||||
@ -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<TValues extends Record<string, unknown>>({
|
||||
[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]);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user