From 7a51cb2d16f46226a5313c1d90fab4bc3cd62e39 Mon Sep 17 00:00:00 2001 From: shamoo53 Date: Thu, 26 Mar 2026 10:02:19 +0100 Subject: [PATCH] Implement-Protected-Route-HO Implement-Protected-Route-HO --- .../forgot-password/ForgotPasswordForm.tsx | 211 ++++++++ app/auth/forgot-password/page.tsx | 12 + app/auth/reset-password/ResetPasswordForm.tsx | 475 ++++++++++++++++++ app/auth/reset-password/page.tsx | 11 + app/auth/signup/SignupForm.tsx | 459 +++++++++-------- components/passwordInput.tsx | 177 ++++--- components/ui/Toast.tsx | 12 +- features/authThunk.ts | 115 ----- features/authValidation.ts | 63 +-- lib/api/auth.ts | 63 ++- 10 files changed, 1165 insertions(+), 433 deletions(-) create mode 100644 app/auth/forgot-password/ForgotPasswordForm.tsx create mode 100644 app/auth/forgot-password/page.tsx create mode 100644 app/auth/reset-password/ResetPasswordForm.tsx create mode 100644 app/auth/reset-password/page.tsx delete mode 100644 features/authThunk.ts diff --git a/app/auth/forgot-password/ForgotPasswordForm.tsx b/app/auth/forgot-password/ForgotPasswordForm.tsx new file mode 100644 index 0000000..818eb40 --- /dev/null +++ b/app/auth/forgot-password/ForgotPasswordForm.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import Link from 'next/link'; +import { z } from 'zod'; + +import { authApi } from '@/lib/api/auth'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import type { ApiError } from '@/types/api'; + +// Validation schema +const forgotPasswordSchema = z.object({ + email: z + .string() + .min(1, 'Email is required') + .email('Please enter a valid email address'), +}); + +type ForgotPasswordValues = z.infer; + +export default function ForgotPasswordForm() { + const [isSubmitted, setIsSubmitted] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(forgotPasswordSchema), + defaultValues: { + email: '', + }, + }); + + const onSubmit = async (data: ForgotPasswordValues) => { + setIsLoading(true); + try { + await authApi.forgotPassword({ + email: data.email, + }); + + // Show success message regardless of whether email exists + setIsSubmitted(true); + } catch (err) { + const apiError = err as ApiError; + + // For security (avoiding user enumeration), always show success for 404 + if (apiError.status === 404) { + setIsSubmitted(true); + } else { + // For other errors, show the error message + console.error('Forgot password error:', apiError); + } + } finally { + setIsLoading(false); + } + }; + + if (isSubmitted) { + return ( + + {/* Success Icon */} +
+ + + +
+ + {/* Success Message */} +
+

+ Check Your Email +

+

+ We've sent password reset instructions to your email address. + Please check your inbox and follow the link to reset your password. +

+
+ + {/* Instructions */} +
+

Next Steps:

+
    +
  • • Check your email inbox
  • +
  • • Look for an email from StellarAid
  • +
  • • Click the reset link in the email
  • +
  • • Create a new password
  • +
+
+ + {/* Action Buttons */} +
+ + + + +

+ Didn't receive the email? Check your spam folder or{' '} + +

+
+
+ ); + } + + return ( + + {/* Brand header */} +
+
+ S +
+ StellarAid +
+ + {/* Heading */} +
+

Forgot Password?

+

+ Enter your email address and we'll send you a link to reset your password. +

+
+ + {/* Root-level error */} + {errors.root && ( +
+ {errors.root.message} +
+ )} + + {/* Form */} +
+ {/* Email */} +
+ + + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+ + {/* Submit */} + +
+ + {/* Back to login */} +

+ Remember your password?{' '} + + Sign In + +

+
+ ); +} diff --git a/app/auth/forgot-password/page.tsx b/app/auth/forgot-password/page.tsx new file mode 100644 index 0000000..320b36d --- /dev/null +++ b/app/auth/forgot-password/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import ForgotPasswordForm from './ForgotPasswordForm'; + +export const metadata: Metadata = { + title: 'Forgot Password | StellarAid', + description: + 'Reset your StellarAid password securely via email verification.', +}; + +export default function ForgotPasswordPage() { + return ; +} diff --git a/app/auth/reset-password/ResetPasswordForm.tsx b/app/auth/reset-password/ResetPasswordForm.tsx new file mode 100644 index 0000000..3d7578e --- /dev/null +++ b/app/auth/reset-password/ResetPasswordForm.tsx @@ -0,0 +1,475 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { z } from 'zod'; + +import { authApi } from '@/lib/api/auth'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import type { ApiError } from '@/types/api'; + +// Password requirements +const passwordRequirements = { + minLength: 8, + hasUpperCase: true, + hasLowerCase: true, + hasNumber: true, + hasSpecialChar: true, +}; + +// Validation schema +const resetPasswordSchema = z + .object({ + password: z + .string() + .min(passwordRequirements.minLength, `Password must be at least ${passwordRequirements.minLength} characters`) + .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') + .regex(/[a-z]/, 'Password must contain at least one lowercase letter') + .regex(/[0-9]/, 'Password must contain at least one number') + .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'), + confirmPassword: z.string().min(1, 'Please confirm your password'), + token: z.string().min(1, 'Token is required'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords must match", + path: ["confirmPassword"], + }); + +type ResetPasswordValues = z.infer; + +export default function ResetPasswordForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [tokenError, setTokenError] = useState(null); + + const token = searchParams.get('token'); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + watch, + } = useForm({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { + password: '', + confirmPassword: '', + token: token || '', + }, + }); + + const password = watch('password'); + + // Check password requirements + const passwordChecks = { + length: password.length >= passwordRequirements.minLength, + uppercase: /[A-Z]/.test(password), + lowercase: /[a-z]/.test(password), + number: /[0-9]/.test(password), + special: /[^A-Za-z0-9]/.test(password), + }; + + // Validate token on mount + useEffect(() => { + if (!token) { + setTokenError('Invalid reset link. Please request a new password reset.'); + } + }, [token]); + + const onSubmit = async (data: ResetPasswordValues) => { + if (!token) { + setTokenError('Invalid reset link. Please request a new password reset.'); + return; + } + + setIsLoading(true); + try { + await authApi.resetPassword({ + token, + password: data.password, + }); + + setIsSuccess(true); + } catch (err) { + const apiError = err as ApiError; + + if (apiError.status === 400 && apiError.message?.includes('expired')) { + setTokenError('This reset link has expired. Please request a new password reset.'); + } else if (apiError.status === 400 && apiError.message?.includes('invalid')) { + setTokenError('This reset link is invalid. Please request a new password reset.'); + } else { + setTokenError(apiError.message || 'Unable to reset password. Please try again.'); + } + } finally { + setIsLoading(false); + } + }; + + // Success state + if (isSuccess) { + return ( + + {/* Success Icon */} +
+ + + +
+ + {/* Success Message */} +
+

+ Password Reset Successful +

+

+ Your password has been successfully reset. You can now sign in with your new password. +

+
+ + {/* Action Button */} + + + +
+ ); + } + + // Token error state + if (tokenError) { + return ( + + {/* Error Icon */} +
+ + + +
+ + {/* Error Message */} +
+

+ Reset Link Invalid +

+

+ {tokenError} +

+
+ + {/* Action Buttons */} +
+ + + + + + + +
+
+ ); + } + + return ( + + {/* Brand header */} +
+
+ S +
+ StellarAid +
+ + {/* Heading */} +
+

Reset Password

+

+ Create a new password for your StellarAid account. +

+
+ + {/* Root-level error */} + {errors.root && ( +
+ {errors.root.message} +
+ )} + + {/* Form */} +
+ {/* New Password */} +
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} + + {/* Password Requirements */} + {password && ( +
+

+ Password must contain: +

+
+
+
+ {passwordChecks.length ? ( + + + + ) : ( + + + + )} +
+ + At least {passwordRequirements.minLength} characters + +
+
+
+ {passwordChecks.uppercase ? ( + + + + ) : ( + + + + )} +
+ + One uppercase letter + +
+
+
+ {passwordChecks.lowercase ? ( + + + + ) : ( + + + + )} +
+ + One lowercase letter + +
+
+
+ {passwordChecks.number ? ( + + + + ) : ( + + + + )} +
+ + One number + +
+
+
+ {passwordChecks.special ? ( + + + + ) : ( + + + + )} +
+ + One special character + +
+
+
+ )} +
+ + {/* Confirm Password */} +
+ + + {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ + {/* Submit */} + +
+ + {/* Back to login */} +

+ Remember your password?{' '} + + Sign In + +

+
+ ); +} diff --git a/app/auth/reset-password/page.tsx b/app/auth/reset-password/page.tsx new file mode 100644 index 0000000..8e1cf67 --- /dev/null +++ b/app/auth/reset-password/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import ResetPasswordForm from "./ResetPasswordForm"; + +export const metadata: Metadata = { + title: "Reset Password | StellarAid", + description: "Create a new password for your StellarAid account.", +}; + +export default function ResetPasswordPage() { + return ; +} diff --git a/app/auth/signup/SignupForm.tsx b/app/auth/signup/SignupForm.tsx index 48d74a8..13fce31 100644 --- a/app/auth/signup/SignupForm.tsx +++ b/app/auth/signup/SignupForm.tsx @@ -1,22 +1,22 @@ "use client"; import { useCallback } from "react"; -import Link from 'next/link'; +import Link from "next/link"; import { Button, Input, useToast } from "@/components/ui"; -import PasswordInput from '@/components/passwordInput'; -import { FcGoogle } from "react-icons/fc"; -import { SiStellar } from "react-icons/si"; +import PasswordInput from "@/components/passwordInput"; +// import { FcGoogle } from "react-icons/fc"; +// import { SiStellar } from "react-icons/si"; // form & validation -import { useForm, Controller } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import { useRouter } from 'next/navigation'; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; import { registerSchema } from "@/features/authValidation"; import { RadioOption } from "@/components/ui/radioOption"; interface RegisterFormData { email: string; - role: 'donor' | 'creator'; + role: "donor" | "creator"; password: string; confirmPassword: string; } @@ -31,13 +31,13 @@ const Signup = () => { formState: { errors, isValid, isSubmitting }, watch, } = useForm({ - resolver: yupResolver(registerSchema), - mode: 'onChange', + resolver: zodResolver(registerSchema), + mode: "onChange", defaultValues: { - email: '', - role: 'donor', - password: '', - confirmPassword: '', + email: "", + role: "donor", + password: "", + confirmPassword: "", }, }); @@ -46,243 +46,288 @@ const Signup = () => { try { // TODO: Replace with actual API call when backend is ready // await dispatch(registerUser(data)).unwrap(); - + // Simulate API call - await new Promise(resolve => setTimeout(resolve, 1500)); - - toast.success('Registration successful! Please check your email to verify your account.'); - router.push('/auth/verify-email'); + await new Promise((resolve) => setTimeout(resolve, 1500)); + + toast.success( + "Registration successful! Please check your email to verify your account.", + ); + router.push("/auth/verify-email"); } catch (err: any) { - toast.error(err?.message || 'Registration failed. Please try again.'); + toast.error(err?.message || "Registration failed. Please try again."); } }, - [toast, router] + [toast, router], ); return ( -
-
+ - - {/* Logo */} -
-
+ {/* Logo */} +
+
- S -
- - StellarAid + }} + > + + S
+ + StellarAid + +
- {/* Heading */} -
-

+

- Create Account -

-

- Join our transparent giving community -

-
- - {/* Email */} - ( - - )} - /> + }} + > + Create Account + +

+ Join our transparent giving community +

+
- {/* Role selection */} - ( -
- - I want to - - field.onChange("donor")} - /> - field.onChange("creator")} - /> -
- )} - /> - {errors.role && ( -

{errors.role.message}

+ {/* Email */} + ( + )} + /> - {/* Password */} - ( - ( +
+ + I want to + + field.onChange("donor")} /> - )} - /> - {errors.password && ( -

{errors.password.message}

+ field.onChange("creator")} + /> +
)} + /> + {errors.role && ( +

{errors.role.message}

+ )} - {/* Confirm Password */} - ( - - )} - /> - {errors.confirmPassword && ( -

{errors.confirmPassword.message}

+ {/* Password */} + ( + )} + /> + {errors.password && ( +

{errors.password.message}

+ )} - {/* Primary CTA */} - + {/* Confirm Password */} + ( + + )} + /> + {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} - {/* Divider */} -
-
- or -
-
+ {/* Primary CTA */} + - {/* Social / Wallet */} -
- + {/* Divider */} +
+
+ or +
+
- -
+ + + + + + Continue with Google + - {/* Sign-in link */} -

+ Connect Stellar Wallet + +

+ + {/* Sign-in link */} +

- Already have an account?{" "} - e.currentTarget.style.color = "#1e3a8a"} - onMouseLeave={(e) => e.currentTarget.style.color = "#1d4ed8"} - > - Sign in - -

- -
+ }} + > + Already have an account?{" "} + (e.currentTarget.style.color = "#1e3a8a")} + onMouseLeave={(e) => (e.currentTarget.style.color = "#1d4ed8")} + > + Sign in + +

+ +
); }; diff --git a/components/passwordInput.tsx b/components/passwordInput.tsx index 49d09de..e3140e8 100644 --- a/components/passwordInput.tsx +++ b/components/passwordInput.tsx @@ -1,5 +1,4 @@ import { useState, forwardRef, InputHTMLAttributes } from "react"; -import { FiEye, FiEyeOff } from "react-icons/fi"; import PasswordStrengthBar from "./passwordStrengthBar"; interface PasswordInputProps extends InputHTMLAttributes { @@ -11,67 +10,129 @@ interface PasswordInputProps extends InputHTMLAttributes { autoComplete?: string; } -const PasswordInput = forwardRef(function PasswordInput({ label, id, value, onChange, showStrength = false, autoComplete, ...props }, ref) { - const [focused, setFocused] = useState(false); - const [visible, setVisible] = useState(false); - return ( -
-