Skip to content

Commit e141ff5

Browse files
[ADE-63] - Add password recovery features
- Implemented ForgotPassword and ResetPassword pages with corresponding forms. - Added forgotPassword and confirmResetPassword functions in useAuthOperations. - Updated AuthContext and AuthProvider to include new password recovery methods. - Enhanced routing in AppRouter to support new password recovery routes. - Added tests for forgotPassword and confirmResetPassword functionalities. - Updated translations and styles for password recovery UI components.
1 parent 572119a commit e141ff5

File tree

17 files changed

+767
-0
lines changed

17 files changed

+767
-0
lines changed

frontend/src/common/components/Router/AppRouter.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import SignUpPage from 'pages/Auth/SignUp/SignUpPage';
99
import SignOutPage from 'pages/Auth/SignOut/SignOutPage';
1010
import VerificationPage from 'pages/Auth/Verify/VerificationPage';
1111
import OAuthRedirectHandler from 'pages/Auth/OAuth/OAuthRedirectHandler';
12+
import ForgotPasswordPage from 'pages/Auth/ForgotPassword/ForgotPasswordPage';
13+
import ResetPasswordPage from 'pages/Auth/ResetPassword/ResetPasswordPage';
1214

1315
/**
1416
* The application router. This is the main router for the Ionic React
@@ -36,6 +38,8 @@ const AppRouter = (): JSX.Element => {
3638
<Route exact path="/auth/verify" render={() => <VerificationPage />} />
3739
<Route exact path="/auth/oauth" render={() => <OAuthRedirectHandler />} />
3840
<Route exact path="/auth/signout" render={() => <SignOutPage />} />
41+
<Route exact path="/auth/forgot-password" render={() => <ForgotPasswordPage />} />
42+
<Route exact path="/auth/reset-password" render={() => <ResetPasswordPage />} />
3943
<Route exact path="/">
4044
<Redirect to="/tabs" />
4145
</Route>

frontend/src/common/components/Router/__tests__/PrivateOutlet.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ describe('PrivateOutlet', () => {
2323
signOut: vi.fn(),
2424
signInWithGoogle: vi.fn(),
2525
signInWithApple: vi.fn(),
26+
forgotPassword: vi.fn(),
27+
confirmResetPassword: vi.fn(),
2628
clearError: vi.fn()
2729
});
2830
});
@@ -53,6 +55,8 @@ describe('PrivateOutlet', () => {
5355
signOut: vi.fn(),
5456
signInWithGoogle: vi.fn(),
5557
signInWithApple: vi.fn(),
58+
forgotPassword: vi.fn(),
59+
confirmResetPassword: vi.fn(),
5660
clearError: vi.fn()
5761
});
5862

frontend/src/common/hooks/__tests__/useAuthOperations.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ vi.mock('common/services/auth/cognito-auth-service', () => ({
1717
resendConfirmationCode: vi.fn(),
1818
signOut: vi.fn(),
1919
getCurrentUser: vi.fn(),
20+
forgotPassword: vi.fn(),
21+
confirmResetPassword: vi.fn(),
2022
}
2123
}));
2224

@@ -263,4 +265,78 @@ describe('useAuthOperations', () => {
263265
expect(result.current.error).toBeUndefined();
264266
});
265267
});
268+
269+
describe('forgotPassword', () => {
270+
it('should call CognitoAuthService.forgotPassword with correct parameters', async () => {
271+
const { result } = renderHook(() => useAuthOperations());
272+
273+
await act(async () => {
274+
await result.current.forgotPassword('[email protected]');
275+
});
276+
277+
expect(CognitoAuthService.forgotPassword).toHaveBeenCalledWith('[email protected]');
278+
expect(result.current.isLoading).toBe(false);
279+
});
280+
281+
it('should handle forgotPassword errors', async () => {
282+
const error = new Error('Invalid email');
283+
vi.mocked(CognitoAuthService.forgotPassword).mockRejectedValueOnce(error);
284+
285+
const mockError = { code: 'MockError', message: 'Invalid email', name: 'MockError' };
286+
vi.mocked(AuthErrorUtils.formatAuthError).mockReturnValueOnce(mockError as AuthError);
287+
288+
const { result } = renderHook(() => useAuthOperations());
289+
290+
await act(async () => {
291+
try {
292+
await result.current.forgotPassword('[email protected]');
293+
} catch {
294+
// Error is handled in the hook
295+
}
296+
});
297+
298+
expect(CognitoAuthService.forgotPassword).toHaveBeenCalledWith('[email protected]');
299+
expect(result.current.isLoading).toBe(false);
300+
expect(result.current.error).toBeDefined();
301+
expect(result.current.error).toEqual(mockError);
302+
expect(AuthErrorUtils.formatAuthError).toHaveBeenCalledWith(error);
303+
});
304+
});
305+
306+
describe('confirmResetPassword', () => {
307+
it('should call CognitoAuthService.confirmResetPassword with correct parameters', async () => {
308+
const { result } = renderHook(() => useAuthOperations());
309+
310+
await act(async () => {
311+
await result.current.confirmResetPassword('[email protected]', '123456', 'newPassword123');
312+
});
313+
314+
expect(CognitoAuthService.confirmResetPassword).toHaveBeenCalledWith('[email protected]', '123456', 'newPassword123');
315+
expect(result.current.isLoading).toBe(false);
316+
});
317+
318+
it('should handle confirmResetPassword errors', async () => {
319+
const error = new Error('Invalid verification code');
320+
vi.mocked(CognitoAuthService.confirmResetPassword).mockRejectedValueOnce(error);
321+
322+
const mockError = { code: 'MockError', message: 'Invalid verification code', name: 'MockError' };
323+
vi.mocked(AuthErrorUtils.formatAuthError).mockReturnValueOnce(mockError as AuthError);
324+
325+
const { result } = renderHook(() => useAuthOperations());
326+
327+
await act(async () => {
328+
try {
329+
await result.current.confirmResetPassword('[email protected]', '123456', 'newPassword123');
330+
} catch {
331+
// Error is handled in the hook
332+
}
333+
});
334+
335+
expect(CognitoAuthService.confirmResetPassword).toHaveBeenCalledWith('[email protected]', '123456', 'newPassword123');
336+
expect(result.current.isLoading).toBe(false);
337+
expect(result.current.error).toBeDefined();
338+
expect(result.current.error).toEqual(mockError);
339+
expect(AuthErrorUtils.formatAuthError).toHaveBeenCalledWith(error);
340+
});
341+
});
266342
});

frontend/src/common/hooks/useAuthOperations.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,50 @@ export const useAuthOperations = () => {
202202
}
203203
};
204204

205+
/**
206+
* Initiate password reset flow
207+
* @param email User's email
208+
*/
209+
const forgotPassword = async (email: string): Promise<void> => {
210+
setIsLoading(true);
211+
clearError();
212+
213+
try {
214+
await CognitoAuthService.forgotPassword(email);
215+
// Success - code sent to user's email
216+
} catch (err) {
217+
setError(formatAuthError(err));
218+
throw err;
219+
} finally {
220+
setIsLoading(false);
221+
}
222+
};
223+
224+
/**
225+
* Confirm password reset with code and set new password
226+
* @param email User's email
227+
* @param code Verification code
228+
* @param newPassword New password
229+
*/
230+
const confirmResetPassword = async (
231+
email: string,
232+
code: string,
233+
newPassword: string
234+
): Promise<void> => {
235+
setIsLoading(true);
236+
clearError();
237+
238+
try {
239+
await CognitoAuthService.confirmResetPassword(email, code, newPassword);
240+
// Success - password reset complete
241+
} catch (err) {
242+
setError(formatAuthError(err));
243+
throw err;
244+
} finally {
245+
setIsLoading(false);
246+
}
247+
};
248+
205249
return {
206250
isLoading,
207251
error,
@@ -215,5 +259,7 @@ export const useAuthOperations = () => {
215259
signOut,
216260
signInWithGoogle,
217261
signInWithApple,
262+
forgotPassword,
263+
confirmResetPassword,
218264
};
219265
};

frontend/src/common/providers/AuthContext.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export interface AuthContextValue {
2828
resendConfirmationCode: (email: string) => Promise<void>;
2929
signOut: () => Promise<void>;
3030

31+
// Password recovery
32+
forgotPassword: (email: string) => Promise<void>;
33+
confirmResetPassword: (email: string, code: string, newPassword: string) => Promise<void>;
34+
3135
// Social authentication
3236
signInWithGoogle: () => Promise<void>;
3337
signInWithApple: () => Promise<void>;
@@ -51,6 +55,8 @@ const DEFAULT_CONTEXT_VALUE: AuthContextValue = {
5155
confirmSignUp: async () => { throw new Error('AuthContext not initialized'); },
5256
resendConfirmationCode: async () => { throw new Error('AuthContext not initialized'); },
5357
signOut: async () => { throw new Error('AuthContext not initialized'); },
58+
forgotPassword: async () => { throw new Error('AuthContext not initialized'); },
59+
confirmResetPassword: async () => { throw new Error('AuthContext not initialized'); },
5460
signInWithGoogle: async () => { throw new Error('AuthContext not initialized'); },
5561
signInWithApple: async () => { throw new Error('AuthContext not initialized'); },
5662
clearError: () => { /* empty implementation */ },

frontend/src/common/providers/AuthProvider.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ const AuthProvider = ({ children }: PropsWithChildren): JSX.Element => {
5959
signOut,
6060
signInWithGoogle,
6161
signInWithApple,
62+
forgotPassword,
63+
confirmResetPassword,
6264
} = useAuthOperations();
6365

6466
// Extract user info from tokens when they change
@@ -167,6 +169,8 @@ const AuthProvider = ({ children }: PropsWithChildren): JSX.Element => {
167169
signOut,
168170
signInWithGoogle,
169171
signInWithApple,
172+
forgotPassword,
173+
confirmResetPassword,
170174
};
171175

172176
// Only render children when not initializing

frontend/src/common/services/auth/cognito-auth-service.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { signIn, signUp, confirmSignUp, signOut,
22
fetchAuthSession, getCurrentUser, resendSignUpCode, signInWithRedirect,
3+
resetPassword, confirmResetPassword,
34
type AuthUser } from '@aws-amplify/auth';
45
import { Amplify } from 'aws-amplify';
56
import { amplifyConfig } from '../../config/aws-config';
@@ -209,6 +210,40 @@ export class CognitoAuthService {
209210
}
210211
}
211212

213+
/**
214+
* Initiate password reset flow by sending a code to the user's email
215+
* @param username Email address
216+
* @returns Promise resolving when the code is sent
217+
*/
218+
static async forgotPassword(username: string) {
219+
try {
220+
return await resetPassword({ username });
221+
} catch (error) {
222+
this.handleAuthError(error);
223+
throw error;
224+
}
225+
}
226+
227+
/**
228+
* Confirm password reset with verification code and new password
229+
* @param username Email address
230+
* @param code Verification code
231+
* @param newPassword New password
232+
* @returns Promise resolving when the password is reset
233+
*/
234+
static async confirmResetPassword(username: string, code: string, newPassword: string) {
235+
try {
236+
return await confirmResetPassword({
237+
username,
238+
confirmationCode: code,
239+
newPassword
240+
});
241+
} catch (error) {
242+
this.handleAuthError(error);
243+
throw error;
244+
}
245+
}
246+
212247
/**
213248
* Handle common authentication errors
214249
* @param error The error from Cognito

frontend/src/common/utils/i18n/resources/en/auth.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@
3939
"submit": "Submit",
4040
"confirm": "Confirm",
4141
"resend-code": "Resend Code",
42+
"forgot-password": "Forgot Password?",
43+
"password-recovery": {
44+
"title": "Password Recovery",
45+
"message": "Enter your email address and we'll send you instructions to reset your password.",
46+
"success": "Password reset instructions sent to your email.",
47+
"email-sent": "We've sent a verification code to your email.",
48+
"enter-code": "Enter the verification code and your new password below."
49+
},
50+
"password-reset": {
51+
"title": "Reset Password",
52+
"success": "Password reset successful!",
53+
"button": "Reset Password"
54+
},
4255
"password-requirements": "Password Requirements:",
4356
"email-verification": {
4457
"title": "Email Verification",
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.ls-forgot-password-page {
2+
&__container {
3+
display: flex;
4+
flex-direction: column;
5+
height: 100%;
6+
justify-content: center;
7+
}
8+
9+
&__form {
10+
width: 100%;
11+
max-width: 500px;
12+
margin: 0 auto;
13+
}
14+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { IonContent, IonPage } from '@ionic/react';
2+
import { useTranslation } from 'react-i18next';
3+
4+
import './ForgotPasswordPage.scss';
5+
import { PropsWithTestId } from 'common/components/types';
6+
import ProgressProvider from 'common/providers/ProgressProvider';
7+
import Header from 'common/components/Header/Header';
8+
import ForgotPasswordForm from './components/ForgotPasswordForm';
9+
import Container from 'common/components/Content/Container';
10+
11+
/**
12+
* Properties for the `ForgotPasswordPage` component.
13+
*/
14+
interface ForgotPasswordPageProps extends PropsWithTestId {}
15+
16+
/**
17+
* The `ForgotPasswordPage` renders the layout for password recovery.
18+
* @param {ForgotPasswordPageProps} props - Component properties.
19+
* @returns {JSX.Element} JSX
20+
*/
21+
const ForgotPasswordPage = ({ testid = 'page-forgot-password' }: ForgotPasswordPageProps): JSX.Element => {
22+
const { t } = useTranslation();
23+
24+
return (
25+
<IonPage className="ls-forgot-password-page" data-testid={testid}>
26+
<ProgressProvider>
27+
<Header title={t('password-recovery.title', { ns: 'auth' })} />
28+
29+
<IonContent fullscreen className="ion-padding">
30+
<Container className="ls-forgot-password-page__container" fixed>
31+
<ForgotPasswordForm className="ls-forgot-password-page__form" />
32+
</Container>
33+
</IonContent>
34+
</ProgressProvider>
35+
</IonPage>
36+
);
37+
};
38+
39+
export default ForgotPasswordPage;

0 commit comments

Comments
 (0)