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:
barsa 2026-03-02 19:02:48 +09:00
parent 49e9dba3a3
commit 99761b21dd
6 changed files with 60 additions and 59 deletions

View File

@ -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: "" },

View File

@ -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) {

View File

@ -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">

View File

@ -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">

View File

@ -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 */}

View File

@ -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]);