GH-3652 Add forgot password on sign-in page (#3789)

* Remove auth guard from password reset email endpoint

* Add arg for GQL mutation and update its usage

* Add forgot password button on sign-in page

* Generate automated graphql queries

* Move utils to dedicated hook

* Remove useless hook function

* Split simple hook methods

* Split workspace hook

* Split signInWithGoogle hook

* Split useSignInUpForm

* Fix error in logs

* Add Link Button UI Component

* Add storybook doc

---------

Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
Deepak Kumar
2024-02-09 22:07:44 +05:30
committed by GitHub
parent 917fc5bd4d
commit 3cbf958a1c
16 changed files with 399 additions and 116 deletions

View File

@ -1,8 +1,8 @@
import { gql } from '@apollo/client';
export const EMAIL_PASSWORD_RESET_Link = gql`
mutation EmailPasswordResetLink {
emailPasswordResetLink {
mutation EmailPasswordResetLink($email: String!) {
emailPasswordResetLink(email: $email) {
success
}
}

View File

@ -1,13 +1,20 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Controller } from 'react-hook-form';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useRecoilState } from 'recoil';
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword.ts';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm.ts';
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle.ts';
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts';
import { authProvidersState } from '@/client-config/states/authProvidersState.ts';
import { IconGoogle } from '@/ui/display/icon/components/IconGoogle';
import { Loader } from '@/ui/feedback/loader/components/Loader';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { TextInput } from '@/ui/input/components/TextInput';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx';
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
import { Logo } from '../../components/Logo';
@ -43,24 +50,20 @@ const StyledInputContainer = styled.div`
`;
export const SignInUpForm = () => {
const [authProviders] = useRecoilState(authProvidersState);
const [showErrors, setShowErrors] = useState(false);
const { handleResetPassword } = useHandleResetPassword();
const workspace = useWorkspaceFromInviteHash();
const { signInWithGoogle } = useSignInWithGoogle();
const { form } = useSignInUpForm();
const {
authProviders,
signInWithGoogle,
signInUpStep,
signInUpMode,
showErrors,
setShowErrors,
continueWithCredentials,
continueWithEmail,
submitCredentials,
form: {
control,
watch,
handleSubmit,
formState: { isSubmitting },
},
workspace,
} = useSignInUp();
} = useSignInUp(form);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
@ -72,7 +75,7 @@ export const SignInUpForm = () => {
continueWithCredentials();
} else if (signInUpStep === SignInUpStep.Password) {
setShowErrors(true);
handleSubmit(submitCredentials)();
form.handleSubmit(submitCredentials)();
}
}
};
@ -88,10 +91,10 @@ export const SignInUpForm = () => {
return signInUpMode === SignInUpMode.SignIn
? 'Sign in'
: isSubmitting
: form.formState.isSubmitting
? 'Creating workspace'
: 'Sign up';
}, [signInUpMode, signInUpStep, isSubmitting]);
}, [signInUpMode, signInUpStep, form.formState.isSubmitting]);
const title = useMemo(() => {
if (signInUpMode === SignInUpMode.Invite) {
@ -141,7 +144,7 @@ export const SignInUpForm = () => {
>
<Controller
name="email"
control={control}
control={form.control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
@ -180,7 +183,7 @@ export const SignInUpForm = () => {
>
<Controller
name="password"
control={control}
control={form.control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
@ -218,24 +221,32 @@ export const SignInUpForm = () => {
return;
}
setShowErrors(true);
handleSubmit(submitCredentials)();
form.handleSubmit(submitCredentials)();
}}
Icon={() => isSubmitting && <Loader />}
Icon={() => form.formState.isSubmitting && <Loader />}
disabled={
SignInUpStep.Init
? false
: signInUpStep === SignInUpStep.Email
? !watch('email')
: !watch('email') || !watch('password') || isSubmitting
? !form.watch('email')
: !form.watch('email') ||
!form.watch('password') ||
form.formState.isSubmitting
}
fullWidth
/>
</StyledForm>
</StyledContentContainer>
<StyledFooterNote>
By using Twenty, you agree to the Terms of Service and Data Processing
Agreement.
</StyledFooterNote>
{signInUpStep === SignInUpStep.Password ? (
<ActionLink onClick={handleResetPassword(form.getValues('email'))}>
Forgot your password?
</ActionLink>
) : (
<StyledFooterNote>
By using Twenty, you agree to the Terms of Service and Data Processing
Agreement.
</StyledFooterNote>
)}
</>
);
};

View File

@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar.tsx';
import { useEmailPasswordResetLinkMutation } from '~/generated/graphql.tsx';
export const useHandleResetPassword = () => {
const { enqueueSnackBar } = useSnackBar();
const [emailPasswordResetLink] = useEmailPasswordResetLinkMutation();
const handleResetPassword = useCallback(
(email: string) => {
return async () => {
if (!email) {
enqueueSnackBar('Invalid email', {
variant: 'error',
});
return;
}
try {
const { data } = await emailPasswordResetLink({
variables: { email },
});
if (data?.emailPasswordResetLink?.success) {
enqueueSnackBar('Password reset link has been sent to the email', {
variant: 'success',
});
} else {
enqueueSnackBar('There was some issue', {
variant: 'error',
});
}
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: 'error',
});
}
};
},
[enqueueSnackBar, emailPasswordResetLink],
);
return { handleResetPassword };
};

View File

@ -1,22 +1,17 @@
import { useCallback, useEffect, useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { useCallback, useState } from 'react';
import { SubmitHandler, UseFormReturn } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRecoilState, useRecoilValue } from 'recoil';
import { z } from 'zod';
import { useRecoilValue } from 'recoil';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm.ts';
import { billingState } from '@/client-config/states/billingState';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
import { AppPath } from '@/types/AppPath';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { useAuth } from '../../hooks/useAuth';
import { PASSWORD_REGEX } from '../../utils/passwordRegex';
export enum SignInUpMode {
SignIn = 'sign-in',
@ -30,27 +25,11 @@ export enum SignInUpStep {
Password = 'password',
}
const validationSchema = z
.object({
exist: z.boolean(),
email: z.string().trim().email('Email must be a valid email'),
password: z
.string()
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
})
.required();
type Form = z.infer<typeof validationSchema>;
export const useSignInUp = () => {
export const useSignInUp = (form: UseFormReturn<Form>) => {
const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar();
const isMatchingLocation = useIsMatchingLocation();
const [authProviders] = useRecoilState(authProvidersState);
const isSignInPrefilled = useRecoilValue(isSignInPrefilledState);
const billing = useRecoilValue(billingState);
const workspaceInviteHash = useParams().workspaceInviteHash;
const [signInUpStep, setSignInUpStep] = useState<SignInUpStep>(
SignInUpStep.Init,
@ -64,31 +43,9 @@ export const useSignInUp = () => {
? SignInUpMode.SignIn
: SignInUpMode.SignUp;
});
const [showErrors, setShowErrors] = useState(false);
const { data: workspaceFromInviteHash } = useGetWorkspaceFromInviteHashQuery({
variables: { inviteHash: workspaceInviteHash || '' },
});
const form = useForm<Form>({
mode: 'onChange',
defaultValues: {
exist: false,
},
resolver: zodResolver(validationSchema),
});
useEffect(() => {
if (isSignInPrefilled) {
form.setValue('email', 'tim@apple.dev');
form.setValue('password', 'Applecar2025');
}
}, [form, isSignInPrefilled]);
const {
signInWithCredentials,
signUpWithCredentials,
signInWithGoogle,
checkUserExists: { checkUserExistsQuery },
} = useAuth();
@ -169,10 +126,6 @@ export const useSignInUp = () => {
],
);
const goBackToEmailStep = useCallback(() => {
setSignInUpStep(SignInUpStep.Email);
}, [setSignInUpStep]);
useScopedHotkeys(
'enter',
() => {
@ -193,17 +146,10 @@ export const useSignInUp = () => {
);
return {
authProviders,
signInWithGoogle: () => signInWithGoogle(workspaceInviteHash),
signInUpStep,
signInUpMode,
showErrors,
setShowErrors,
continueWithCredentials,
continueWithEmail,
goBackToEmailStep,
submitCredentials,
form,
workspace: workspaceFromInviteHash?.findWorkspaceFromInviteHash,
};
};

View File

@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRecoilValue } from 'recoil';
import { z } from 'zod';
import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex.ts';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState.ts';
const validationSchema = z
.object({
exist: z.boolean(),
email: z.string().trim().email('Email must be a valid email'),
password: z
.string()
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
})
.required();
export type Form = z.infer<typeof validationSchema>;
export const useSignInUpForm = () => {
const isSignInPrefilled = useRecoilValue(isSignInPrefilledState);
const form = useForm<Form>({
mode: 'onChange',
defaultValues: {
exist: false,
},
resolver: zodResolver(validationSchema),
});
useEffect(() => {
if (isSignInPrefilled) {
form.setValue('email', 'tim@apple.dev');
form.setValue('password', 'Applecar2025');
}
}, [form, isSignInPrefilled]);
return { form: form };
};

View File

@ -0,0 +1,9 @@
import { useParams } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth.ts';
export const useSignInWithGoogle = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;
const { signInWithGoogle } = useAuth();
return { signInWithGoogle: () => signInWithGoogle(workspaceInviteHash) };
};

View File

@ -0,0 +1,11 @@
import { useParams } from 'react-router-dom';
import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql.tsx';
export const useWorkspaceFromInviteHash = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;
const { data: workspaceFromInviteHash } = useGetWorkspaceFromInviteHashQuery({
variables: { inviteHash: workspaceInviteHash || '' },
});
return workspaceFromInviteHash?.findWorkspaceFromInviteHash;
};

View File

@ -1,17 +1,32 @@
import { useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button';
import { useEmailPasswordResetLinkMutation } from '~/generated/graphql';
import { logError } from '~/utils/logError';
export const ChangePassword = () => {
const { enqueueSnackBar } = useSnackBar();
const currentUser = useRecoilValue(currentUserState);
const [emailPasswordResetLink] = useEmailPasswordResetLinkMutation();
const handlePasswordResetClick = async () => {
if (!currentUser?.email) {
enqueueSnackBar('Invalid email', {
variant: 'error',
});
return;
}
try {
const { data } = await emailPasswordResetLink();
const { data } = await emailPasswordResetLink({
variables: {
email: currentUser.email,
},
});
if (data?.emailPasswordResetLink?.success) {
enqueueSnackBar('Password reset link has been sent to the email', {
variant: 'success',
@ -22,7 +37,6 @@ export const ChangePassword = () => {
});
}
} catch (error) {
logError(error);
enqueueSnackBar((error as Error).message, {
variant: 'error',
});

View File

@ -0,0 +1,31 @@
import React from 'react';
import styled from '@emotion/styled';
const StyledButtonLink = styled.a`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
padding: 0 ${({ theme }) => theme.spacing(1)};
text-decoration: none;
:hover {
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
}
`;
export const ActionLink = (props: React.ComponentProps<'a'>) => {
return (
<StyledButtonLink
href={props.href}
onClick={props.onClick}
target={props.target}
rel={props.rel}
>
{props.children}
</StyledButtonLink>
);
};

View File

@ -1,33 +1,18 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconBrandGithub } from '@/ui/display/icon';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx';
import packageJson from '../../../../../../package.json';
import { githubLink } from '../constants';
const StyledVersionLink = styled.a`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
padding: 0 ${({ theme }) => theme.spacing(1)};
text-decoration: none;
:hover {
color: ${({ theme }) => theme.font.color.tertiary};
}
`;
export const GithubVersionLink = () => {
const theme = useTheme();
return (
<StyledVersionLink href={githubLink} target="_blank" rel="noreferrer">
<ActionLink href={githubLink} target="_blank" rel="noreferrer">
<IconBrandGithub size={theme.icon.size.md} />
{packageJson.version}
</StyledVersionLink>
</ActionLink>
);
};

View File

@ -0,0 +1,27 @@
import { Meta, StoryObj } from '@storybook/react';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator.tsx';
const meta: Meta<typeof ActionLink> = {
title: 'UI/navigation/link/ActionLink',
component: ActionLink,
};
export default meta;
type Story = StoryObj<typeof ActionLink>;
export const Default: Story = {
args: {
children: 'Need to reset your password?',
onClick: () => alert('Action link clicked'),
target: undefined,
rel: undefined,
},
argTypes: {
href: { control: false },
target: { type: 'string' },
rel: { type: 'string' },
},
decorators: [ComponentDecorator],
};