feat(migrate-account): implement MigrateAccountStep for passwordless WHMCS account migration

This commit is contained in:
barsa 2026-01-20 19:00:20 +09:00
parent 464f98284a
commit 0ee1f00bf8

View File

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