mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
import { useState, useEffect, useMemo } from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { Input } from "@/shared/common/ui/input";
|
|
import { Button } from "@/shared/common/ui/button";
|
|
import { toast } from "sonner";
|
|
import { useAuthUser } from "../hooks/useAuthUser";
|
|
import { useErrorHandler } from "../hooks/useErrorHandler";
|
|
import { Lock, Mail, Eye, EyeOff, RefreshCw, ArrowLeft } from "lucide-react";
|
|
import { motion } from "framer-motion";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
const SetPasswordPage = () => {
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation();
|
|
const { handleError } = useErrorHandler(t);
|
|
const [searchParams] = useSearchParams();
|
|
const {
|
|
setPassword,
|
|
isSettingPassword,
|
|
setPasswordError,
|
|
setPasswordSuccess,
|
|
resendVerificationCode,
|
|
isResendingCode,
|
|
resendError,
|
|
resendSuccess,
|
|
} = useAuthUser();
|
|
|
|
const email = searchParams.get("email");
|
|
const verificationCode = searchParams.get("verificationCode") || "";
|
|
const phoneNumber = searchParams.get("phoneNumber");
|
|
const userId = searchParams.get("userId");
|
|
|
|
const [newPassword, setNewPassword] = useState("");
|
|
const [confirmPassword, setConfirmPassword] = useState("");
|
|
const [resendAttempted, setResendAttempted] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [submitAttempted, setSubmitAttempted] = useState(false);
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
// Password validation rules
|
|
const getPasswordValidation = (password: string) => {
|
|
return {
|
|
minLength: password.length >= 8,
|
|
hasLowercase: /[a-z]/.test(password),
|
|
hasUppercase: /[A-Z]/.test(password),
|
|
hasNumber: /\d/.test(password),
|
|
hasSymbol: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password),
|
|
};
|
|
};
|
|
|
|
const passwordValidation = useMemo(
|
|
() => getPasswordValidation(newPassword),
|
|
[newPassword]
|
|
);
|
|
const isPasswordStrong = Object.values(passwordValidation).every(Boolean);
|
|
const isPasswordMatch = confirmPassword.length > 0 && newPassword === confirmPassword;
|
|
const canSubmit = Boolean(newPassword && confirmPassword && isPasswordStrong && isPasswordMatch);
|
|
|
|
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const password = e.target.value;
|
|
setNewPassword(password);
|
|
if (error) setError("");
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setSubmitAttempted(true);
|
|
setError("");
|
|
|
|
if (!newPassword || !confirmPassword) {
|
|
setError("Please fill in both fields");
|
|
return;
|
|
}
|
|
|
|
// Validate password strength
|
|
if (!isPasswordStrong) {
|
|
const missingRequirements = [];
|
|
if (!passwordValidation.minLength) missingRequirements.push("at least 8 characters");
|
|
if (!passwordValidation.hasLowercase) missingRequirements.push("one lowercase letter");
|
|
if (!passwordValidation.hasUppercase) missingRequirements.push("one uppercase letter");
|
|
if (!passwordValidation.hasNumber) missingRequirements.push("one number");
|
|
if (!passwordValidation.hasSymbol) missingRequirements.push("one symbol (!@#$%^&*...)");
|
|
|
|
setError(`Password must contain: ${missingRequirements.join(", ")}`);
|
|
return;
|
|
}
|
|
|
|
if (!isPasswordMatch) {
|
|
setError("Passwords don't match");
|
|
return;
|
|
}
|
|
|
|
// Prepare payload - include userId and email when available
|
|
const payload: any = {
|
|
verificationCode,
|
|
newPassword,
|
|
confirmPassword,
|
|
};
|
|
|
|
if (userId) {
|
|
payload.userId = userId;
|
|
}
|
|
|
|
if (email) {
|
|
payload.email = email;
|
|
} else if (phoneNumber) {
|
|
// Apply the same phone number formatting logic
|
|
let processedPhoneNumber = phoneNumber;
|
|
if (processedPhoneNumber.startsWith("0")) {
|
|
processedPhoneNumber = "+251" + processedPhoneNumber.slice(1);
|
|
}
|
|
payload.phoneNumber = processedPhoneNumber;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
setPassword(payload);
|
|
};
|
|
|
|
const handleResendCode = () => {
|
|
if (!phoneNumber && !email) {
|
|
toast.error("User ID is missing. Cannot resend verification code.");
|
|
return;
|
|
}
|
|
setResendAttempted(true);
|
|
resendVerificationCode({ phoneNumber: phoneNumber || "", email: email || undefined });
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (setPasswordSuccess) {
|
|
toast.success("Password updated successfully! You can now login");
|
|
setIsSubmitting(false);
|
|
navigate("/login");
|
|
}
|
|
}, [setPasswordSuccess, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (!submitAttempted) return;
|
|
if (setPasswordError?.message) {
|
|
setIsSubmitting(false);
|
|
// Use handleError hook to process backend errors
|
|
handleError(setPasswordError);
|
|
}
|
|
}, [setPasswordError?.message, handleError, submitAttempted]);
|
|
|
|
useEffect(() => {
|
|
if (resendSuccess && resendAttempted) {
|
|
toast.success("Verification code resent successfully!");
|
|
setResendAttempted(false);
|
|
}
|
|
if (resendError && resendAttempted) {
|
|
// Use handleError hook to process backend errors
|
|
handleError(resendError);
|
|
setResendAttempted(false);
|
|
}
|
|
}, [resendSuccess, resendError, resendAttempted, handleError]);
|
|
|
|
return (
|
|
<div
|
|
className="flex items-center justify-center bg-gray-100 p-4"
|
|
style={{ height: "100vh" }}
|
|
>
|
|
<div className="w-full max-w-md bg-white rounded-xl shadow-lg p-8">
|
|
<div className="mb-8 text-center">
|
|
<img
|
|
src="/assets/smart-office-logo.svg"
|
|
alt="Smart Office Logo"
|
|
className="h-10 mb-6 mx-auto"
|
|
/>
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
|
Set Password
|
|
</h1>
|
|
<p className="text-sm text-gray-500">
|
|
Set a new password for your account
|
|
</p>
|
|
{email && (
|
|
<p className="text-xs text-gray-400 mt-1">Email: {email}</p>
|
|
)}
|
|
{phoneNumber && (
|
|
<p className="text-xs text-gray-400 mt-1">Phone: {phoneNumber}</p>
|
|
)}
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div className="space-y-4">
|
|
<div className="relative">
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<Lock className="h-5 w-5 text-gray-400" />
|
|
</div>
|
|
<Input
|
|
type={showPassword ? "text" : "password"}
|
|
placeholder="New Password"
|
|
className="h-10 rounded-md border px-4 text-sm ps-10 text-black placeholder:text-gray-500"
|
|
value={newPassword}
|
|
onChange={handlePasswordChange}
|
|
required
|
|
/>
|
|
<div
|
|
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
>
|
|
{showPassword ? (
|
|
<EyeOff className="h-4 w-4 text-gray-400" />
|
|
) : (
|
|
<Eye className="h-4 w-4 text-gray-400" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Password strength indicator */}
|
|
{newPassword && (
|
|
<div className="space-y-2 p-3 bg-gray-50 rounded-md border border-gray-200">
|
|
<p className="text-xs font-medium text-gray-700">Password Requirements:</p>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${passwordValidation.minLength ? 'bg-primary-500' : 'bg-gray-300'}`}>
|
|
{passwordValidation.minLength && <span className="text-white">✓</span>}
|
|
</div>
|
|
<span className={`text-xs ${passwordValidation.minLength ? 'text-primary-700' : 'text-gray-600'}`}>
|
|
At least 8 characters
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${passwordValidation.hasUppercase ? 'bg-primary-500' : 'bg-gray-300'}`}>
|
|
{passwordValidation.hasUppercase && <span className="text-white">✓</span>}
|
|
</div>
|
|
<span className={`text-xs ${passwordValidation.hasUppercase ? 'text-primary-700' : 'text-gray-600'}`}>
|
|
One uppercase letter (A-Z)
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${passwordValidation.hasLowercase ? 'bg-primary-500' : 'bg-gray-300'}`}>
|
|
{passwordValidation.hasLowercase && <span className="text-white">✓</span>}
|
|
</div>
|
|
<span className={`text-xs ${passwordValidation.hasLowercase ? 'text-primary-700' : 'text-gray-600'}`}>
|
|
One lowercase letter (a-z)
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${passwordValidation.hasNumber ? 'bg-primary-500' : 'bg-gray-300'}`}>
|
|
{passwordValidation.hasNumber && <span className="text-white">✓</span>}
|
|
</div>
|
|
<span className={`text-xs ${passwordValidation.hasNumber ? 'text-primary-700' : 'text-gray-600'}`}>
|
|
One number (0-9)
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${passwordValidation.hasSymbol ? 'bg-primary-500' : 'bg-gray-300'}`}>
|
|
{passwordValidation.hasSymbol && <span className="text-white">✓</span>}
|
|
</div>
|
|
<span className={`text-xs ${passwordValidation.hasSymbol ? 'text-primary-700' : 'text-gray-600'}`}>
|
|
One symbol (!@#$%^&*...)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="relative">
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<Lock className="h-5 w-5 text-gray-400" />
|
|
</div>
|
|
<Input
|
|
type={showConfirmPassword ? "text" : "password"}
|
|
placeholder="Confirm Password"
|
|
className="h-10 rounded-md border px-4 text-sm ps-10 text-black placeholder:text-gray-500"
|
|
value={confirmPassword}
|
|
onChange={(e) => {
|
|
setConfirmPassword(e.target.value);
|
|
if (error) setError("");
|
|
}}
|
|
required
|
|
/>
|
|
<div
|
|
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
|
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
|
>
|
|
{showConfirmPassword ? (
|
|
<EyeOff className="h-4 w-4 text-gray-400" />
|
|
) : (
|
|
<Eye className="h-4 w-4 text-gray-400" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4">
|
|
<Button
|
|
type="submit"
|
|
className="w-full h-10 bg-primary hover:bg-primary-300 text-white text-sm"
|
|
disabled={isSettingPassword || isSubmitting || !canSubmit}
|
|
>
|
|
{isSettingPassword ? (
|
|
<span className="flex items-center justify-center">
|
|
<RefreshCw className="animate-spin h-5 w-5 mr-2" />
|
|
Setting Password...
|
|
</span>
|
|
) : (
|
|
"Set Password"
|
|
)}
|
|
</Button>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full h-10 text-sm"
|
|
onClick={() => navigate("/")}
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back to Login
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
|
|
<div className="mt-6 text-center">
|
|
<button
|
|
type="button"
|
|
onClick={handleResendCode}
|
|
disabled={isResendingCode || !email || !phoneNumber || isSubmitting}
|
|
className="text-sm text-primary-600 hover:text-primary-800 font-medium flex items-center justify-center space-x-1 mx-auto"
|
|
>
|
|
{isResendingCode ? (
|
|
<>
|
|
<RefreshCw className="animate-spin h-4 w-4 text-primary-600 mr-2" />
|
|
<span>Sending...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Mail className="h-4 w-4 mr-2" />
|
|
<span>Resend Verification Code</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SetPasswordPage;
|