Skip to content
This repository was archived by the owner on May 3, 2025. It is now read-only.

Commit 4f9c718

Browse files
Reset password page/feature (#145)
* Scaffold out reset password route/page * Implementation of reset password page * Add header to LoginOrRegister component, move header in ResetPasswordPage -> Form for consistency * Rename LoginOrRegister with Form appended for consistency * Move redirection logic to login form only * Add hook for parsing query param values from reset password redirect * Add NotFoundPage * Remove icon from NotFound route * Add empty state for invalid or expired tokens, add function for clearing out query param data * Rename useResetPassword to better reflect intent behind hook mutation * Begin implementing ChangePasswordForm component * Implement change password hook/form * Pop success toast and redirect home on success * Add Forgot password link to login/register form * Add padding to NotFoundPage and ResetPasswordPage when expired token empty state is shown * Debounce/update password change validation * Swap rooks useInput w/ custom * Simplify validation in LoginOrRegisterForm with custom input hook
1 parent f4efd38 commit 4f9c718

19 files changed

+713
-191
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { ErrorAlert } from "components/error-alert";
2+
import { Flex } from "components/flex";
3+
import { Form } from "components/forms/form";
4+
import { ErrorMessages } from "constants/error-messages";
5+
import {
6+
Button,
7+
Heading,
8+
majorScale,
9+
TextInputField,
10+
toaster,
11+
} from "evergreen-ui";
12+
import { isEmpty } from "lodash";
13+
import { ChangeEvent, useCallback, useRef } from "react";
14+
import { useNavigate } from "react-router";
15+
import { Sitemap } from "sitemap";
16+
import { useChangePassword } from "utils/hooks/supabase/use-change-password";
17+
import { useInput } from "utils/hooks/use-input";
18+
import { ResetPasswordQueryParams } from "utils/hooks/use-reset-password-route";
19+
20+
interface ChangePasswordFormProps
21+
extends Pick<ResetPasswordQueryParams, "access_token"> {}
22+
23+
const ChangePasswordForm: React.FC<ChangePasswordFormProps> = (
24+
props: ChangePasswordFormProps
25+
) => {
26+
const { access_token } = props;
27+
const navigate = useNavigate();
28+
const {
29+
value: password,
30+
onChange: onPasswordChange,
31+
...passwordValidation
32+
} = useInput({ isRequired: true });
33+
34+
const {
35+
value: passwordConfirmation,
36+
onChange: onPasswordConfirmationChange,
37+
setValidation: setPasswordConfirmationValidation,
38+
...passwordConfirmationValidation
39+
} = useInput({ isRequired: true });
40+
41+
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
42+
43+
const handleChangePasswordSuccess = useCallback(() => {
44+
toaster.success("Password successfully updated!");
45+
navigate(Sitemap.home);
46+
}, [navigate]);
47+
48+
const {
49+
isLoading,
50+
error,
51+
mutate: changePassword,
52+
} = useChangePassword({
53+
onSuccess: handleChangePasswordSuccess,
54+
});
55+
56+
const updateValidation = useCallback(
57+
(password?: string, passwordConfirmation?: string) => {
58+
const passwordsAreEmpty =
59+
isEmpty(password) || isEmpty(passwordConfirmation);
60+
const passwordsMatch = password === passwordConfirmation;
61+
62+
if (validationTimeoutRef.current != null) {
63+
clearTimeout(validationTimeoutRef.current);
64+
}
65+
66+
if (passwordsAreEmpty || passwordsMatch) {
67+
return;
68+
}
69+
70+
validationTimeoutRef.current = setTimeout(() => {
71+
setPasswordConfirmationValidation({
72+
isInvalid: true,
73+
validationMessage: ErrorMessages.PASSWORDS_DO_NOT_MATCH,
74+
});
75+
}, 300);
76+
},
77+
[setPasswordConfirmationValidation]
78+
);
79+
80+
const handlePasswordChange = useCallback(
81+
(event: ChangeEvent<HTMLInputElement>) => {
82+
const { value: password } = event.target;
83+
onPasswordChange(event);
84+
updateValidation(password, passwordConfirmation);
85+
},
86+
[onPasswordChange, passwordConfirmation, updateValidation]
87+
);
88+
89+
const handlePasswordConfirmationChange = useCallback(
90+
(event: ChangeEvent<HTMLInputElement>) => {
91+
const { value: passwordConfirmation } = event.target;
92+
onPasswordConfirmationChange(event);
93+
updateValidation(password, passwordConfirmation);
94+
},
95+
[onPasswordConfirmationChange, password, updateValidation]
96+
);
97+
98+
const handleSubmit = useCallback(
99+
(event: React.FormEvent) => {
100+
event.preventDefault();
101+
102+
const isInvalid =
103+
isEmpty(password) ||
104+
isEmpty(passwordConfirmation) ||
105+
password !== passwordConfirmation;
106+
107+
if (isInvalid) {
108+
return;
109+
}
110+
111+
changePassword({ access_token, password });
112+
},
113+
[access_token, changePassword, password, passwordConfirmation]
114+
);
115+
116+
return (
117+
<Flex.Column alignItems="center" maxWidth={majorScale(60)}>
118+
<Heading marginBottom={majorScale(2)} size={800}>
119+
Change your password
120+
</Heading>
121+
<Form onSubmit={handleSubmit} width={majorScale(30)}>
122+
<TextInputField
123+
{...passwordValidation}
124+
label="Password"
125+
onChange={handlePasswordChange}
126+
type="password"
127+
value={password}
128+
/>
129+
<TextInputField
130+
{...passwordConfirmationValidation}
131+
label="Confirm Password"
132+
onChange={handlePasswordConfirmationChange}
133+
type="password"
134+
value={passwordConfirmation}
135+
/>
136+
<Button
137+
disabled={
138+
passwordValidation?.isInvalid ||
139+
passwordConfirmationValidation.isInvalid
140+
}
141+
isLoading={isLoading}
142+
onClick={handleSubmit}
143+
width="100%">
144+
Change Password
145+
</Button>
146+
</Form>
147+
{error != null && <ErrorAlert error={error} />}
148+
</Flex.Column>
149+
);
150+
};
151+
152+
export { ChangePasswordForm };
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import {
2+
TextInputField,
3+
majorScale,
4+
Button,
5+
Alert,
6+
Link,
7+
Heading,
8+
} from "evergreen-ui";
9+
import { useBoolean } from "utils/hooks/use-boolean";
10+
import { useLogin } from "utils/hooks/supabase/use-login";
11+
import { useRegister } from "utils/hooks/supabase/use-register";
12+
import { Form } from "components/forms/form";
13+
import { FormEvent, MouseEvent, useCallback, useMemo } from "react";
14+
import { isNilOrEmpty } from "utils/core-utils";
15+
import { Flex } from "components/flex";
16+
import { SupabaseUser } from "types/supabase-user";
17+
import { UserRecord } from "models/user-record";
18+
import { useCreateOrUpdateUser } from "generated/hooks/domain/users/use-create-or-update-user";
19+
import { useGlobalState } from "utils/hooks/use-global-state";
20+
import { useNavigate } from "react-router";
21+
import { Link as ReactRouterLink } from "react-router-dom";
22+
import { Sitemap } from "sitemap";
23+
import { ErrorAlert } from "components/error-alert";
24+
import { absolutePath } from "utils/route-utils";
25+
import { useInput } from "utils/hooks/use-input";
26+
27+
interface LoginOrRegisterFormProps {
28+
initialShowRegister: boolean;
29+
}
30+
31+
const marginBottom = majorScale(3);
32+
33+
const LoginOrRegisterForm: React.FC<LoginOrRegisterFormProps> = (
34+
props: LoginOrRegisterFormProps
35+
) => {
36+
const { initialShowRegister } = props;
37+
const { setGlobalState } = useGlobalState();
38+
const navigate = useNavigate();
39+
const { value: showRegister, toggle: toggleShowRegister } =
40+
useBoolean(initialShowRegister);
41+
const {
42+
value: email,
43+
onChange: handleEmailChange,
44+
...emailValidation
45+
} = useInput({
46+
initialValue: "",
47+
isRequired: true,
48+
});
49+
const {
50+
value: password,
51+
onChange: handlePasswordChange,
52+
...passwordValidation
53+
} = useInput({
54+
initialValue: "",
55+
isRequired: true,
56+
});
57+
const { mutate: createOrUpdateUser } = useCreateOrUpdateUser({
58+
onConflict: "id",
59+
});
60+
const {
61+
mutate: register,
62+
reset: resetRegister,
63+
isLoading: isRegisterLoading,
64+
isSuccess: isRegisterSuccess,
65+
error: registerError,
66+
} = useRegister();
67+
const {
68+
mutate: login,
69+
reset: resetLogin,
70+
isLoading: isLoginLoading,
71+
error: loginError,
72+
} = useLogin({
73+
onSuccess: (supabaseUser: SupabaseUser) => {
74+
const user = UserRecord.fromSupabaseUser(supabaseUser);
75+
createOrUpdateUser(user);
76+
setGlobalState((prev) => prev.setUser(supabaseUser));
77+
navigate(Sitemap.home);
78+
},
79+
});
80+
81+
const error = loginError ?? registerError;
82+
83+
const renderError =
84+
(showRegister && registerError != null) ||
85+
(!showRegister && loginError != null);
86+
const buttonText = showRegister ? "Register" : "Login";
87+
const toggleLinkText = showRegister
88+
? "Already have an account? Login here"
89+
: "Need an account? Register here";
90+
91+
const validate = useCallback((): boolean => {
92+
const emailIsInvalid = isNilOrEmpty(email);
93+
const passwordIsInvalid = isNilOrEmpty(password);
94+
const isValid = !emailIsInvalid && !passwordIsInvalid;
95+
return isValid;
96+
}, [email, password]);
97+
98+
const handleLogin = useCallback(
99+
(event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement>) => {
100+
event.preventDefault();
101+
if (!validate()) {
102+
return;
103+
}
104+
105+
login({ email: email!, password: password! });
106+
},
107+
[email, login, password, validate]
108+
);
109+
110+
const handleRegister = useCallback(
111+
(event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement>) => {
112+
event.preventDefault();
113+
114+
if (!validate()) {
115+
return;
116+
}
117+
118+
register({ email: email!, password: password! });
119+
},
120+
[email, password, register, validate]
121+
);
122+
123+
const handleSubmit = useMemo(
124+
() => (showRegister ? handleRegister : handleLogin),
125+
[handleLogin, handleRegister, showRegister]
126+
);
127+
128+
const toggleAndReset = useCallback(() => {
129+
toggleShowRegister();
130+
resetLogin();
131+
resetRegister();
132+
}, [resetLogin, resetRegister, toggleShowRegister]);
133+
134+
return (
135+
<Flex.Column alignItems="center" maxWidth={majorScale(60)}>
136+
<Heading marginBottom={majorScale(2)} size={800}>
137+
{showRegister ? "Register" : "Login"}
138+
</Heading>
139+
<Form
140+
display="flex"
141+
flexDirection="column"
142+
onSubmit={handleSubmit}
143+
width={majorScale(30)}>
144+
<TextInputField
145+
{...emailValidation}
146+
disabled={showRegister ? isRegisterLoading : isLoginLoading}
147+
label="Email"
148+
onChange={handleEmailChange}
149+
value={email}
150+
/>
151+
<TextInputField
152+
{...passwordValidation}
153+
disabled={showRegister ? isRegisterLoading : isLoginLoading}
154+
label="Password"
155+
onChange={handlePasswordChange}
156+
type="password"
157+
value={password}
158+
/>
159+
<Button
160+
isLoading={
161+
showRegister ? isRegisterLoading : isLoginLoading
162+
}
163+
marginBottom={marginBottom}
164+
onClick={handleSubmit}>
165+
{buttonText}
166+
</Button>
167+
<Link marginBottom={marginBottom} onClick={toggleAndReset}>
168+
{toggleLinkText}
169+
</Link>
170+
<Link
171+
is={ReactRouterLink}
172+
marginBottom={marginBottom}
173+
to={absolutePath(Sitemap.resetPassword)}>
174+
Forgot your password?
175+
</Link>
176+
</Form>
177+
{renderError && <ErrorAlert error={error} />}
178+
{isRegisterSuccess && showRegister && (
179+
<Alert intent="success" title="Account successfully created.">
180+
Check your email for a confirmation link to sign in.
181+
</Alert>
182+
)}
183+
</Flex.Column>
184+
);
185+
};
186+
187+
export { LoginOrRegisterForm };

0 commit comments

Comments
 (0)