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:
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
@ -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) };
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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',
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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],
|
||||
};
|
||||
Reference in New Issue
Block a user