From 8c6c13e02a64078efb27161760b57dca96152520 Mon Sep 17 00:00:00 2001 From: nxtkofi Date: Tue, 21 Apr 2026 21:37:15 +0200 Subject: [PATCH] feat(auth): add forgot password, reset password, and email verification flows Add ForgotPasswordForm and ResetPasswordForm components. Create /forgot-password, /reset-password, and /verify-email pages. Update AuthForm with forgot password link. Add new route constants. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/app/[locale]/forgot-password/page.tsx | 9 ++ src/app/[locale]/reset-password/page.tsx | 28 +++++ src/app/[locale]/verify-email/page.tsx | 48 ++++++++ src/components/auth/AuthForm.tsx | 5 + src/components/auth/ForgotPasswordForm.tsx | 85 +++++++++++++ src/components/auth/ResetPasswordForm.tsx | 135 +++++++++++++++++++++ src/lib/routes.ts | 3 + 7 files changed, 313 insertions(+) create mode 100644 src/app/[locale]/forgot-password/page.tsx create mode 100644 src/app/[locale]/reset-password/page.tsx create mode 100644 src/app/[locale]/verify-email/page.tsx create mode 100644 src/components/auth/ForgotPasswordForm.tsx create mode 100644 src/components/auth/ResetPasswordForm.tsx diff --git a/src/app/[locale]/forgot-password/page.tsx b/src/app/[locale]/forgot-password/page.tsx new file mode 100644 index 0000000..26e8246 --- /dev/null +++ b/src/app/[locale]/forgot-password/page.tsx @@ -0,0 +1,9 @@ +import { ForgotPasswordForm } from '@/components/auth/ForgotPasswordForm'; + +export default function ForgotPasswordPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/reset-password/page.tsx b/src/app/[locale]/reset-password/page.tsx new file mode 100644 index 0000000..cb71675 --- /dev/null +++ b/src/app/[locale]/reset-password/page.tsx @@ -0,0 +1,28 @@ +import { ResetPasswordForm } from '@/components/auth/ResetPasswordForm'; + +type ResetPasswordPageProps = { + searchParams: Promise<{ + token?: string | string[]; + }>; +}; + +export default async function ResetPasswordPage({ + searchParams, +}: ResetPasswordPageProps) { + const params = await searchParams; + const token = Array.isArray(params.token) ? params.token[0] : params.token; + + if (!token) { + return ( +
+

Invalid or missing reset token.

+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/verify-email/page.tsx b/src/app/[locale]/verify-email/page.tsx new file mode 100644 index 0000000..d705c23 --- /dev/null +++ b/src/app/[locale]/verify-email/page.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useSearchParams } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +import { authClient } from '@/lib/auth-client'; + +export default function VerifyEmailPage() { + const t = useTranslations('AuthPage'); + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + const [status, setStatus] = useState<'loading' | 'success' | 'error'>( + token ? 'loading' : 'error', + ); + const verifiedRef = useRef(false); + + useEffect(() => { + if (!token || verifiedRef.current) return; + verifiedRef.current = true; + + authClient + .verifyEmail({ query: { token } }) + .then((result) => { + if (result.error) { + toast(result.error.message); + setStatus('error'); + } else { + toast(t('EmailVerified')); + setStatus('success'); + } + }) + .catch(() => { + setStatus('error'); + }); + }, [token, t]); + + return ( +
+
+ {status === 'loading' &&

{t('VerifyingEmail')}

} + {status === 'success' &&

{t('EmailVerified')}

} + {status === 'error' &&

{t('EmailVerificationFailed')}

} +
+
+ ); +} diff --git a/src/components/auth/AuthForm.tsx b/src/components/auth/AuthForm.tsx index b696e1c..d179c2a 100644 --- a/src/components/auth/AuthForm.tsx +++ b/src/components/auth/AuthForm.tsx @@ -210,6 +210,11 @@ export function AuthForm({ mode, redirectPath = '/dashboard' }: AuthFormProps) { )} /> + {!isSignUp && ( + + )} diff --git a/src/components/auth/ForgotPasswordForm.tsx b/src/components/auth/ForgotPasswordForm.tsx new file mode 100644 index 0000000..a2c2bdf --- /dev/null +++ b/src/components/auth/ForgotPasswordForm.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; +import { Controller, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod/v4'; + +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { authClient } from '@/lib/auth-client'; +import { Spinner } from '@/components/ui/spinner'; + +const forgotPasswordSchema = z.object({ + email: z.email(), +}); + +type ForgotPasswordValues = z.infer; + +export function ForgotPasswordForm() { + const t = useTranslations('AuthPage'); + + const form = useForm({ + resolver: zodResolver(forgotPasswordSchema), + defaultValues: { email: '' }, + }); + + const isSubmitting = form.formState.isSubmitting; + + async function onSubmit(values: ForgotPasswordValues) { + const result = await authClient.requestPasswordReset({ + email: values.email, + redirectTo: '/reset-password', + }); + + if (result.error) { + toast(result.error.message); + } else { + toast(t('ResetEmailSent')); + form.reset(); + } + } + + return ( + + + {t('ForgotPasswordTitle')} + + +
+ + ( + + {t('EmailLabel')} + + {fieldState.invalid && } + + )} + /> + + +
+
+
+ ); +} diff --git a/src/components/auth/ResetPasswordForm.tsx b/src/components/auth/ResetPasswordForm.tsx new file mode 100644 index 0000000..01a070a --- /dev/null +++ b/src/components/auth/ResetPasswordForm.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; +import { Controller, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod/v4'; + +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'; +import { defaultPasswordValidator } from '@/constants'; +import { authClient } from '@/lib/auth-client'; +import { Spinner } from '@/components/ui/spinner'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { EyeIcon, EyeOff } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { useState } from 'react'; + +const resetPasswordSchema = z + .object({ + password: defaultPasswordValidator(), + confirmPassword: z.string().min(1), + }) + .refine((value) => value.password === value.confirmPassword, { + error: 'Passwords do not match', + path: ['confirmPassword'], + }); + +type ResetPasswordValues = z.infer; + +export function ResetPasswordForm({ token }: { token: string }) { + const t = useTranslations('AuthPage'); + const [passwordVisible, setPasswordVisible] = useState(); + + const form = useForm({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { password: '', confirmPassword: '' }, + }); + + const isSubmitting = form.formState.isSubmitting; + + async function onSubmit(values: ResetPasswordValues) { + const result = await authClient.resetPassword({ + newPassword: values.password, + token, + }); + + if (result.error) { + toast(result.error.message); + } else { + toast(t('PasswordResetSuccess')); + form.reset(); + } + } + + return ( + + + {t('ResetPasswordTitle')} + + +
+ + ( + + {t('NewPasswordLabel')} + + + setPasswordVisible(!passwordVisible)} + > + + + {passwordVisible ? ( + + ) : ( + + )} + + +

{passwordVisible ? t('HidePasswordTooltip') : t('ShowPasswordTooltip')}

+
+
+
+
+ {fieldState.invalid && } +
+ )} + /> + ( + + {t('ConfirmPasswordLabel')} + + {fieldState.invalid && } + + )} + /> + +
+
+
+
+ ); +} diff --git a/src/lib/routes.ts b/src/lib/routes.ts index 4bf6852..d01d987 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -3,6 +3,9 @@ export const routes = { home: '/', signIn: '/sign-in', signUp: '/sign-up', + forgotPassword: '/forgot-password', + resetPassword: '/reset-password', + verifyEmail: '/verify-email', }, private: { dashboard: '/dashboard',