refactor: integrate PasswordInput component across forms

- Replaced standard input fields with the new PasswordInput component in various forms including PasswordChangeCard, LoginForm, PasswordResetForm, SetPasswordForm, and MigrateAccountStep.
- Updated imports to include PasswordInput in relevant components for consistent password handling and improved user experience.
This commit is contained in:
barsa 2026-03-03 15:37:51 +09:00
parent 73ef1d9825
commit 26a1419189
16 changed files with 287 additions and 269 deletions

View File

@ -155,18 +155,40 @@ export class SalesforceAccountService {
* Check if a Salesforce account exists with the given email
* Used to prevent duplicate account creation during checkout
*/
async findByEmail(email: string): Promise<{ id: string; accountNumber: string } | null> {
async findByEmail(email: string): Promise<FindByEmailResult | null> {
try {
// Search for Contact with matching email and get the associated Account
// Search for Contact with matching email and get the associated Account + Contact details
const result = (await this.connection.query(
`SELECT Account.Id, Account.SF_Account_No__c FROM Contact WHERE Email = '${this.safeSoql(email)}' LIMIT 1`,
`SELECT Account.Id, Account.SF_Account_No__c, FirstName, LastName, MailingStreet, MailingCity, MailingState, MailingPostalCode, MailingCountry FROM Contact WHERE Email = '${this.safeSoql(email)}' LIMIT 1`,
{ label: "checkout:findAccountByEmail" }
)) as SalesforceResponse<{ Account: { Id: string; SF_Account_No__c: string } }>;
)) as SalesforceResponse<{
Account: { Id: string; SF_Account_No__c: string };
FirstName?: string | null;
LastName?: string | null;
MailingStreet?: string | null;
MailingCity?: string | null;
MailingState?: string | null;
MailingPostalCode?: string | null;
MailingCountry?: string | null;
}>;
if (result.totalSize > 0 && result.records[0]?.Account) {
const record = result.records[0];
const hasAddress = record.MailingCity || record.MailingState || record.MailingPostalCode;
return {
id: result.records[0].Account.Id,
accountNumber: result.records[0].Account.SF_Account_No__c,
id: record.Account.Id,
accountNumber: record.Account.SF_Account_No__c,
...(record.FirstName && { firstName: record.FirstName }),
...(record.LastName && { lastName: record.LastName }),
...(hasAddress && {
address: {
address1: record.MailingStreet || "",
city: record.MailingCity || "",
state: record.MailingState || "",
postcode: record.MailingPostalCode || "",
country: record.MailingCountry || "JP",
},
}),
};
}
@ -530,6 +552,23 @@ export interface SalesforceAccountPortalUpdate {
whmcsAccountId?: string | number | null;
}
/**
* Result from findByEmail includes Contact name/address when available
*/
export interface FindByEmailResult {
id: string;
accountNumber: string;
firstName?: string;
lastName?: string;
address?: {
address1: string;
city: string;
state: string;
postcode: string;
country: string;
};
}
/**
* Request type for creating a new Salesforce Account
*/

View File

@ -25,11 +25,20 @@ import { GetStartedSessionService } from "../otp/get-started-session.service.js"
* Result of account status detection across Portal, WHMCS, and Salesforce.
* Optional fields are populated based on the detected status:
* - WHMCS_UNMAPPED: whmcsClientId, whmcsFirstName, whmcsLastName
* - SF_UNMAPPED: sfAccountId
* - SF_UNMAPPED: sfAccountId, sfFirstName, sfLastName, sfAddress
*/
interface AccountStatusResult {
status: AccountStatus;
sfAccountId?: string;
sfFirstName?: string;
sfLastName?: string;
sfAddress?: {
address1: string;
city: string;
state: string;
postcode: string;
country: string;
};
whmcsClientId?: number;
whmcsFirstName?: string;
whmcsLastName?: string;
@ -233,7 +242,13 @@ export class VerificationWorkflowService {
if (mapping) {
return { status: ACCOUNT_STATUS.PORTAL_EXISTS };
}
return { status: ACCOUNT_STATUS.SF_UNMAPPED, sfAccountId: sfAccount.id };
return {
status: ACCOUNT_STATUS.SF_UNMAPPED,
sfAccountId: sfAccount.id,
...(sfAccount.firstName && { sfFirstName: sfAccount.firstName }),
...(sfAccount.lastName && { sfLastName: sfAccount.lastName }),
...(sfAccount.address && { sfAddress: sfAccount.address }),
};
}
return { status: ACCOUNT_STATUS.NEW_CUSTOMER };
@ -251,7 +266,12 @@ export class VerificationWorkflowService {
};
}
if (accountStatus.status === ACCOUNT_STATUS.SF_UNMAPPED && accountStatus.sfAccountId) {
return { email };
return {
email,
...(accountStatus.sfFirstName && { firstName: accountStatus.sfFirstName }),
...(accountStatus.sfLastName && { lastName: accountStatus.sfLastName }),
...(accountStatus.sfAddress && { address: accountStatus.sfAddress }),
};
}
return undefined;
}

View File

@ -10,6 +10,8 @@ export type { ButtonProps } from "./button";
export { Input } from "./input";
export type { InputProps } from "./input";
export { PasswordInput } from "./password-input";
export { Checkbox } from "./checkbox";
export type { CheckboxProps } from "./checkbox";

View File

@ -0,0 +1,37 @@
"use client";
import { forwardRef, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@/shared/utils";
import { Input } from "./input";
import type { InputProps } from "./input";
const PasswordInput = forwardRef<HTMLInputElement, Omit<InputProps, "type">>(
({ className, ...props }, ref) => {
const [visible, setVisible] = useState(false);
return (
<div className="relative">
<Input
type={visible ? "text" : "password"}
className={cn("pr-11", className)}
ref={ref}
{...props}
/>
<button
type="button"
tabIndex={-1}
onClick={() => setVisible(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors disabled:pointer-events-none disabled:opacity-50"
disabled={props.disabled}
aria-label={visible ? "Hide password" : "Show password"}
>
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
);
}
);
PasswordInput.displayName = "PasswordInput";
export { PasswordInput };

View File

@ -1,6 +1,9 @@
"use client";
import { PasswordInput, Button, Label } from "@/components/atoms";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { PasswordRequirements, PasswordMatchIndicator } from "@/features/auth/components";
import { usePasswordValidation } from "@/features/auth/hooks/usePasswordValidation";
interface PasswordChangeCardProps {
isChanging: boolean;
@ -19,65 +22,71 @@ export function PasswordChangeCard({
setForm,
onSubmit,
}: PasswordChangeCardProps) {
const { checks } = usePasswordValidation(form.newPassword);
const showPasswordMatch = form.confirmPassword.length > 0;
const passwordsMatch = form.newPassword === form.confirmPassword;
return (
<SubCard>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Change Password</h2>
<h2 className="text-xl font-semibold text-foreground mb-4">Change Password</h2>
{success && (
<div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
<div className="mb-4 bg-success-bg border border-success-border text-success px-4 py-3 rounded">
{success}
</div>
)}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
<div className="mb-4 bg-danger-bg border border-danger-border text-danger px-4 py-3 rounded">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Current Password</label>
<input
type="password"
<Label className="mb-2">Current Password</Label>
<PasswordInput
value={form.currentPassword}
onChange={e => setForm({ currentPassword: e.target.value })}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="••••••••"
disabled={isChanging}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">New Password</label>
<input
type="password"
<Label className="mb-2">New Password</Label>
<PasswordInput
value={form.newPassword}
onChange={e => setForm({ newPassword: e.target.value })}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="New secure password"
disabled={isChanging}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirm New Password
</label>
<input
type="password"
<Label className="mb-2">Confirm New Password</Label>
<PasswordInput
value={form.confirmPassword}
onChange={e => setForm({ confirmPassword: e.target.value })}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Re-enter new password"
disabled={isChanging}
/>
</div>
</div>
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-200">
<button
<div className="mt-3">
<PasswordRequirements checks={checks} showHint={form.newPassword.length === 0} />
</div>
{showPasswordMatch && (
<div className="mt-2">
<PasswordMatchIndicator passwordsMatch={passwordsMatch} />
</div>
)}
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border">
<Button
type="button"
onClick={onSubmit}
disabled={isChanging}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 transition-colors"
loading={isChanging}
loadingText="Changing..."
>
{isChanging ? "Changing..." : "Change Password"}
</button>
Change Password
</Button>
</div>
<p className="text-xs text-gray-500 mt-3">
Password must be at least 8 characters and include uppercase, lowercase, and a number.
</p>
</SubCard>
);
}

View File

@ -13,7 +13,7 @@
import { useState, useCallback } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Button, Input, ErrorMessage } from "@/components/atoms";
import { Button, Input, PasswordInput, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { LoginOtpStep } from "../LoginOtpStep";
import { useLoginWithOtp } from "../../hooks/use-auth";
@ -179,8 +179,7 @@ export function LoginForm({
error={touched["password"] ? errors["password"] : undefined}
required
>
<Input
type="password"
<PasswordInput
value={values.password}
onChange={e => setValue("password", e.target.value)}
onBlur={() => setTouchedField("password")}

View File

@ -7,9 +7,11 @@
import { useEffect } from "react";
import Link from "next/link";
import { Button, Input, ErrorMessage } from "@/components/atoms";
import { Button, Input, PasswordInput, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { PasswordRequirements, PasswordMatchIndicator } from "..";
import { usePasswordReset } from "../../hooks/use-auth";
import { usePasswordValidation } from "../../hooks/usePasswordValidation";
import { useZodForm } from "@/shared/hooks";
import { passwordResetRequestSchema, passwordResetSchema } from "@customer-portal/domain/auth";
import { z } from "zod";
@ -87,6 +89,11 @@ export function PasswordResetForm({
},
});
const { checks } = usePasswordValidation(resetForm.values.password);
const showPasswordMatch =
resetForm.values.confirmPassword.length > 0 && !resetForm.errors["confirmPassword"];
const passwordsMatch = resetForm.values.password === resetForm.values.confirmPassword;
// Extract stable reset functions to avoid unnecessary effect runs.
// The form objects change when internal state changes, but reset is stable.
const requestFormReset = requestForm.reset;
@ -161,8 +168,7 @@ export function PasswordResetForm({
error={resetForm.touched["password"] ? resetForm.errors["password"] : undefined}
required
>
<Input
type="password"
<PasswordInput
placeholder="Enter new password"
value={resetForm.values.password}
onChange={e => resetForm.setValue("password", e.target.value)}
@ -170,6 +176,7 @@ export function PasswordResetForm({
disabled={loading || resetForm.isSubmitting}
className={resetForm.errors["password"] ? "border-red-300" : ""}
/>
<PasswordRequirements checks={checks} showHint={resetForm.values.password.length === 0} />
</FormField>
<FormField
@ -179,8 +186,7 @@ export function PasswordResetForm({
}
required
>
<Input
type="password"
<PasswordInput
placeholder="Confirm new password"
value={resetForm.values.confirmPassword}
onChange={e => resetForm.setValue("confirmPassword", e.target.value)}
@ -188,6 +194,7 @@ export function PasswordResetForm({
disabled={loading || resetForm.isSubmitting}
className={resetForm.errors["confirmPassword"] ? "border-red-300" : ""}
/>
{showPasswordMatch && <PasswordMatchIndicator passwordsMatch={passwordsMatch} />}
</FormField>
{error && <ErrorMessage>{error}</ErrorMessage>}

View File

@ -0,0 +1,77 @@
"use client";
import { PasswordInput, Label, ErrorMessage } from "@/components/atoms";
import { PasswordRequirements } from "./PasswordRequirements";
import { PasswordMatchIndicator } from "./PasswordMatchIndicator";
import { usePasswordValidation } from "../hooks/usePasswordValidation";
interface PasswordSectionProps {
password: string;
confirmPassword: string;
onPasswordChange: (value: string) => void;
onConfirmPasswordChange: (value: string) => void;
errors: {
password?: string | undefined;
confirmPassword?: string | undefined;
};
clearError: (field: "password" | "confirmPassword") => void;
loading: boolean;
}
export function PasswordSection({
password,
confirmPassword,
onPasswordChange,
onConfirmPasswordChange,
errors,
clearError,
loading,
}: PasswordSectionProps) {
const { checks } = usePasswordValidation(password);
const showPasswordMatch = confirmPassword.length > 0 && !errors.confirmPassword;
const passwordsMatch = password === confirmPassword;
return (
<>
<div className="space-y-2">
<Label htmlFor="password">
Password <span className="text-danger">*</span>
</Label>
<PasswordInput
id="password"
value={password}
onChange={e => {
onPasswordChange(e.target.value);
clearError("password");
}}
placeholder="Create a strong password"
disabled={loading}
error={errors.password}
autoComplete="new-password"
/>
<ErrorMessage>{errors.password}</ErrorMessage>
<PasswordRequirements checks={checks} showHint={password.length === 0} />
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">
Confirm Password <span className="text-danger">*</span>
</Label>
<PasswordInput
id="confirmPassword"
value={confirmPassword}
onChange={e => {
onConfirmPasswordChange(e.target.value);
clearError("confirmPassword");
}}
placeholder="Confirm your password"
disabled={loading}
error={errors.confirmPassword}
autoComplete="new-password"
/>
<ErrorMessage>{errors.confirmPassword}</ErrorMessage>
{showPasswordMatch && <PasswordMatchIndicator passwordsMatch={passwordsMatch} />}
</div>
</>
);
}

View File

@ -6,15 +6,13 @@
import { useEffect } from "react";
import Link from "next/link";
import { Button, Input, ErrorMessage } from "@/components/atoms";
import { Button, Input, PasswordInput, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { PasswordRequirements, PasswordMatchIndicator } from "..";
import { useAuth } from "../../hooks/use-auth";
import { usePasswordValidation } from "../../hooks/usePasswordValidation";
import { useZodForm } from "@/shared/hooks";
import {
setPasswordRequestSchema,
checkPasswordStrength,
getPasswordStrengthDisplay,
} from "@customer-portal/domain/auth";
import { setPasswordRequestSchema, getPasswordStrengthDisplay } from "@customer-portal/domain/auth";
import { z } from "zod";
// Extend domain schema with confirmPassword
@ -55,9 +53,11 @@ export function SetPasswordForm({
},
});
const { requirements, strength, isValid } = checkPasswordStrength(form.values.password);
const { checks, strength, isValid } = usePasswordValidation(form.values.password);
const { label, colorClass } = getPasswordStrengthDisplay(strength);
const passwordsMatch = form.values.password === form.values.confirmPassword;
const showPasswordMatch =
form.values.confirmPassword.length > 0 && !form.errors["confirmPassword"];
const isLoading = loading || form.isSubmitting;
const isEmailProvided = Boolean(email);
@ -95,11 +95,10 @@ export function SetPasswordForm({
error={form.touched["password"] ? form.errors["password"] : undefined}
required
>
<Input
type="password"
<PasswordInput
value={form.values.password}
onChange={e => form.setValue("password", e.target.value)}
onBlur={() => form.setTouched("password", true)}
onBlur={() => form.setTouchedField("password")}
placeholder="Create a secure password"
disabled={isLoading}
autoComplete="new-password"
@ -122,16 +121,7 @@ export function SetPasswordForm({
{label}
</span>
</div>
<div className="grid grid-cols-2 gap-1">
{requirements.map(r => (
<div key={r.key} className="flex items-center gap-1.5 text-xs">
<span className={r.met ? "text-success" : "text-muted-foreground/60"}>
{r.met ? "✓" : "○"}
</span>
<span className={r.met ? "text-success" : "text-muted-foreground"}>{r.label}</span>
</div>
))}
</div>
<PasswordRequirements checks={checks} />
</div>
)}
@ -140,22 +130,17 @@ export function SetPasswordForm({
error={form.touched["confirmPassword"] ? form.errors["confirmPassword"] : undefined}
required
>
<Input
type="password"
<PasswordInput
value={form.values.confirmPassword}
onChange={e => form.setValue("confirmPassword", e.target.value)}
onBlur={() => form.setTouched("confirmPassword", true)}
onBlur={() => form.setTouchedField("confirmPassword")}
placeholder="Re-enter your password"
disabled={isLoading}
autoComplete="new-password"
/>
</FormField>
{form.values.confirmPassword && (
<p className={`text-sm ${passwordsMatch ? "text-success" : "text-danger"}`}>
{passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
</p>
)}
{showPasswordMatch && <PasswordMatchIndicator passwordsMatch={passwordsMatch} />}
{(error || form.errors["_form"]) && (
<ErrorMessage>{form.errors["_form"] || error}</ErrorMessage>

View File

@ -10,6 +10,7 @@ export { SetPasswordForm } from "./SetPasswordForm/SetPasswordForm";
export { AuthLayout } from "@/components/templates/AuthLayout";
// Account creation components
export { PasswordSection } from "./PasswordSection";
export { PasswordRequirements } from "./PasswordRequirements";
export { PasswordMatchIndicator } from "./PasswordMatchIndicator";
export { TermsCheckbox } from "./TermsCheckbox";

View File

@ -1,4 +1,5 @@
import { useMemo } from "react";
import { checkPasswordStrength } from "@customer-portal/domain/auth";
export interface PasswordChecks {
minLength: boolean;
@ -9,32 +10,31 @@ export interface PasswordChecks {
export interface PasswordValidation {
checks: PasswordChecks;
strength: number;
isValid: boolean;
error: string | undefined;
}
export function validatePasswordRules(password: string): string | undefined {
if (!password) return "Password is required";
if (password.length < 8) return "Password must be at least 8 characters";
if (!/[A-Z]/.test(password)) return "Password must contain an uppercase letter";
if (!/[a-z]/.test(password)) return "Password must contain a lowercase letter";
if (!/[0-9]/.test(password)) return "Password must contain a number";
return undefined;
}
export function usePasswordValidation(password: string): PasswordValidation {
return useMemo(() => {
const { requirements, strength, isValid } = checkPasswordStrength(password);
const byKey = Object.fromEntries(requirements.map(r => [r.key, r.met]));
const checks: PasswordChecks = {
minLength: password.length >= 8,
hasUppercase: /[A-Z]/.test(password),
hasLowercase: /[a-z]/.test(password),
hasNumber: /[0-9]/.test(password),
minLength: byKey["minLength"] ?? false,
hasUppercase: byKey["uppercase"] ?? false,
hasLowercase: byKey["lowercase"] ?? false,
hasNumber: byKey["number"] ?? false,
};
const isValid =
checks.minLength && checks.hasUppercase && checks.hasLowercase && checks.hasNumber;
const error = validatePasswordRules(password);
const firstFailing = requirements.find(r => !r.met);
let error: string | undefined;
if (password.length === 0) {
error = "Password is required";
} else if (firstFailing) {
error = firstFailing.label;
}
return { checks, isValid, error };
return { checks, strength, isValid, error };
}, [password]);
}

View File

@ -10,8 +10,10 @@
"use client";
import { useState } from "react";
import { Button, Input, Label } from "@/components/atoms";
import { Button, Input, PasswordInput, Label } from "@/components/atoms";
import { Checkbox } from "@/components/atoms/checkbox";
import { PasswordRequirements, PasswordMatchIndicator } from "@/features/auth/components";
import { usePasswordValidation } from "@/features/auth/hooks/usePasswordValidation";
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
interface FormErrors {
@ -31,22 +33,15 @@ export function MigrateAccountStep() {
const [acceptTerms, setAcceptTerms] = useState(formData.acceptTerms);
const [marketingConsent, setMarketingConsent] = useState(formData.marketingConsent);
const [localErrors, setLocalErrors] = useState<FormErrors>({});
const validatePassword = (pass: string): string | undefined => {
if (!pass) return "Password is required";
if (pass.length < 8) return "Password must be at least 8 characters";
if (!/[A-Z]/.test(pass)) return "Password must contain an uppercase letter";
if (!/[a-z]/.test(pass)) return "Password must contain a lowercase letter";
if (!/[0-9]/.test(pass)) return "Password must contain a number";
return undefined;
};
const { checks, error: passwordValidationError } = usePasswordValidation(password);
const showPasswordMatch = confirmPassword.length > 0 && !localErrors.confirmPassword;
const passwordsMatch = password === confirmPassword;
const validate = (): boolean => {
const errors: FormErrors = {};
const passwordError = validatePassword(password);
if (passwordError) {
errors.password = passwordError;
if (passwordValidationError) {
errors.password = passwordValidationError;
}
if (password !== confirmPassword) {
@ -146,9 +141,8 @@ export function MigrateAccountStep() {
<Label htmlFor="password">
Password <span className="text-danger">*</span>
</Label>
<Input
<PasswordInput
id="password"
type="password"
value={password}
onChange={e => {
setPassword(e.target.value);
@ -160,9 +154,7 @@ export function MigrateAccountStep() {
autoComplete="new-password"
/>
{localErrors.password && <p className="text-sm text-danger">{localErrors.password}</p>}
<p className="text-xs text-muted-foreground">
At least 8 characters with uppercase, lowercase, and numbers
</p>
<PasswordRequirements checks={checks} showHint={password.length === 0} />
</div>
{/* Confirm Password */}
@ -170,9 +162,8 @@ export function MigrateAccountStep() {
<Label htmlFor="confirmPassword">
Confirm Password <span className="text-danger">*</span>
</Label>
<Input
<PasswordInput
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={e => {
setConfirmPassword(e.target.value);
@ -186,6 +177,7 @@ export function MigrateAccountStep() {
{localErrors.confirmPassword && (
<p className="text-sm text-danger">{localErrors.confirmPassword}</p>
)}
{showPasswordMatch && <PasswordMatchIndicator passwordsMatch={passwordsMatch} />}
</div>
{/* Terms & Marketing */}

View File

@ -1,73 +1 @@
"use client";
import { Input, Label } from "@/components/atoms";
import { PasswordRequirements } from "@/features/auth/components";
import { usePasswordValidation } from "@/features/auth/hooks/usePasswordValidation";
import type { AccountFormErrors } from "./types";
interface PasswordSectionProps {
password: string;
confirmPassword: string;
onPasswordChange: (value: string) => void;
onConfirmPasswordChange: (value: string) => void;
errors: AccountFormErrors;
clearError: (field: keyof AccountFormErrors) => void;
loading: boolean;
}
export function PasswordSection({
password,
confirmPassword,
onPasswordChange,
onConfirmPasswordChange,
errors,
clearError,
loading,
}: PasswordSectionProps) {
const { checks } = usePasswordValidation(password);
return (
<>
<div className="space-y-2">
<Label htmlFor="password">
Password <span className="text-danger">*</span>
</Label>
<Input
id="password"
type="password"
value={password}
onChange={e => {
onPasswordChange(e.target.value);
clearError("password");
}}
placeholder="Create a strong password"
disabled={loading}
error={errors.password}
autoComplete="new-password"
/>
{errors.password && <p className="text-sm text-danger">{errors.password}</p>}
<PasswordRequirements checks={checks} showHint={password.length === 0} />
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">
Confirm Password <span className="text-danger">*</span>
</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={e => {
onConfirmPasswordChange(e.target.value);
clearError("confirmPassword");
}}
placeholder="Confirm your password"
disabled={loading}
error={errors.confirmPassword}
autoComplete="new-password"
/>
{errors.confirmPassword && <p className="text-sm text-danger">{errors.confirmPassword}</p>}
</div>
</>
);
}
export { PasswordSection } from "@/features/auth/components";

View File

@ -1,7 +1,7 @@
import { useState, useCallback } from "react";
import { type JapanAddressFormData } from "@/features/address/components/JapanAddressForm";
import { prepareWhmcsAddressFields } from "@customer-portal/domain/address";
import { validatePasswordRules } from "@/features/auth/hooks/usePasswordValidation";
import { usePasswordValidation } from "@/features/auth/hooks/usePasswordValidation";
import { phoneSchema } from "@customer-portal/domain/common";
import type { AccountFormErrors } from "./types";
@ -54,6 +54,7 @@ export function useCompleteAccountForm({
const [acceptTerms, setAcceptTerms] = useState(initialValues.acceptTerms || false);
const [marketingConsent, setMarketingConsent] = useState(initialValues.marketingConsent || false);
const [errors, setErrors] = useState<AccountFormErrors>({});
const { error: passwordValidationError } = usePasswordValidation(password);
const clearError = useCallback((field: keyof AccountFormErrors) => {
setErrors(prev => ({ ...prev, [field]: undefined }));
@ -88,8 +89,7 @@ export function useCompleteAccountForm({
newErrors.address = "Please complete the address";
}
const passwordError = validatePasswordRules(password);
if (passwordError) newErrors.password = passwordError;
if (passwordValidationError) newErrors.password = passwordValidationError;
if (password !== confirmPassword) newErrors.confirmPassword = "Passwords do not match";
if (phone.trim()) {
const phoneResult = phoneSchema.safeParse(phone.trim());
@ -111,6 +111,7 @@ export function useCompleteAccountForm({
firstName,
lastName,
isAddressComplete,
passwordValidationError,
password,
confirmPassword,
phone,

View File

@ -11,10 +11,7 @@ import { useState, useCallback } from "react";
import { ArrowLeft } from "lucide-react";
import { Button, ErrorMessage } from "@/components/atoms";
import { TermsCheckbox, MarketingCheckbox } from "@/features/auth/components";
import {
validatePasswordRules,
usePasswordValidation,
} from "@/features/auth/hooks/usePasswordValidation";
import { usePasswordValidation } from "@/features/auth/hooks/usePasswordValidation";
import { phoneSchema } from "@customer-portal/domain/common";
import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store";
import { AccountInfoDisplay, PersonalInfoFields, PasswordSection } from "./complete-account";
@ -50,14 +47,15 @@ export function CompleteAccountStep() {
});
}, []);
const { isValid: isPasswordValid } = usePasswordValidation(accountData.password);
const { isValid: isPasswordValid, error: passwordValidationError } = usePasswordValidation(
accountData.password
);
const doPasswordsMatch = accountData.password === accountData.confirmPassword;
const validateAccountForm = useCallback((): boolean => {
const errors: AccountFormErrors = {};
const passwordError = validatePasswordRules(accountData.password);
if (passwordError) errors.password = passwordError;
if (passwordValidationError) errors.password = passwordValidationError;
if (accountData.password !== accountData.confirmPassword)
errors.confirmPassword = "Passwords do not match";
if (accountData.phone.trim()) {
@ -74,7 +72,7 @@ export function CompleteAccountStep() {
setAccountErrors(errors);
return Object.keys(errors).length === 0;
}, [accountData]);
}, [accountData, passwordValidationError]);
const handleSubmit = async () => {
if (!validateAccountForm()) return;

View File

@ -1,78 +1 @@
"use client";
import { Input, Label, ErrorMessage } from "@/components/atoms";
import { PasswordRequirements, PasswordMatchIndicator } from "@/features/auth/components";
import { usePasswordValidation } from "@/features/auth/hooks/usePasswordValidation";
interface PasswordSectionProps {
password: string;
confirmPassword: string;
onPasswordChange: (value: string) => void;
onConfirmPasswordChange: (value: string) => void;
errors: {
password?: string | undefined;
confirmPassword?: string | undefined;
};
clearError: (field: "password" | "confirmPassword") => void;
loading: boolean;
}
export function PasswordSection({
password,
confirmPassword,
onPasswordChange,
onConfirmPasswordChange,
errors,
clearError,
loading,
}: PasswordSectionProps) {
const { checks } = usePasswordValidation(password);
const showPasswordMatch = confirmPassword.length > 0 && !errors.confirmPassword;
const passwordsMatch = password === confirmPassword;
return (
<>
<div className="space-y-2">
<Label htmlFor="password">
Password <span className="text-danger">*</span>
</Label>
<Input
id="password"
type="password"
value={password}
onChange={e => {
onPasswordChange(e.target.value);
clearError("password");
}}
placeholder="Create a strong password"
disabled={loading}
error={errors.password}
autoComplete="new-password"
/>
<ErrorMessage>{errors.password}</ErrorMessage>
<PasswordRequirements checks={checks} showHint={password.length === 0} />
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">
Confirm Password <span className="text-danger">*</span>
</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={e => {
onConfirmPasswordChange(e.target.value);
clearError("confirmPassword");
}}
placeholder="Confirm your password"
disabled={loading}
error={errors.confirmPassword}
autoComplete="new-password"
/>
<ErrorMessage>{errors.confirmPassword}</ErrorMessage>
{showPasswordMatch && <PasswordMatchIndicator passwordsMatch={passwordsMatch} />}
</div>
</>
);
}
export { PasswordSection } from "@/features/auth/components";