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