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>
|
</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}>
|
<Button type="submit" form="form-auth" disabled={isSubmitting}>
|
||||||
{isSubmitting ? <Spinner /> : t('Submit')}
|
{isSubmitting ? <Spinner /> : t('Submit')}
|
||||||
</Button>
|
</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: '/',
|
home: '/',
|
||||||
signIn: '/sign-in',
|
signIn: '/sign-in',
|
||||||
signUp: '/sign-up',
|
signUp: '/sign-up',
|
||||||
|
forgotPassword: '/forgot-password',
|
||||||
|
resetPassword: '/reset-password',
|
||||||
|
verifyEmail: '/verify-email',
|
||||||
},
|
},
|
||||||
private: {
|
private: {
|
||||||
dashboard: '/dashboard',
|
dashboard: '/dashboard',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue