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:
parent
d6da6e6193
commit
8c6c13e02a
7 changed files with 313 additions and 0 deletions
9
src/app/[locale]/forgot-password/page.tsx
Normal file
9
src/app/[locale]/forgot-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/app/[locale]/reset-password/page.tsx
Normal file
28
src/app/[locale]/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/app/[locale]/verify-email/page.tsx
Normal file
48
src/app/[locale]/verify-email/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
85
src/components/auth/ForgotPasswordForm.tsx
Normal file
85
src/components/auth/ForgotPasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/components/auth/ResetPasswordForm.tsx
Normal file
135
src/components/auth/ResetPasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue