Skip to content

Commit e207a6e

Browse files
committed
add resetting password flow
1 parent 2ebfda6 commit e207a6e

7 files changed

Lines changed: 319 additions & 7 deletions

File tree

apps/backend/src/auth/auth.controller.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ describe('AuthController', () => {
1313

1414
const mockUsersService = {
1515
create: jest.fn(),
16+
find: jest.fn(),
1617
};
1718

1819
beforeEach(async () => {
@@ -36,4 +37,12 @@ describe('AuthController', () => {
3637
it('should be defined', () => {
3738
expect(controller).toBeDefined();
3839
});
40+
41+
it('rejects forgot password for unregistered email', async () => {
42+
mockUsersService.find.mockResolvedValue([]);
43+
44+
await expect(
45+
controller.forgotPassword({ email: 'random@example.com' } as any),
46+
).rejects.toThrow('Account is not registered.');
47+
});
3948
});

apps/backend/src/auth/auth.controller.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,19 @@ export class AuthController {
131131
}
132132

133133
@Post('/forgotPassword')
134-
forgotPassword(@Body() body: ForgotPasswordDto): Promise<void> {
135-
return this.authService.forgotPassword(body.email);
134+
async forgotPassword(@Body() body: ForgotPasswordDto): Promise<void> {
135+
const registeredUsers = await this.usersService.find(body.email);
136+
137+
if (!registeredUsers.length) {
138+
throw new BadRequestException('Account is not registered.');
139+
}
140+
141+
try {
142+
await this.authService.forgotPassword(body.email);
143+
} catch (e) {
144+
console.error('Forgot password error:', e);
145+
throw new BadRequestException(e.message);
146+
}
136147
}
137148

138149
@Post('/confirmPassword')

apps/frontend/src/api/apiClient.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export type AuthResponse = {
2929
idToken: string;
3030
};
3131
export type RefreshRequest = { refreshToken: string; userSub: string };
32+
export type ConfirmPasswordRequest = {
33+
email: string;
34+
confirmationCode: string;
35+
newPassword: string;
36+
};
3237

3338
type ApiError = { error?: string; message?: string };
3439

@@ -81,6 +86,22 @@ export class ApiClient {
8186
}
8287
}
8388

89+
public async forgotPassword(email: string): Promise<void> {
90+
try {
91+
await this.axiosInstance.post('/api/auth/forgotPassword', { email });
92+
} catch (err: unknown) {
93+
this.handleAxiosError(err, 'Failed to send password reset email');
94+
}
95+
}
96+
97+
public async confirmPassword(body: ConfirmPasswordRequest): Promise<void> {
98+
try {
99+
await this.axiosInstance.post('/api/auth/confirmPassword', body);
100+
} catch (err: unknown) {
101+
this.handleAxiosError(err, 'Failed to reset password');
102+
}
103+
}
104+
84105
private handleAxiosError(err: unknown, defaultMsg: string): never {
85106
if (axios.isAxiosError<ApiError>(err)) {
86107
const data = err.response?.data;

apps/frontend/src/app.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { AuthProvider } from '@components/AuthProvider';
1111
import { ProtectedRoute } from '@components/ProtectedRoute';
1212
import { AdminRoute } from '@components/AdminRoute';
1313
import { LoginPage } from '@containers/auth/LoginPage';
14-
import { ConfirmSentEmailPage } from '@containers/auth/ConfirmSentEmailPage';
14+
import { ResetPasswordPage } from '@containers/auth/ResetPasswordPage';
1515
import { ConfirmRegisteredPage } from '@containers/auth/ConfirmRegisteredPage';
1616
import { DashboardPage } from '@containers/dashboard/DashboardPage';
1717
import { DonorStatsChart } from '@components/DonorStatsChart';
@@ -27,8 +27,8 @@ const router = createBrowserRouter([
2727
element: <LoginPage />,
2828
},
2929
{
30-
path: '/confirm-sent-email',
31-
element: <ConfirmSentEmailPage />,
30+
path: '/reset-password',
31+
element: <ResetPasswordPage />,
3232
},
3333
{
3434
path: '/confirm-registered',

apps/frontend/src/containers/auth/LoginPage.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '@components/ui/input-group';
1515
import { EyeIcon, EyeOffIcon } from 'lucide-react';
1616
import { PasswordCriterion } from './PasswordCriterion';
17+
import apiClient from '@api/apiClient';
1718

1819
enum AuthPage {
1920
Login,
@@ -30,6 +31,7 @@ export const LoginPage: React.FC = () => {
3031
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
3132

3233
const [error, setError] = useState('');
34+
const [success, setSuccess] = useState('');
3335
const [isLoading, setIsLoading] = useState(false);
3436
const [authPage, setAuthPage] = useState<AuthPage>(AuthPage.Login);
3537

@@ -39,9 +41,16 @@ export const LoginPage: React.FC = () => {
3941

4042
const locationState = location.state as {
4143
from?: { pathname: string };
44+
message?: string;
4245
} | null;
4346
const from = locationState?.from?.pathname || '/dashboard';
4447

48+
React.useEffect(() => {
49+
if (locationState?.message) {
50+
setSuccess(locationState.message);
51+
}
52+
}, [locationState?.message]);
53+
4554
const hasMinLength = password.length >= 8;
4655
const hasUppercase = /[A-Z]/.test(password);
4756
const hasLowercase = /[a-z]/.test(password);
@@ -62,7 +71,8 @@ export const LoginPage: React.FC = () => {
6271
const showAuthFieldError =
6372
authPage === AuthPage.Login &&
6473
!!error &&
65-
error !== 'Account created successfully! Please sign in.';
74+
error !== 'Account created successfully! Please sign in.' &&
75+
!success;
6676

6777
const headerText = (() => {
6878
switch (authPage) {
@@ -103,7 +113,8 @@ export const LoginPage: React.FC = () => {
103113
navigate('/confirm-registered', { replace: true });
104114
setError('Account created successfully! Please sign in.');
105115
} else if (authPage === AuthPage.ForgotPassword) {
106-
navigate('/confirm-sent-email', { replace: true });
116+
await apiClient.forgotPassword(email);
117+
navigate('/reset-password', { replace: true, state: { email } });
107118
}
108119
} catch (err: unknown) {
109120
if (err instanceof Error) {
@@ -152,6 +163,12 @@ export const LoginPage: React.FC = () => {
152163
className="flex flex-col gap-4 mt-10"
153164
noValidate
154165
>
166+
{success && (
167+
<p className="text-sm text-[#12BA82] font-medium" role="status">
168+
{success}
169+
</p>
170+
)}
171+
155172
{authPage === AuthPage.Signup && (
156173
<div>
157174
<Label
@@ -198,6 +215,12 @@ export const LoginPage: React.FC = () => {
198215
</span>
199216
</div>
200217

218+
{authPage === AuthPage.ForgotPassword && error && (
219+
<p className="-mt-2 text-sm text-[#B4444D]" role="alert">
220+
{error}
221+
</p>
222+
)}
223+
201224
{authPage !== AuthPage.ForgotPassword && (
202225
<div>
203226
<Label
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import React, { useState } from 'react';
2+
import { Input } from '@components/ui/input';
3+
import { Label } from '@components/ui/label';
4+
import { Button } from '@components/ui/button';
5+
import {
6+
InputGroup,
7+
InputGroupAddon,
8+
InputGroupInput,
9+
} from '@components/ui/input-group';
10+
import { EyeIcon, EyeOffIcon } from 'lucide-react';
11+
import { PasswordCriterion } from './PasswordCriterion';
12+
import apiClient from '@api/apiClient';
13+
14+
interface ResetPasswordFormProps {
15+
email: string;
16+
onSuccess?: () => void;
17+
}
18+
19+
export const ResetPasswordForm: React.FC<ResetPasswordFormProps> = ({
20+
email,
21+
onSuccess,
22+
}) => {
23+
const [confirmationCode, setConfirmationCode] = useState('');
24+
const [newPassword, setNewPassword] = useState('');
25+
const [confirmPassword, setConfirmPassword] = useState('');
26+
const [showPassword, setShowPassword] = useState(false);
27+
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
28+
const [error, setError] = useState('');
29+
const [isLoading, setIsLoading] = useState(false);
30+
31+
const hasMinLength = newPassword.length >= 8;
32+
const hasUppercase = /[A-Z]/.test(newPassword);
33+
const hasLowercase = /[a-z]/.test(newPassword);
34+
const hasNumber = /\d/.test(newPassword);
35+
const hasSpecialChar = /[^A-Za-z0-9]/.test(newPassword);
36+
const passwordsMatch =
37+
newPassword.length > 0 &&
38+
confirmPassword.length > 0 &&
39+
newPassword === confirmPassword;
40+
const allCriteriaMet =
41+
hasMinLength &&
42+
hasUppercase &&
43+
hasLowercase &&
44+
hasNumber &&
45+
hasSpecialChar &&
46+
passwordsMatch;
47+
48+
const handleSubmit = async (e: React.FormEvent) => {
49+
e.preventDefault();
50+
setError('');
51+
setIsLoading(true);
52+
53+
try {
54+
await apiClient.confirmPassword({
55+
email,
56+
confirmationCode,
57+
newPassword,
58+
});
59+
onSuccess?.();
60+
} catch (err: unknown) {
61+
if (err instanceof Error) {
62+
setError(err.message);
63+
} else {
64+
setError('Failed to reset password');
65+
}
66+
} finally {
67+
setIsLoading(false);
68+
}
69+
};
70+
71+
return (
72+
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
73+
<div>
74+
<Label
75+
htmlFor="confirmation-code"
76+
className="font-semibold mb-1 text-[#404040]"
77+
>
78+
Confirmation Code
79+
</Label>
80+
<Input
81+
id="confirmation-code"
82+
type="text"
83+
placeholder="Enter code from email"
84+
required
85+
value={confirmationCode}
86+
onChange={(e) => setConfirmationCode(e.target.value)}
87+
className={`w-full py-5 focus:ring-[2.5px] focus:ring-[#007B64] ${
88+
error ? 'ring-[2.5px] ring-[#B4444D] bg-[#FFFAFA]' : ''
89+
}`}
90+
/>
91+
</div>
92+
93+
<div>
94+
<Label
95+
htmlFor="new-password"
96+
className="font-semibold mb-1 text-[#404040]"
97+
>
98+
New Password
99+
</Label>
100+
<InputGroup
101+
className={`w-full focus-within:border-[#007B64] focus-within:ring-[2.5px] focus-within:ring-[#007B64] py-5 ${
102+
error ? 'border-[#B4444D] ring-[2px] ring-[#B4444D]' : ''
103+
}`}
104+
>
105+
<InputGroupInput
106+
id="new-password"
107+
type={showPassword ? 'text' : 'password'}
108+
value={newPassword}
109+
onChange={(e) => setNewPassword(e.target.value)}
110+
placeholder="Password"
111+
className={`focus:ring-[#007B64] ${error ? 'bg-[#FFFAFA]' : ''}`}
112+
/>
113+
<InputGroupAddon
114+
align="inline-end"
115+
className="hover:cursor-pointer"
116+
onClick={() => setShowPassword(!showPassword)}
117+
>
118+
{showPassword ? <EyeIcon /> : <EyeOffIcon />}
119+
</InputGroupAddon>
120+
</InputGroup>
121+
</div>
122+
123+
<div>
124+
<Label
125+
htmlFor="confirm-password"
126+
className="font-semibold mb-1 text-[#404040]"
127+
>
128+
Confirm Password
129+
</Label>
130+
<InputGroup className="w-full focus-within:border-[#007B64] focus-within:ring-[2.5px] focus-within:ring-[#007B64] py-5">
131+
<InputGroupInput
132+
id="confirm-password"
133+
type={showConfirmPassword ? 'text' : 'password'}
134+
value={confirmPassword}
135+
onChange={(e) => setConfirmPassword(e.target.value)}
136+
placeholder="Re-enter Password"
137+
className="focus:ring-[#007B64]"
138+
/>
139+
<InputGroupAddon
140+
align="inline-end"
141+
className="hover:cursor-pointer"
142+
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
143+
>
144+
{showConfirmPassword ? <EyeIcon /> : <EyeOffIcon />}
145+
</InputGroupAddon>
146+
</InputGroup>
147+
</div>
148+
149+
<div className="flex gap-1 flex-wrap w-full">
150+
<PasswordCriterion name="8+ characters" criterionMet={hasMinLength} />
151+
<PasswordCriterion name="Uppercase" criterionMet={hasUppercase} />
152+
<PasswordCriterion name="Lowercase" criterionMet={hasLowercase} />
153+
<PasswordCriterion
154+
name="Special character"
155+
criterionMet={hasSpecialChar}
156+
/>
157+
<PasswordCriterion name="Number" criterionMet={hasNumber} />
158+
<PasswordCriterion name="Matching" criterionMet={passwordsMatch} />
159+
</div>
160+
161+
{error && <p className="text-sm text-[#B4444D]">{error}</p>}
162+
163+
<Button
164+
id="reset-password-submit-btn"
165+
type="submit"
166+
disabled={!confirmationCode || !allCriteriaMet || isLoading}
167+
className={`py-5 h-14 rounded-full font-semibold text-xl text-white ${
168+
!confirmationCode || !allCriteriaMet || isLoading
169+
? 'bg-[#737373] cursor-not-allowed'
170+
: 'bg-[#007B64]'
171+
}`}
172+
>
173+
{isLoading ? '...' : 'Reset Password'}
174+
</Button>
175+
</form>
176+
);
177+
};

0 commit comments

Comments
 (0)