Skip to content

Commit 5825e4f

Browse files
authored
Merge pull request #668 from Mkalbani/feat/forgot-password-page
Feat/forgot password page
2 parents 7a21d61 + 48458d9 commit 5825e4f

4 files changed

Lines changed: 452 additions & 137 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
import { useState } from 'react';
5+
import { useForm } from 'react-hook-form';
6+
import { zodResolver } from '@hookform/resolvers/zod';
7+
import { z } from 'zod';
8+
9+
import { forgotPassword } from '../../../lib/api/auth.api';
10+
import { Button } from '../../../components/ui/button';
11+
import { Input } from '../../../components/ui/input';
12+
import { Label } from '../../../components/ui/label';
13+
import {
14+
Card,
15+
CardContent,
16+
CardDescription,
17+
CardFooter,
18+
CardHeader,
19+
CardTitle,
20+
} from '../../../components/ui/card';
21+
22+
const forgotPasswordSchema = z.object({
23+
email: z.string().email('Invalid email address'),
24+
});
25+
26+
type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
27+
28+
export default function ForgotPasswordPage() {
29+
const [isSubmitted, setIsSubmitted] = useState(false);
30+
31+
const {
32+
register,
33+
handleSubmit,
34+
reset,
35+
formState: { errors, isSubmitting },
36+
} = useForm<ForgotPasswordFormData>({
37+
resolver: zodResolver(forgotPasswordSchema),
38+
defaultValues: {
39+
email: '',
40+
},
41+
});
42+
43+
const onSubmit = async (data: ForgotPasswordFormData) => {
44+
try {
45+
await forgotPassword(data.email);
46+
} catch {
47+
// Intentionally ignore error details to prevent account enumeration.
48+
} finally {
49+
setIsSubmitted(true);
50+
}
51+
};
52+
53+
if (isSubmitted) {
54+
return (
55+
<Card>
56+
<CardHeader>
57+
<CardTitle>Check your inbox</CardTitle>
58+
<CardDescription>
59+
If an account exists for that email, we&apos;ve sent password reset instructions.
60+
</CardDescription>
61+
</CardHeader>
62+
<CardFooter className="flex flex-col gap-4">
63+
<Button
64+
type="button"
65+
className="w-full"
66+
onClick={() => {
67+
reset({ email: '' });
68+
setIsSubmitted(false);
69+
}}
70+
>
71+
Try again
72+
</Button>
73+
<p className="text-sm text-muted-foreground text-center">
74+
<Link href="/login" className="text-primary underline underline-offset-4">
75+
Back to sign in
76+
</Link>
77+
</p>
78+
</CardFooter>
79+
</Card>
80+
);
81+
}
82+
83+
return (
84+
<Card>
85+
<CardHeader>
86+
<CardTitle className="text-2xl">Forgot password</CardTitle>
87+
<CardDescription>
88+
Enter your email address and we&apos;ll send password reset instructions.
89+
</CardDescription>
90+
</CardHeader>
91+
<form onSubmit={handleSubmit(onSubmit)}>
92+
<CardContent className="space-y-4">
93+
<div className="space-y-2">
94+
<Label htmlFor="email">Email</Label>
95+
<Input
96+
id="email"
97+
type="email"
98+
placeholder="you@example.com"
99+
autoComplete="email"
100+
{...register('email')}
101+
/>
102+
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
103+
</div>
104+
</CardContent>
105+
<CardFooter className="flex flex-col gap-4">
106+
<Button type="submit" className="w-full" disabled={isSubmitting}>
107+
{isSubmitting ? 'Sending…' : 'Send reset link'}
108+
</Button>
109+
<p className="text-sm text-muted-foreground text-center">
110+
<Link href="/login" className="text-primary underline underline-offset-4">
111+
Back to sign in
112+
</Link>
113+
</p>
114+
</CardFooter>
115+
</form>
116+
</Card>
117+
);
118+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
import { Suspense, useMemo, useState } from 'react';
5+
import { useSearchParams } from 'next/navigation';
6+
import { useForm } from 'react-hook-form';
7+
import { zodResolver } from '@hookform/resolvers/zod';
8+
import { z } from 'zod';
9+
import { toast } from 'sonner';
10+
11+
import { resetPassword } from '../../../lib/api/auth.api';
12+
import { Button } from '../../../components/ui/button';
13+
import { Input } from '../../../components/ui/input';
14+
import { Label } from '../../../components/ui/label';
15+
import {
16+
Card,
17+
CardContent,
18+
CardDescription,
19+
CardFooter,
20+
CardHeader,
21+
CardTitle,
22+
} from '../../../components/ui/card';
23+
24+
const resetPasswordSchema = z
25+
.object({
26+
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
27+
confirmPassword: z.string().min(8, 'Password must be at least 8 characters'),
28+
})
29+
.refine((values) => values.newPassword === values.confirmPassword, {
30+
message: 'Passwords must match',
31+
path: ['confirmPassword'],
32+
});
33+
34+
type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
35+
36+
function getErrorMessage(error: unknown): string {
37+
if (typeof error === 'object' && error !== null && 'message' in error) {
38+
const message = (error as { message?: string | string[] }).message;
39+
if (Array.isArray(message) && message.length > 0) {
40+
return message[0] ?? 'Failed to reset password';
41+
}
42+
if (typeof message === 'string' && message.length > 0) {
43+
return message;
44+
}
45+
}
46+
47+
return 'Failed to reset password';
48+
}
49+
50+
function ResetPasswordForm() {
51+
const searchParams = useSearchParams();
52+
const token = useMemo(() => searchParams.get('token')?.trim() ?? '', [searchParams]);
53+
const [isSuccess, setIsSuccess] = useState(false);
54+
55+
const {
56+
register,
57+
handleSubmit,
58+
formState: { errors, isSubmitting },
59+
} = useForm<ResetPasswordFormData>({
60+
resolver: zodResolver(resetPasswordSchema),
61+
defaultValues: {
62+
newPassword: '',
63+
confirmPassword: '',
64+
},
65+
});
66+
67+
if (!token) {
68+
return (
69+
<Card>
70+
<CardHeader>
71+
<CardTitle>Invalid link</CardTitle>
72+
<CardDescription>
73+
This password reset link is missing or invalid. Request a new reset email to continue.
74+
</CardDescription>
75+
</CardHeader>
76+
<CardFooter>
77+
<Button asChild className="w-full">
78+
<Link href="/forgot-password">Request new link</Link>
79+
</Button>
80+
</CardFooter>
81+
</Card>
82+
);
83+
}
84+
85+
if (isSuccess) {
86+
return (
87+
<Card>
88+
<CardHeader>
89+
<CardTitle>Password updated</CardTitle>
90+
<CardDescription>
91+
Your password has been reset successfully. You can now sign in with your new password.
92+
</CardDescription>
93+
</CardHeader>
94+
<CardFooter>
95+
<Button asChild className="w-full">
96+
<Link href="/login">Sign in</Link>
97+
</Button>
98+
</CardFooter>
99+
</Card>
100+
);
101+
}
102+
103+
const onSubmit = async (data: ResetPasswordFormData) => {
104+
try {
105+
await resetPassword(token, data.newPassword);
106+
setIsSuccess(true);
107+
} catch (error: unknown) {
108+
toast.error(getErrorMessage(error));
109+
}
110+
};
111+
112+
return (
113+
<Card>
114+
<CardHeader>
115+
<CardTitle className="text-2xl">Reset password</CardTitle>
116+
<CardDescription>Enter a new password for your account.</CardDescription>
117+
</CardHeader>
118+
<form onSubmit={handleSubmit(onSubmit)}>
119+
<CardContent className="space-y-4">
120+
<div className="space-y-2">
121+
<Label htmlFor="newPassword">New password</Label>
122+
<Input
123+
id="newPassword"
124+
type="password"
125+
autoComplete="new-password"
126+
placeholder="••••••••"
127+
{...register('newPassword')}
128+
/>
129+
{errors.newPassword && (
130+
<p className="text-sm text-destructive">{errors.newPassword.message}</p>
131+
)}
132+
</div>
133+
<div className="space-y-2">
134+
<Label htmlFor="confirmPassword">Confirm new password</Label>
135+
<Input
136+
id="confirmPassword"
137+
type="password"
138+
autoComplete="new-password"
139+
placeholder="••••••••"
140+
{...register('confirmPassword')}
141+
/>
142+
{errors.confirmPassword && (
143+
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
144+
)}
145+
</div>
146+
</CardContent>
147+
<CardFooter>
148+
<Button type="submit" className="w-full" disabled={isSubmitting}>
149+
{isSubmitting ? 'Resetting…' : 'Reset password'}
150+
</Button>
151+
</CardFooter>
152+
</form>
153+
</Card>
154+
);
155+
}
156+
157+
export default function ResetPasswordPage() {
158+
return (
159+
<Suspense>
160+
<ResetPasswordForm />
161+
</Suspense>
162+
);
163+
}

0 commit comments

Comments
 (0)