feat(migrate-account): implement MigrateAccountStep for passwordless WHMCS account migration
This commit is contained in:
parent
464f98284a
commit
0ee1f00bf8
@ -0,0 +1,364 @@
|
||||
/**
|
||||
* MigrateAccountStep - Migrate WHMCS account to portal (passwordless)
|
||||
*
|
||||
* For whmcs_unmapped users who have verified their email via OTP.
|
||||
* Email verification serves as identity proof - no legacy password needed.
|
||||
* Shows prefilled WHMCS data and collects password + required fields.
|
||||
*/
|
||||
|
||||
"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 { useGetStartedStore } from "../../../stores/get-started.store";
|
||||
|
||||
interface FormErrors {
|
||||
password?: string | undefined;
|
||||
confirmPassword?: string | undefined;
|
||||
dateOfBirth?: string | undefined;
|
||||
gender?: string | undefined;
|
||||
acceptTerms?: string | undefined;
|
||||
}
|
||||
|
||||
export function MigrateAccountStep() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
formData,
|
||||
updateFormData,
|
||||
migrateWhmcsAccount,
|
||||
prefill,
|
||||
loading,
|
||||
error,
|
||||
clearError,
|
||||
goBack,
|
||||
redirectTo,
|
||||
serviceContext,
|
||||
} = useGetStartedStore();
|
||||
|
||||
// Compute effective redirect URL from store state (with validation)
|
||||
const effectiveRedirectTo = getSafeRedirect(
|
||||
redirectTo || serviceContext?.redirectTo,
|
||||
"/account/dashboard"
|
||||
);
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [dateOfBirth, setDateOfBirth] = useState(formData.dateOfBirth);
|
||||
const [gender, setGender] = useState<"male" | "female" | "other" | "">(formData.gender);
|
||||
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 validate = (): boolean => {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
const passwordError = validatePassword(password);
|
||||
if (passwordError) {
|
||||
errors.password = passwordError;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errors.confirmPassword = "Passwords do not match";
|
||||
}
|
||||
|
||||
if (!dateOfBirth) {
|
||||
errors.dateOfBirth = "Date of birth is required";
|
||||
}
|
||||
|
||||
if (!gender) {
|
||||
errors.gender = "Please select a gender";
|
||||
}
|
||||
|
||||
if (!acceptTerms) {
|
||||
errors.acceptTerms = "You must accept the terms of service";
|
||||
}
|
||||
|
||||
setLocalErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
clearError();
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update form data
|
||||
updateFormData({
|
||||
password,
|
||||
confirmPassword,
|
||||
dateOfBirth,
|
||||
gender: gender as "male" | "female" | "other",
|
||||
acceptTerms,
|
||||
marketingConsent,
|
||||
});
|
||||
|
||||
const result = await migrateWhmcsAccount();
|
||||
if (result) {
|
||||
// Redirect to the effective redirect URL on success
|
||||
router.push(effectiveRedirectTo);
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = password && confirmPassword && dateOfBirth && gender && acceptTerms;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{prefill?.firstName
|
||||
? `Welcome back, ${prefill.firstName}! Set up your new portal password.`
|
||||
: "Set up your new portal password to continue."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pre-filled info display in gray disabled inputs */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">First Name</Label>
|
||||
<Input
|
||||
value={prefill?.firstName || formData.firstName || ""}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Last Name</Label>
|
||||
<Input
|
||||
value={prefill?.lastName || formData.lastName || ""}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Email</Label>
|
||||
<Input value={formData.email} disabled className="bg-muted text-muted-foreground" />
|
||||
</div>
|
||||
{(prefill?.phone || formData.phone) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Phone</Label>
|
||||
<Input
|
||||
value={prefill?.phone || formData.phone}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{prefill?.address && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Address</Label>
|
||||
<Input
|
||||
value={[
|
||||
prefill.address.postcode,
|
||||
prefill.address.state,
|
||||
prefill.address.city,
|
||||
prefill.address.address1,
|
||||
prefill.address.address2,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date of Birth */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateOfBirth">
|
||||
Date of Birth <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
value={dateOfBirth}
|
||||
onChange={e => {
|
||||
setDateOfBirth(e.target.value);
|
||||
setLocalErrors(prev => ({ ...prev, dateOfBirth: undefined }));
|
||||
}}
|
||||
disabled={loading}
|
||||
error={localErrors.dateOfBirth}
|
||||
max={new Date().toISOString().split("T")[0]}
|
||||
/>
|
||||
{localErrors.dateOfBirth && (
|
||||
<p className="text-sm text-danger">{localErrors.dateOfBirth}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gender */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Gender <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
{(["male", "female", "other"] as const).map(option => (
|
||||
<label key={option} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="gender"
|
||||
value={option}
|
||||
checked={gender === option}
|
||||
onChange={() => {
|
||||
setGender(option);
|
||||
setLocalErrors(prev => ({ ...prev, gender: undefined }));
|
||||
}}
|
||||
disabled={loading}
|
||||
className="h-4 w-4 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm capitalize">{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{localErrors.gender && <p className="text-sm text-danger">{localErrors.gender}</p>}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Password <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPassword(e.target.value);
|
||||
setLocalErrors(prev => ({ ...prev, password: undefined }));
|
||||
}}
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
error={localErrors.password}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<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 => {
|
||||
setConfirmPassword(e.target.value);
|
||||
setLocalErrors(prev => ({ ...prev, confirmPassword: undefined }));
|
||||
}}
|
||||
placeholder="Confirm your password"
|
||||
disabled={loading}
|
||||
error={localErrors.confirmPassword}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{localErrors.confirmPassword && (
|
||||
<p className="text-sm text-danger">{localErrors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms & Marketing */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="acceptTerms"
|
||||
checked={acceptTerms}
|
||||
onChange={e => {
|
||||
setAcceptTerms(e.target.checked);
|
||||
setLocalErrors(prev => ({ ...prev, acceptTerms: undefined }));
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label htmlFor="acceptTerms" className="text-sm font-normal leading-tight cursor-pointer">
|
||||
I accept the{" "}
|
||||
<a
|
||||
href="/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>{" "}
|
||||
<span className="text-danger">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
{localErrors.acceptTerms && (
|
||||
<p className="text-sm text-danger ml-6">{localErrors.acceptTerms}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="marketingConsent"
|
||||
checked={marketingConsent}
|
||||
onChange={e => setMarketingConsent(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="marketingConsent"
|
||||
className="text-sm font-normal leading-tight cursor-pointer"
|
||||
>
|
||||
I would like to receive marketing emails and updates
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-danger/10 border border-danger/20">
|
||||
<p className="text-sm text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !canSubmit}
|
||||
loading={loading}
|
||||
className="w-full h-11"
|
||||
>
|
||||
{loading ? "Setting Up Account..." : "Set Up Account"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={goBack}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user