diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/(full-page)/forgot-password/page.tsx b/src/app/(full-page)/forgot-password/page.tsx new file mode 100644 index 0000000..1cf396d --- /dev/null +++ b/src/app/(full-page)/forgot-password/page.tsx @@ -0,0 +1,94 @@ +'use client'; + +import Link from 'next/link'; +import { MailCheck, Send } from 'lucide-react'; +import { Suspense } from 'react'; + +import { FormField } from '@/components/form'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { GuestGuard } from '@/guards'; +import { useForgotPasswordForm } from '@/hooks/useForgotPasswordForm'; +import { ROUTES } from '@/utils/routes'; +import { Loader2 } from 'lucide-react'; + +function ForgotPasswordContent() { + const { + register, + handleSubmit, + errors, + isLoading, + isSubmitted, + isFormSubmitted, + canSubmit, + displayInfo, + } = useForgotPasswordForm(); + + return ( + +
+
+
+
+
+

VisionRoad

+

{displayInfo.title}

+

{displayInfo.description}

+
+ + {!isSubmitted ? ( +
+ + + + + + +
+ + Back to Login + +
+
+ ) : ( + + )} +
+
+
+ + ); +} + +export default function ForgotPasswordPage() { + return ( + + + + ); +} diff --git a/src/app/(full-page)/login/page.tsx b/src/app/(full-page)/login/page.tsx index 12c140a..d137fb5 100644 --- a/src/app/(full-page)/login/page.tsx +++ b/src/app/(full-page)/login/page.tsx @@ -1,10 +1,14 @@ 'use client'; +import Link from 'next/link'; + +import { PasswordField } from '@/components/form'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { GuestGuard } from '@/guards'; import { useLoginForm } from '@/hooks/useLoginForm'; +import { ROUTES } from '@/utils/routes'; import { Loader2 } from 'lucide-react'; export default function LoginPage() { @@ -47,25 +51,24 @@ export default function LoginPage() { )}
-
- - - {errors.password && ( -

- {errors.password.message} -

- )} -
+ + Forgot password? + + } + placeholder="Enter password" + autoComplete="current-password" + disabled={isLoading} + aria-invalid={!!errors.password} + error={errors.password?.message} + {...register('password', { + required: 'Password is required', + })} + /> + + )} + + + +
+ ); +} + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/src/app/(full-page)/set-password/page.tsx b/src/app/(full-page)/set-password/page.tsx new file mode 100644 index 0000000..eb1a057 --- /dev/null +++ b/src/app/(full-page)/set-password/page.tsx @@ -0,0 +1 @@ +export { default } from '../reset-password/page'; diff --git a/src/app/(modules)/users/components/UserSheet.tsx b/src/app/(modules)/users/components/UserSheet.tsx index bcba230..eec88af 100644 --- a/src/app/(modules)/users/components/UserSheet.tsx +++ b/src/app/(modules)/users/components/UserSheet.tsx @@ -98,32 +98,34 @@ export function UserSheet({ - - + - + label="Email" + required + error={emailErrorMessage} + > + + - - - + label="Phone Number" + error={phoneErrorMessage} + > + + + , 'type'> { + label: ReactNode; + required?: boolean; + error?: ReactNode; + labelEnd?: ReactNode; + fieldClassName?: string; +} + +export function PasswordField({ + id, + label, + required = false, + error, + labelEnd, + fieldClassName, + disabled, + ...props +}: PasswordFieldProps) { + const [showPassword, setShowPassword] = useState(false); + const ToggleIcon = showPassword ? EyeOffIcon : EyeIcon; + + return ( + + + + + setShowPassword((current) => !current)} + > + + + + + + ); +} diff --git a/src/components/form/index.ts b/src/components/form/index.ts index 0e97de6..52dcd7b 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1 +1,2 @@ export { FormField } from './FormField'; +export { PasswordField } from './PasswordField'; diff --git a/src/constants/apiRoutes.ts b/src/constants/apiRoutes.ts index b54d3d1..00fd395 100644 --- a/src/constants/apiRoutes.ts +++ b/src/constants/apiRoutes.ts @@ -4,6 +4,9 @@ export const API_ROUTES = { REFRESH: 'api/auth/refresh', LOGOUT: 'api/auth/logout', ME: 'api/auth/me', + FORGOT_PASSWORD: 'api/auth/forget-password', + RESET_PASSWORD: 'api/auth/reset-password', + SET_PASSWORD: 'api/auth/set-password', }, PERMISSIONS: { MY_PERMISSIONS: 'api/permissions/my-permissions', diff --git a/src/hooks/useForgotPasswordForm.ts b/src/hooks/useForgotPasswordForm.ts new file mode 100644 index 0000000..451d3a2 --- /dev/null +++ b/src/hooks/useForgotPasswordForm.ts @@ -0,0 +1,80 @@ +'use client'; + +import { authService } from '@/services/api/auth.service'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useSearchParams } from 'next/navigation'; +import { useMemo, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const forgotPasswordFormSchema = z.object({ + email: z + .string() + .trim() + .min(1, 'Email is required') + .email('Please enter a valid email address'), +}); + +export type ForgotPasswordFormValues = z.infer< + typeof forgotPasswordFormSchema +>; + +export function useForgotPasswordForm() { + const searchParams = useSearchParams(); + const email = searchParams.get('email') || ''; + const [isSubmitted, setIsSubmitted] = useState(false); + const { + control, + register, + handleSubmit: submitForm, + formState: { errors, isSubmitting, isSubmitted: isFormSubmitted }, + } = useForm({ + defaultValues: { email }, + mode: 'onSubmit', + reValidateMode: 'onChange', + resolver: zodResolver(forgotPasswordFormSchema), + }); + const emailValue = useWatch({ control, name: 'email' }) || ''; + const canSubmit = emailValue.trim().length > 0; + + const displayInfo = useMemo( + () => ({ + title: isSubmitted ? 'Check Your Email' : 'Forgot Password?', + description: isSubmitted + ? 'We have sent a password reset link to your account. Please check your email and follow the instructions.' + : 'Enter your email address to receive a password reset link.', + buttonLabel: isSubmitted ? 'Back to Login' : 'Send Reset Link', + }), + [isSubmitted], + ); + + const onSubmit = submitForm( + async (values) => { + try { + await authService.forgotPassword({ email: values.email.trim() }); + setIsSubmitted(true); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Unable to send reset link', + ); + } + }, + (formErrors) => { + if (formErrors.email?.message) { + toast.error(formErrors.email.message); + } + }, + ); + + return { + register, + handleSubmit: onSubmit, + errors, + isLoading: isSubmitting, + isSubmitted, + isFormSubmitted, + canSubmit, + displayInfo, + }; +} diff --git a/src/hooks/useSetPasswordForm.ts b/src/hooks/useSetPasswordForm.ts new file mode 100644 index 0000000..306df31 --- /dev/null +++ b/src/hooks/useSetPasswordForm.ts @@ -0,0 +1,117 @@ +'use client'; + +import { authService } from '@/services/api/auth.service'; +import type { SetPasswordPayload } from '@/types'; +import { ROUTES } from '@/utils/routes'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useMemo } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const setPasswordFormSchema = z + .object({ + password: z + .string() + .trim() + .min(1, 'Password is required') + .min(6, 'Password must be at least 6 characters'), + confirmPassword: z.string().trim().min(1, 'Confirm password is required'), + }) + .refine((values) => values.password === values.confirmPassword, { + path: ['confirmPassword'], + message: 'Passwords do not match', + }); + +export type SetPasswordFormValues = z.infer; + +export function useSetPasswordForm() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const token = searchParams.get('token') || ''; + const isInvite = pathname.includes(ROUTES.SET_PASSWORD); + const { + control, + register, + handleSubmit: submitForm, + formState: { errors, isSubmitting, isSubmitted }, + } = useForm({ + defaultValues: { + password: '', + confirmPassword: '', + }, + mode: 'onSubmit', + reValidateMode: 'onChange', + resolver: zodResolver(setPasswordFormSchema), + }); + const password = useWatch({ control, name: 'password' }) || ''; + const confirmPassword = useWatch({ control, name: 'confirmPassword' }) || ''; + const canSubmit = + Boolean(token) && + password.trim().length > 0 && + confirmPassword.trim().length > 0; + + const displayInfo = useMemo( + () => ({ + title: isInvite ? 'Set Your Password' : 'Reset Your Password', + description: isInvite + ? 'Welcome. Please set a secure password for your account.' + : 'Please enter your new password below.', + buttonLabel: isInvite ? 'Accept & Set Password' : 'Reset Password', + }), + [isInvite], + ); + + const onSubmit = submitForm( + async (values) => { + if (!token) { + toast.error('Password reset token is missing'); + return; + } + + const payload: SetPasswordPayload = { + token, + password: values.password.trim(), + confirm_password: values.confirmPassword.trim(), + }; + + try { + if (isInvite) { + await authService.setPassword(payload); + } else { + await authService.resetPassword(payload); + } + toast.success( + isInvite ? 'Password set successfully' : 'Password reset successfully', + ); + router.replace(ROUTES.LOGIN); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Unable to update password', + ); + } + }, + (formErrors) => { + if (formErrors.password?.message) { + toast.error(formErrors.password.message); + return; + } + if (formErrors.confirmPassword?.message) { + toast.error(formErrors.confirmPassword.message); + } + }, + ); + + return { + register, + handleSubmit: onSubmit, + errors, + isLoading: isSubmitting, + isSubmitted, + canSubmit, + displayInfo, + hasToken: Boolean(token), + }; +} diff --git a/src/services/api/auth.service.ts b/src/services/api/auth.service.ts index ed9e023..d62aaef 100644 --- a/src/services/api/auth.service.ts +++ b/src/services/api/auth.service.ts @@ -1,8 +1,10 @@ import type { AuthResponseData, + ForgotPasswordPayload, LoginPayload, MeResponse, PermissionResponse, + SetPasswordPayload, } from '@/types'; import { API_ROUTES } from '@/constants/apiRoutes'; import axiosClient, { axiosAuth } from '../axios/axios'; @@ -41,4 +43,25 @@ export const authService = { ); return response.data; }, + forgotPassword: async (payload: ForgotPasswordPayload): Promise => { + const response = await axiosAuth.post( + API_ROUTES.AUTH.FORGOT_PASSWORD, + payload, + ); + return response.data; + }, + resetPassword: async (payload: SetPasswordPayload): Promise => { + const response = await axiosAuth.post( + API_ROUTES.AUTH.RESET_PASSWORD, + payload, + ); + return response.data; + }, + setPassword: async (payload: SetPasswordPayload): Promise => { + const response = await axiosAuth.post( + API_ROUTES.AUTH.SET_PASSWORD, + payload, + ); + return response.data; + }, }; diff --git a/src/services/axios/axios.ts b/src/services/axios/axios.ts index 85e342b..b40a990 100644 --- a/src/services/axios/axios.ts +++ b/src/services/axios/axios.ts @@ -36,7 +36,10 @@ axiosClient.interceptors.response.use( const requestUrl = originalRequest?.url ?? ''; const isAuthRoute = requestUrl.includes('api/auth/login') || - requestUrl.includes('api/auth/refresh'); + requestUrl.includes('api/auth/refresh') || + requestUrl.includes('api/auth/forget-password') || + requestUrl.includes('api/auth/reset-password') || + requestUrl.includes('api/auth/set-password'); if ( status === 401 && diff --git a/src/types/auth.type.ts b/src/types/auth.type.ts index 2d5613e..910393e 100644 --- a/src/types/auth.type.ts +++ b/src/types/auth.type.ts @@ -60,6 +60,16 @@ export type LoginPayload = { rememberMe?: boolean; }; +export type ForgotPasswordPayload = { + email: string; +}; + +export type SetPasswordPayload = { + token: string; + password: string; + confirm_password: string; +}; + export type AuthResponseData = { access_token?: string; token_type?: string; diff --git a/src/utils/routes.ts b/src/utils/routes.ts index 7789896..2a5f479 100644 --- a/src/utils/routes.ts +++ b/src/utils/routes.ts @@ -1,5 +1,8 @@ export const ROUTES = { LOGIN: '/login', + FORGOT_PASSWORD: '/forgot-password', + RESET_PASSWORD: '/reset-password', + SET_PASSWORD: '/set-password', DASHBOARD: '/dashboard', PROJECT: '/project', PACKAGE: '/package',