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:
parent
73ef1d9825
commit
26a1419189
@ -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
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
37
apps/portal/src/components/atoms/password-input.tsx
Normal file
37
apps/portal/src/components/atoms/password-input.tsx
Normal 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 };
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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>}
|
||||
|
||||
77
apps/portal/src/features/auth/components/PasswordSection.tsx
Normal file
77
apps/portal/src/features/auth/components/PasswordSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user