Skip to content

Commit c13d130

Browse files
Merge pull request #109 from rootstrap/feature/forgot-password-screen
feat: add forgot password screen
2 parents eb28cb9 + 0950d46 commit c13d130

File tree

8 files changed

+226
-1
lines changed

8 files changed

+226
-1
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { createMutation } from 'react-query-kit';
2+
3+
import { client } from '../common';
4+
5+
type Variables = {
6+
email: string;
7+
};
8+
9+
type Response = {
10+
message: string;
11+
};
12+
13+
const sendForgotPasswordInstructions = async (variables: Variables) => {
14+
const { data } = await client({
15+
url: 'auth/forgot-password', // Dummy endpoint for forgot password
16+
method: 'POST',
17+
data: {
18+
email: variables.email,
19+
},
20+
headers: {
21+
'Content-Type': 'application/json',
22+
},
23+
});
24+
return data;
25+
};
26+
27+
export const useForgotPassword = createMutation<Response, Variables>({
28+
mutationFn: (variables) => sendForgotPasswordInstructions(variables),
29+
});

src/app/_layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export default function RootLayout() {
3434
<Stack.Screen name="(app)" options={{ headerShown: false }} />
3535
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
3636
<Stack.Screen name="login" options={{ headerShown: false }} />
37+
<Stack.Screen name="forgot-password" />
3738
</Stack>
3839
</Providers>
3940
);

src/app/forgot-password.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { showMessage } from 'react-native-flash-message';
4+
5+
import { useForgotPassword } from '@/api/auth/use-forgot-password';
6+
import {
7+
ForgotPasswordForm,
8+
type FormType as ForgotPasswordFormType,
9+
} from '@/components/forgot-password-form';
10+
import { FocusAwareStatusBar } from '@/ui';
11+
12+
export default function ForgotPassword() {
13+
const { t } = useTranslation();
14+
15+
const { mutate: sendForgotPasswordInstructions } = useForgotPassword({
16+
onSuccess: () => {
17+
showMessage({
18+
message: t('forgotPassword.successMessage'),
19+
type: 'success',
20+
});
21+
},
22+
onError: () => {
23+
showMessage({
24+
message: t('forgotPassword.errorMessage'),
25+
type: 'danger',
26+
});
27+
},
28+
});
29+
30+
const onSubmit = (data: ForgotPasswordFormType) => {
31+
sendForgotPasswordInstructions(data);
32+
};
33+
return (
34+
<>
35+
<FocusAwareStatusBar />
36+
<ForgotPasswordForm onSubmit={onSubmit} />
37+
</>
38+
);
39+
}

src/app/login.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { useRouter } from 'expo-router';
2+
import React from 'react';
3+
import { useTranslation } from 'react-i18next';
24
import { showMessage } from 'react-native-flash-message';
35

46
import { useLogin } from '@/api/auth/use-login';
57
import type { LoginFormProps } from '@/components/login-form';
68
import { LoginForm } from '@/components/login-form';
79
import { useAuth } from '@/core';
8-
import { FocusAwareStatusBar } from '@/ui';
10+
import { Button, FocusAwareStatusBar } from '@/ui';
911

1012
export default function Login() {
13+
const { t } = useTranslation();
1114
const router = useRouter();
1215
const signIn = useAuth.use.signIn();
1316
const { mutate: login } = useLogin({
@@ -21,10 +24,20 @@ export default function Login() {
2124
const onSubmit: LoginFormProps['onSubmit'] = (data) => {
2225
login(data);
2326
};
27+
28+
const navigateToForgotPasswordScreen = () => {
29+
router.push('/forgot-password');
30+
};
2431
return (
2532
<>
2633
<FocusAwareStatusBar />
2734
<LoginForm onSubmit={onSubmit} />
35+
<Button
36+
testID="login-button"
37+
variant="link"
38+
label={t('auth.sign-in.forgotPasswordButton')}
39+
onPress={navigateToForgotPasswordScreen}
40+
/>
2841
</>
2942
);
3043
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { cleanup, fireEvent, render, screen, waitFor } from '@/core/test-utils';
2+
3+
import {
4+
ForgotPasswordForm,
5+
type ForgotPasswordFormProps,
6+
} from './forgot-password-form';
7+
8+
afterEach(cleanup);
9+
10+
const onSubmitMock: jest.Mock<ForgotPasswordFormProps['onSubmit']> = jest.fn();
11+
12+
describe('ForgotPasswordForm', () => {
13+
const SEND_EMAIL_BUTTON = 'send-email-button';
14+
const EMAIL_INPUT = 'email-input';
15+
16+
it('renders correctly', async () => {
17+
render(<ForgotPasswordForm onSubmit={onSubmitMock} />);
18+
expect(await screen.findByText(/Forgot your password?/i)).toBeOnTheScreen();
19+
});
20+
21+
it('should display error when email is empty', async () => {
22+
render(<ForgotPasswordForm onSubmit={onSubmitMock} />);
23+
const button = screen.getByTestId(SEND_EMAIL_BUTTON);
24+
fireEvent.press(button);
25+
expect(await screen.findByText(/Required/i)).toBeOnTheScreen();
26+
});
27+
28+
it('should display error when email is invalid', async () => {
29+
render(<ForgotPasswordForm onSubmit={onSubmitMock} />);
30+
const emailInput = screen.getByTestId(EMAIL_INPUT);
31+
const button = screen.getByTestId(SEND_EMAIL_BUTTON);
32+
33+
fireEvent.changeText(emailInput, 'invalid-email');
34+
fireEvent.press(button);
35+
36+
expect(await screen.findByText(/Invalid email format/i)).toBeOnTheScreen();
37+
});
38+
39+
it('should call onSubmit with correct values when email is valid', async () => {
40+
render(<ForgotPasswordForm onSubmit={onSubmitMock} />);
41+
const emailInput = screen.getByTestId(EMAIL_INPUT);
42+
const button = screen.getByTestId(SEND_EMAIL_BUTTON);
43+
44+
fireEvent.changeText(emailInput, '[email protected]');
45+
fireEvent.press(button);
46+
47+
await waitFor(() => {
48+
expect(onSubmitMock).toHaveBeenCalledTimes(1);
49+
});
50+
51+
expect(onSubmitMock).toHaveBeenCalledWith({
52+
53+
});
54+
});
55+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { useForm } from 'react-hook-form';
3+
import { useTranslation } from 'react-i18next';
4+
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
5+
import { z } from 'zod';
6+
7+
import { translate } from '@/core';
8+
import { Button, ControlledInput, Text, View } from '@/ui';
9+
10+
const schema = z.object({
11+
email: z
12+
.string()
13+
.email(translate('forgotPassword.emailInvalidFormatFormError')),
14+
});
15+
16+
export type FormType = z.infer<typeof schema>;
17+
18+
export type ForgotPasswordFormProps = {
19+
onSubmit: (data: FormType) => void;
20+
};
21+
22+
export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
23+
const { t } = useTranslation();
24+
const { handleSubmit, control } = useForm<{
25+
email: string;
26+
}>({
27+
resolver: zodResolver(schema),
28+
});
29+
30+
const onSubmit = (data: FormType) => {
31+
props.onSubmit(data);
32+
};
33+
34+
return (
35+
<KeyboardAvoidingView>
36+
<View className="gap-8 p-4">
37+
<View className="gap-2">
38+
<Text
39+
testID="forgot-password-form-title"
40+
className="text-center text-2xl"
41+
>
42+
{t('forgotPassword.title')}
43+
</Text>
44+
<Text className="text-center text-gray-600">
45+
{t('forgotPassword.description')}
46+
</Text>
47+
</View>
48+
<View className="gap-2">
49+
<ControlledInput
50+
testID="email-input"
51+
autoCapitalize="none"
52+
autoComplete="email"
53+
control={control}
54+
name="email"
55+
label={t('forgotPassword.emailLabel')}
56+
placeholder={t('forgotPassword.emailPlaceholder')}
57+
/>
58+
<Text className="text-sm text-gray-500">
59+
{t('forgotPassword.instructions')}
60+
</Text>
61+
<Button
62+
testID="send-email-button"
63+
label={t('forgotPassword.buttonLabel')}
64+
onPress={handleSubmit(onSubmit)}
65+
/>
66+
</View>
67+
</View>
68+
</KeyboardAvoidingView>
69+
);
70+
};

src/components/login-form.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export const LoginForm = ({ onSubmit = () => {} }: LoginFormProps) => {
4242

4343
<ControlledInput
4444
testID="email-input"
45+
autoCapitalize="none"
46+
autoComplete="email"
4547
control={control}
4648
name="email"
4749
label="Email (optional)"

src/translations/en.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
11
{
2+
"auth": {
3+
"sign-in": {
4+
"forgotPasswordButton": "Forgot Password"
5+
}
6+
},
7+
"forgotPassword": {
8+
"buttonLabel": "Send Reset Instructions",
9+
"description": "Enter your email address below and we'll send you instructions to reset your password.",
10+
"emailInvalidFormatFormError": "Invalid email format",
11+
"emailLabel": "Email Address",
12+
"emailPlaceholder": "Enter your email",
13+
"errorMessage": "There was an error sending the instructions. Please try again.",
14+
"instructions": "You'll receive an email with a link to reset your password. Please check your inbox.",
15+
"successMessage": "Instructions to reset your password have been sent to your email.",
16+
"title": "Forgot your password?"
17+
},
218
"onboarding": {
319
"message": "Welcome to rootstrap app site"
420
},

0 commit comments

Comments
 (0)