GH-3245 Change password from settings page (#3538)
* GH-3245 add passwordResetToken and passwordResetTokenExpiresAt column on user entity * Add password reset token expiry delay env variable * Add generatePasswordResetToken mutation resolver * Update .env.sample file on server * Add password reset token and expiry migration script * Add validate password reset token query and a dummy password update (WIP) resolver * Fix bug in password reset token generate * add update password mutation * Update name and add email password reset link * Add change password UI on settings page * Add reset password route on frontend * Add reset password form UI * sign in user on password reset * format code * make PASSWORD_RESET_TOKEN_EXPIRES_IN optional * add email template for password reset * Improve error message * Rename methods and DTO to improve naming * fix formatting of backend code * Update change password component * Update password reset via token component * update graphql files * spelling fix * Make password-reset route authless on frontend * show token generation wait time * remove constant from .env.example * Add PASSWORD_RESET_TOKEN_EXPIRES_IN in docs * refactor emails module in reset password * update Graphql generated file * update email template of password reset * add space between date and text * update method name * fix lint issues * remove unused code, fix indentation, and email link color * update test file for auth and token service * Fix ci: build twenty-emails when running tests --------- Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
@ -10,6 +10,7 @@ import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
|
||||
import { useDefaultHomePagePath } from '~/hooks/useDefaultHomePagePath';
|
||||
import { CreateProfile } from '~/pages/auth/CreateProfile';
|
||||
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
|
||||
import { PasswordReset } from '~/pages/auth/PasswordReset';
|
||||
import { PlanRequired } from '~/pages/auth/PlanRequired';
|
||||
import { SignInUp } from '~/pages/auth/SignInUp';
|
||||
import { VerifyEffect } from '~/pages/auth/VerifyEffect';
|
||||
@ -59,6 +60,7 @@ export const App = () => {
|
||||
<Route path={AppPath.SignIn} element={<SignInUp />} />
|
||||
<Route path={AppPath.SignUp} element={<SignInUp />} />
|
||||
<Route path={AppPath.Invite} element={<SignInUp />} />
|
||||
<Route path={AppPath.ResetPassword} element={<PasswordReset />} />
|
||||
<Route path={AppPath.CreateWorkspace} element={<CreateWorkspace />} />
|
||||
<Route path={AppPath.CreateProfile} element={<CreateProfile />} />
|
||||
<Route path={AppPath.PlanRequired} element={<PlanRequired />} />
|
||||
|
||||
@ -54,14 +54,14 @@ export const PageChangeEffect = () => {
|
||||
}, [location, previousLocation]);
|
||||
|
||||
useEffect(() => {
|
||||
const isMachinOngoingUserCreationRoute =
|
||||
const isMatchingOngoingUserCreationRoute =
|
||||
isMatchingLocation(AppPath.SignUp) ||
|
||||
isMatchingLocation(AppPath.SignIn) ||
|
||||
isMatchingLocation(AppPath.Invite) ||
|
||||
isMatchingLocation(AppPath.Verify);
|
||||
|
||||
const isMatchingOnboardingRoute =
|
||||
isMachinOngoingUserCreationRoute ||
|
||||
isMatchingOngoingUserCreationRoute ||
|
||||
isMatchingLocation(AppPath.CreateWorkspace) ||
|
||||
isMatchingLocation(AppPath.CreateProfile) ||
|
||||
isMatchingLocation(AppPath.PlanRequired);
|
||||
@ -75,7 +75,8 @@ export const PageChangeEffect = () => {
|
||||
|
||||
if (
|
||||
onboardingStatus === OnboardingStatus.OngoingUserCreation &&
|
||||
!isMachinOngoingUserCreationRoute
|
||||
!isMatchingOngoingUserCreationRoute &&
|
||||
!isMatchingLocation(AppPath.ResetPassword)
|
||||
) {
|
||||
navigate(AppPath.SignIn);
|
||||
} else if (
|
||||
|
||||
@ -495,6 +495,8 @@ export type User = {
|
||||
id: Scalars['ID']['output'];
|
||||
lastName: Scalars['String']['output'];
|
||||
passwordHash?: Maybe<Scalars['String']['output']>;
|
||||
passwordResetToken?: Maybe<Scalars['String']['output']>;
|
||||
passwordResetTokenExpiresAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
supportUserHash?: Maybe<Scalars['String']['output']>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
workspaceMember: WorkspaceMember;
|
||||
|
||||
@ -97,6 +97,12 @@ export type CursorPaging = {
|
||||
last?: InputMaybe<Scalars['Int']>;
|
||||
};
|
||||
|
||||
export type EmailPasswordResetLink = {
|
||||
__typename?: 'EmailPasswordResetLink';
|
||||
/** Boolean that confirms query was dispatched */
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type FeatureFlag = {
|
||||
__typename?: 'FeatureFlag';
|
||||
id: Scalars['ID'];
|
||||
@ -199,6 +205,12 @@ export type IdFilterComparison = {
|
||||
notLike?: InputMaybe<Scalars['ID']>;
|
||||
};
|
||||
|
||||
export type InvalidatePassword = {
|
||||
__typename?: 'InvalidatePassword';
|
||||
/** Boolean that confirms query was dispatched */
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type LoginToken = {
|
||||
__typename?: 'LoginToken';
|
||||
loginToken: AuthToken;
|
||||
@ -213,12 +225,14 @@ export type Mutation = {
|
||||
deleteCurrentWorkspace: Workspace;
|
||||
deleteOneObject: ObjectDeleteResponse;
|
||||
deleteUser: User;
|
||||
emailPasswordResetLink: EmailPasswordResetLink;
|
||||
generateApiKeyToken: ApiKeyToken;
|
||||
generateTransientToken: TransientToken;
|
||||
impersonate: Verify;
|
||||
renewToken: AuthTokens;
|
||||
signUp: LoginToken;
|
||||
updateOneObject: Object;
|
||||
updatePasswordViaResetToken: InvalidatePassword;
|
||||
updateWorkspace: Workspace;
|
||||
uploadFile: Scalars['String'];
|
||||
uploadImage: Scalars['String'];
|
||||
@ -268,6 +282,12 @@ export type MutationSignUpArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdatePasswordViaResetTokenArgs = {
|
||||
newPassword: Scalars['String'];
|
||||
passwordResetToken: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateWorkspaceArgs = {
|
||||
data: UpdateWorkspaceInput;
|
||||
};
|
||||
@ -362,6 +382,7 @@ export type Query = {
|
||||
getTimelineThreadsFromPersonId: Array<TimelineThread>;
|
||||
object: Object;
|
||||
objects: ObjectConnection;
|
||||
validatePasswordResetToken: ValidatePasswordResetToken;
|
||||
};
|
||||
|
||||
|
||||
@ -389,6 +410,11 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = {
|
||||
personId: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryValidatePasswordResetTokenArgs = {
|
||||
passwordResetToken: Scalars['String'];
|
||||
};
|
||||
|
||||
export type RefreshToken = {
|
||||
__typename?: 'RefreshToken';
|
||||
createdAt: Scalars['DateTime'];
|
||||
@ -500,6 +526,8 @@ export type User = {
|
||||
id: Scalars['ID'];
|
||||
lastName: Scalars['String'];
|
||||
passwordHash?: Maybe<Scalars['String']>;
|
||||
passwordResetToken?: Maybe<Scalars['String']>;
|
||||
passwordResetTokenExpiresAt?: Maybe<Scalars['DateTime']>;
|
||||
supportUserHash?: Maybe<Scalars['String']>;
|
||||
updatedAt: Scalars['DateTime'];
|
||||
workspaceMember: WorkspaceMember;
|
||||
@ -518,6 +546,12 @@ export type UserExists = {
|
||||
exists: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type ValidatePasswordResetToken = {
|
||||
__typename?: 'ValidatePasswordResetToken';
|
||||
email: Scalars['String'];
|
||||
id: Scalars['String'];
|
||||
};
|
||||
|
||||
export type Verify = {
|
||||
__typename?: 'Verify';
|
||||
tokens: AuthTokenPair;
|
||||
@ -694,6 +728,11 @@ export type ChallengeMutationVariables = Exact<{
|
||||
|
||||
export type ChallengeMutation = { __typename?: 'Mutation', challenge: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } };
|
||||
|
||||
export type EmailPasswordResetLinkMutationVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type EmailPasswordResetLinkMutation = { __typename?: 'Mutation', emailPasswordResetLink: { __typename?: 'EmailPasswordResetLink', success: boolean } };
|
||||
|
||||
export type GenerateApiKeyTokenMutationVariables = Exact<{
|
||||
apiKeyId: Scalars['String'];
|
||||
expiresAt: Scalars['String'];
|
||||
@ -730,6 +769,14 @@ export type SignUpMutationVariables = Exact<{
|
||||
|
||||
export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } };
|
||||
|
||||
export type UpdatePasswordViaResetTokenMutationVariables = Exact<{
|
||||
token: Scalars['String'];
|
||||
newPassword: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdatePasswordViaResetTokenMutation = { __typename?: 'Mutation', updatePasswordViaResetToken: { __typename?: 'InvalidatePassword', success: boolean } };
|
||||
|
||||
export type VerifyMutationVariables = Exact<{
|
||||
loginToken: Scalars['String'];
|
||||
}>;
|
||||
@ -744,6 +791,13 @@ export type CheckUserExistsQueryVariables = Exact<{
|
||||
|
||||
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename?: 'UserExists', exists: boolean } };
|
||||
|
||||
export type ValidatePasswordResetTokenQueryVariables = Exact<{
|
||||
token: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type ValidatePasswordResetTokenQuery = { __typename?: 'Query', validatePasswordResetToken: { __typename?: 'ValidatePasswordResetToken', id: string, email: string } };
|
||||
|
||||
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@ -1008,6 +1062,38 @@ export function useChallengeMutation(baseOptions?: Apollo.MutationHookOptions<Ch
|
||||
export type ChallengeMutationHookResult = ReturnType<typeof useChallengeMutation>;
|
||||
export type ChallengeMutationResult = Apollo.MutationResult<ChallengeMutation>;
|
||||
export type ChallengeMutationOptions = Apollo.BaseMutationOptions<ChallengeMutation, ChallengeMutationVariables>;
|
||||
export const EmailPasswordResetLinkDocument = gql`
|
||||
mutation EmailPasswordResetLink {
|
||||
emailPasswordResetLink {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type EmailPasswordResetLinkMutationFn = Apollo.MutationFunction<EmailPasswordResetLinkMutation, EmailPasswordResetLinkMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useEmailPasswordResetLinkMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useEmailPasswordResetLinkMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useEmailPasswordResetLinkMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [emailPasswordResetLinkMutation, { data, loading, error }] = useEmailPasswordResetLinkMutation({
|
||||
* variables: {
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useEmailPasswordResetLinkMutation(baseOptions?: Apollo.MutationHookOptions<EmailPasswordResetLinkMutation, EmailPasswordResetLinkMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<EmailPasswordResetLinkMutation, EmailPasswordResetLinkMutationVariables>(EmailPasswordResetLinkDocument, options);
|
||||
}
|
||||
export type EmailPasswordResetLinkMutationHookResult = ReturnType<typeof useEmailPasswordResetLinkMutation>;
|
||||
export type EmailPasswordResetLinkMutationResult = Apollo.MutationResult<EmailPasswordResetLinkMutation>;
|
||||
export type EmailPasswordResetLinkMutationOptions = Apollo.BaseMutationOptions<EmailPasswordResetLinkMutation, EmailPasswordResetLinkMutationVariables>;
|
||||
export const GenerateApiKeyTokenDocument = gql`
|
||||
mutation GenerateApiKeyToken($apiKeyId: String!, $expiresAt: String!) {
|
||||
generateApiKeyToken(apiKeyId: $apiKeyId, expiresAt: $expiresAt) {
|
||||
@ -1191,6 +1277,43 @@ export function useSignUpMutation(baseOptions?: Apollo.MutationHookOptions<SignU
|
||||
export type SignUpMutationHookResult = ReturnType<typeof useSignUpMutation>;
|
||||
export type SignUpMutationResult = Apollo.MutationResult<SignUpMutation>;
|
||||
export type SignUpMutationOptions = Apollo.BaseMutationOptions<SignUpMutation, SignUpMutationVariables>;
|
||||
export const UpdatePasswordViaResetTokenDocument = gql`
|
||||
mutation UpdatePasswordViaResetToken($token: String!, $newPassword: String!) {
|
||||
updatePasswordViaResetToken(
|
||||
passwordResetToken: $token
|
||||
newPassword: $newPassword
|
||||
) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type UpdatePasswordViaResetTokenMutationFn = Apollo.MutationFunction<UpdatePasswordViaResetTokenMutation, UpdatePasswordViaResetTokenMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useUpdatePasswordViaResetTokenMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useUpdatePasswordViaResetTokenMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useUpdatePasswordViaResetTokenMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [updatePasswordViaResetTokenMutation, { data, loading, error }] = useUpdatePasswordViaResetTokenMutation({
|
||||
* variables: {
|
||||
* token: // value for 'token'
|
||||
* newPassword: // value for 'newPassword'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useUpdatePasswordViaResetTokenMutation(baseOptions?: Apollo.MutationHookOptions<UpdatePasswordViaResetTokenMutation, UpdatePasswordViaResetTokenMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<UpdatePasswordViaResetTokenMutation, UpdatePasswordViaResetTokenMutationVariables>(UpdatePasswordViaResetTokenDocument, options);
|
||||
}
|
||||
export type UpdatePasswordViaResetTokenMutationHookResult = ReturnType<typeof useUpdatePasswordViaResetTokenMutation>;
|
||||
export type UpdatePasswordViaResetTokenMutationResult = Apollo.MutationResult<UpdatePasswordViaResetTokenMutation>;
|
||||
export type UpdatePasswordViaResetTokenMutationOptions = Apollo.BaseMutationOptions<UpdatePasswordViaResetTokenMutation, UpdatePasswordViaResetTokenMutationVariables>;
|
||||
export const VerifyDocument = gql`
|
||||
mutation Verify($loginToken: String!) {
|
||||
verify(loginToken: $loginToken) {
|
||||
@ -1265,6 +1388,42 @@ export function useCheckUserExistsLazyQuery(baseOptions?: Apollo.LazyQueryHookOp
|
||||
export type CheckUserExistsQueryHookResult = ReturnType<typeof useCheckUserExistsQuery>;
|
||||
export type CheckUserExistsLazyQueryHookResult = ReturnType<typeof useCheckUserExistsLazyQuery>;
|
||||
export type CheckUserExistsQueryResult = Apollo.QueryResult<CheckUserExistsQuery, CheckUserExistsQueryVariables>;
|
||||
export const ValidatePasswordResetTokenDocument = gql`
|
||||
query validatePasswordResetToken($token: String!) {
|
||||
validatePasswordResetToken(passwordResetToken: $token) {
|
||||
id
|
||||
email
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useValidatePasswordResetTokenQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useValidatePasswordResetTokenQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useValidatePasswordResetTokenQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useValidatePasswordResetTokenQuery({
|
||||
* variables: {
|
||||
* token: // value for 'token'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useValidatePasswordResetTokenQuery(baseOptions: Apollo.QueryHookOptions<ValidatePasswordResetTokenQuery, ValidatePasswordResetTokenQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<ValidatePasswordResetTokenQuery, ValidatePasswordResetTokenQueryVariables>(ValidatePasswordResetTokenDocument, options);
|
||||
}
|
||||
export function useValidatePasswordResetTokenLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ValidatePasswordResetTokenQuery, ValidatePasswordResetTokenQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<ValidatePasswordResetTokenQuery, ValidatePasswordResetTokenQueryVariables>(ValidatePasswordResetTokenDocument, options);
|
||||
}
|
||||
export type ValidatePasswordResetTokenQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenQuery>;
|
||||
export type ValidatePasswordResetTokenLazyQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenLazyQuery>;
|
||||
export type ValidatePasswordResetTokenQueryResult = Apollo.QueryResult<ValidatePasswordResetTokenQuery, ValidatePasswordResetTokenQueryVariables>;
|
||||
export const GetClientConfigDocument = gql`
|
||||
query GetClientConfig {
|
||||
clientConfig {
|
||||
|
||||
@ -42,7 +42,8 @@ export const useApolloFactory = () => {
|
||||
!isMatchingLocation(AppPath.Verify) &&
|
||||
!isMatchingLocation(AppPath.SignIn) &&
|
||||
!isMatchingLocation(AppPath.SignUp) &&
|
||||
!isMatchingLocation(AppPath.Invite)
|
||||
!isMatchingLocation(AppPath.Invite) &&
|
||||
!isMatchingLocation(AppPath.ResetPassword)
|
||||
) {
|
||||
navigate(AppPath.SignIn);
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const EMAIL_PASSWORD_RESET_Link = gql`
|
||||
mutation EmailPasswordResetLink {
|
||||
emailPasswordResetLink {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_PASSWORD_VIA_RESET_TOKEN = gql`
|
||||
mutation UpdatePasswordViaResetToken($token: String!, $newPassword: String!) {
|
||||
updatePasswordViaResetToken(
|
||||
passwordResetToken: $token
|
||||
newPassword: $newPassword
|
||||
) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const VALIDATE_PASSWORD_RESET_TOKEN = gql`
|
||||
query validatePasswordResetToken($token: String!) {
|
||||
validatePasswordResetToken(passwordResetToken: $token) {
|
||||
id
|
||||
email
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,45 @@
|
||||
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 [emailPasswordResetLink] = useEmailPasswordResetLinkMutation();
|
||||
|
||||
const handlePasswordResetClick = async () => {
|
||||
try {
|
||||
const { data } = await emailPasswordResetLink();
|
||||
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) {
|
||||
logError(error);
|
||||
enqueueSnackBar((error as Error).message, {
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<H2Title
|
||||
title="Change Password"
|
||||
description="Receive an email containing password update link"
|
||||
/>
|
||||
<Button
|
||||
onClick={handlePasswordResetClick}
|
||||
variant="secondary"
|
||||
title="Change Password"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -4,6 +4,7 @@ export enum AppPath {
|
||||
SignIn = '/sign-in',
|
||||
SignUp = '/sign-up',
|
||||
Invite = '/invite/:workspaceInviteHash',
|
||||
ResetPassword = '/reset-password/:passwordResetToken',
|
||||
|
||||
// Onboarding
|
||||
CreateWorkspace = '/create/workspace',
|
||||
|
||||
275
packages/twenty-front/src/pages/auth/PasswordReset.tsx
Normal file
275
packages/twenty-front/src/pages/auth/PasswordReset.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Logo } from '@/auth/components/Logo';
|
||||
import { Title } from '@/auth/components/Title';
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { useIsLogged } from '@/auth/hooks/useIsLogged';
|
||||
import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { MainButton } from '@/ui/input/button/components/MainButton';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
|
||||
import {
|
||||
useUpdatePasswordViaResetTokenMutation,
|
||||
useValidatePasswordResetTokenQuery,
|
||||
} from '~/generated/graphql';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
const validationSchema = z
|
||||
.object({
|
||||
passwordResetToken: z.string(),
|
||||
newPassword: z
|
||||
.string()
|
||||
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
|
||||
})
|
||||
.required();
|
||||
|
||||
type Form = z.infer<typeof validationSchema>;
|
||||
|
||||
const StyledMainContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
width: 200px;
|
||||
`;
|
||||
|
||||
const StyledForm = styled.form`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledFullWidthMotionDiv = styled(motion.div)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const StyledFooterContainer = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
text-align: center;
|
||||
max-width: 280px;
|
||||
`;
|
||||
|
||||
export const PasswordReset = () => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const passwordResetToken = useParams().passwordResetToken;
|
||||
|
||||
const isLoggedIn = useIsLogged();
|
||||
|
||||
const { control, handleSubmit } = useForm<Form>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
passwordResetToken: passwordResetToken ?? '',
|
||||
newPassword: '',
|
||||
},
|
||||
resolver: zodResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { loading: isValidatingToken } = useValidatePasswordResetTokenQuery({
|
||||
variables: {
|
||||
token: passwordResetToken ?? '',
|
||||
},
|
||||
skip: !passwordResetToken,
|
||||
onError: (error) => {
|
||||
enqueueSnackBar(error?.message ?? 'Token Invalid', {
|
||||
variant: 'error',
|
||||
});
|
||||
if (!isLoggedIn) {
|
||||
navigate(AppPath.SignIn);
|
||||
} else {
|
||||
navigate(AppPath.Index);
|
||||
}
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
if (data?.validatePasswordResetToken?.email) {
|
||||
setEmail(data.validatePasswordResetToken.email);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [updatePasswordViaToken, { loading: isUpdatingPassword }] =
|
||||
useUpdatePasswordViaResetTokenMutation();
|
||||
|
||||
const { signInWithCredentials } = useAuth();
|
||||
|
||||
const billing = useRecoilValue(billingState);
|
||||
|
||||
const onSubmit = async (formData: Form) => {
|
||||
try {
|
||||
const { data } = await updatePasswordViaToken({
|
||||
variables: {
|
||||
token: formData.passwordResetToken,
|
||||
newPassword: formData.newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data?.updatePasswordViaResetToken.success) {
|
||||
enqueueSnackBar('There was an error while updating password.', {
|
||||
variant: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
enqueueSnackBar('Password has been updated', {
|
||||
variant: 'success',
|
||||
});
|
||||
navigate(AppPath.Index);
|
||||
return;
|
||||
}
|
||||
|
||||
const { workspace: currentWorkspace } = await signInWithCredentials(
|
||||
email || '',
|
||||
formData.newPassword,
|
||||
);
|
||||
|
||||
if (
|
||||
billing?.isBillingEnabled &&
|
||||
currentWorkspace.subscriptionStatus !== 'active'
|
||||
) {
|
||||
navigate(AppPath.PlanRequired);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentWorkspace.displayName) {
|
||||
navigate(AppPath.Index);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(AppPath.CreateWorkspace);
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
enqueueSnackBar(
|
||||
(err as Error)?.message || 'An error occurred while updating password',
|
||||
{
|
||||
variant: 'error',
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledMainContainer>
|
||||
<AnimatedEaseIn>
|
||||
<Logo />
|
||||
</AnimatedEaseIn>
|
||||
<Title animate>Reset Password</Title>
|
||||
<StyledContentContainer>
|
||||
{isValidatingToken && (
|
||||
<SkeletonTheme
|
||||
baseColor={theme.background.quaternary}
|
||||
highlightColor={theme.background.secondary}
|
||||
>
|
||||
<Skeleton
|
||||
height={32}
|
||||
count={2}
|
||||
style={{
|
||||
marginBottom: theme.spacing(2),
|
||||
}}
|
||||
/>
|
||||
</SkeletonTheme>
|
||||
)}
|
||||
{email && (
|
||||
<StyledForm onSubmit={handleSubmit(onSubmit)}>
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={email}
|
||||
placeholder="Email"
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
disabled
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
</StyledFullWidthMotionDiv>
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="newPassword"
|
||||
control={control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
type="password"
|
||||
placeholder="New Password"
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
error={error?.message}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
|
||||
<MainButton
|
||||
variant="secondary"
|
||||
title="Change Password"
|
||||
type="submit"
|
||||
fullWidth
|
||||
disabled={isUpdatingPassword}
|
||||
/>
|
||||
</StyledForm>
|
||||
)}
|
||||
</StyledContentContainer>
|
||||
<StyledFooterContainer>
|
||||
By using Twenty, you agree to the Terms of Service and Data Processing
|
||||
Agreement.
|
||||
</StyledFooterContainer>
|
||||
</StyledMainContainer>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { ChangePassword } from '@/settings/profile/components/ChangePassword';
|
||||
import { DeleteAccount } from '@/settings/profile/components/DeleteAccount';
|
||||
import { EmailField } from '@/settings/profile/components/EmailField';
|
||||
import { NameFields } from '@/settings/profile/components/NameFields';
|
||||
@ -34,6 +35,9 @@ export const SettingsProfile = () => (
|
||||
/>
|
||||
<EmailField />
|
||||
</Section>
|
||||
<Section>
|
||||
<ChangePassword />
|
||||
</Section>
|
||||
<Section>
|
||||
<DeleteAccount />
|
||||
</Section>
|
||||
|
||||
Reference in New Issue
Block a user