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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
nxtkofi 2026-04-21 21:37:15 +02:00
parent d6da6e6193
commit 8c6c13e02a
7 changed files with 313 additions and 0 deletions

View file

@ -0,0 +1,9 @@
import { ForgotPasswordForm } from '@/components/auth/ForgotPasswordForm';
export default function ForgotPasswordPage() {
return (
<section className="mx-auto flex min-h-[calc(100vh-8rem)] w-full max-w-md items-start px-4 py-8 sm:px-6 lg:px-8">
<ForgotPasswordForm />
</section>
);
}

View file

@ -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 (
<section className="mx-auto flex min-h-[calc(100vh-8rem)] w-full max-w-md items-start px-4 py-8 sm:px-6 lg:px-8">
<p className="text-destructive">Invalid or missing reset token.</p>
</section>
);
}
return (
<section className="mx-auto flex min-h-[calc(100vh-8rem)] w-full max-w-md items-start px-4 py-8 sm:px-6 lg:px-8">
<ResetPasswordForm token={token} />
</section>
);
}

View file

@ -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 (
<section className="mx-auto flex min-h-[calc(100vh-8rem)] w-full max-w-md items-center px-4 py-8 sm:px-6 lg:px-8">
<div className="w-full text-center">
{status === 'loading' && <p>{t('VerifyingEmail')}</p>}
{status === 'success' && <p className="text-green-600">{t('EmailVerified')}</p>}
{status === 'error' && <p className="text-destructive">{t('EmailVerificationFailed')}</p>}
</div>
</section>
);
}

View file

@ -210,6 +210,11 @@ export function AuthForm({ mode, redirectPath = '/dashboard' }: AuthFormProps) {
</Field>
)}
/>
{!isSignUp && (
<Button variant="link" asChild className="justify-start p-0">
<Link href="/forgot-password">{t('ForgotPasswordLink')}</Link>
</Button>
)}
<Button type="submit" form="form-auth" disabled={isSubmitting}>
{isSubmitting ? <Spinner /> : t('Submit')}
</Button>

View file

@ -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<typeof forgotPasswordSchema>;
export function ForgotPasswordForm() {
const t = useTranslations('AuthPage');
const form = useForm<ForgotPasswordValues>({
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 (
<Card>
<CardHeader>
<CardTitle>{t('ForgotPasswordTitle')}</CardTitle>
</CardHeader>
<CardContent>
<form id="form-forgot-password" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-email">{t('EmailLabel')}</FieldLabel>
<Input
{...field}
id="form-email"
type="email"
aria-invalid={fieldState.invalid}
placeholder={t('EmailPlaceholder')}
autoComplete="email"
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Button type="submit" form="form-forgot-password" disabled={isSubmitting}>
{isSubmitting ? <Spinner /> : t('SendResetLink')}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
);
}

View file

@ -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<typeof resetPasswordSchema>;
export function ResetPasswordForm({ token }: { token: string }) {
const t = useTranslations('AuthPage');
const [passwordVisible, setPasswordVisible] = useState<boolean>();
const form = useForm<ResetPasswordValues>({
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 (
<Card>
<CardHeader>
<CardTitle>{t('ResetPasswordTitle')}</CardTitle>
</CardHeader>
<CardContent>
<form id="form-reset-password" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-password">{t('NewPasswordLabel')}</FieldLabel>
<InputGroup>
<InputGroupInput
{...field}
id="form-password"
type={passwordVisible ? 'text' : 'password'}
aria-invalid={fieldState.invalid}
placeholder={t('PasswordPlaceholder')}
autoComplete="new-password"
/>
<InputGroupAddon
align="inline-end"
onClick={() => setPasswordVisible(!passwordVisible)}
>
<Tooltip>
<TooltipTrigger asChild>
{passwordVisible ? (
<HugeiconsIcon icon={EyeOff} />
) : (
<HugeiconsIcon icon={EyeIcon} />
)}
</TooltipTrigger>
<TooltipContent>
<p>{passwordVisible ? t('HidePasswordTooltip') : t('ShowPasswordTooltip')}</p>
</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
name="confirmPassword"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-confirm-password">{t('ConfirmPasswordLabel')}</FieldLabel>
<Input
{...field}
id="form-confirm-password"
type="password"
aria-invalid={fieldState.invalid}
placeholder={t('ConfirmPasswordPlaceholder')}
autoComplete="new-password"
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Button type="submit" form="form-reset-password" disabled={isSubmitting}>
{isSubmitting ? <Spinner /> : t('ResetPasswordSubmit')}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
);
}

View file

@ -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',