diff --git a/packages/twenty-docs/docs/start/self-hosting/environment-variables.mdx b/packages/twenty-docs/docs/start/self-hosting/environment-variables.mdx index 1d595c93cfd4..377e5ce0302d 100644 --- a/packages/twenty-docs/docs/start/self-hosting/environment-variables.mdx +++ b/packages/twenty-docs/docs/start/self-hosting/environment-variables.mdx @@ -57,6 +57,7 @@ import TabItem from '@theme/TabItem'; ['AUTH_GOOGLE_CALLBACK_URL', '', 'Google auth callback'], ['FRONT_AUTH_CALLBACK_URL', 'http://localhost:3001/verify ', 'Callback used for Login page'], ['IS_SIGN_UP_DISABLED', 'false', 'Disable sign-up'], + ['PASSWORD_RESET_TOKEN_EXPIRES_IN', '5m', 'Password reset token expiration time'], ]}> ### Email diff --git a/packages/twenty-emails/src/common-style.ts b/packages/twenty-emails/src/common-style.ts index 6a80ebeddb0d..bd550b544824 100644 --- a/packages/twenty-emails/src/common-style.ts +++ b/packages/twenty-emails/src/common-style.ts @@ -25,6 +25,7 @@ export const emailTheme = { colors: { highlighted: grayScale.gray60, primary: grayScale.gray50, + tertiary: grayScale.gray40, inverted: grayScale.gray0, }, weight: { diff --git a/packages/twenty-emails/src/components/Link.tsx b/packages/twenty-emails/src/components/Link.tsx new file mode 100644 index 000000000000..5ebcc52f9781 --- /dev/null +++ b/packages/twenty-emails/src/components/Link.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Link as EmailLink } from '@react-email/components'; +import { emailTheme } from 'src/common-style'; + +const linkStyle = { + color: emailTheme.font.colors.tertiary, + textDecoration: 'underline', +}; + +export const Link = ({ value, href }) => { + return ( + + {value} + + ); +}; diff --git a/packages/twenty-emails/src/emails/password-reset-link.email.tsx b/packages/twenty-emails/src/emails/password-reset-link.email.tsx new file mode 100644 index 000000000000..a8355b0857d1 --- /dev/null +++ b/packages/twenty-emails/src/emails/password-reset-link.email.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { BaseEmail } from 'src/components/BaseEmail'; +import { CallToAction } from 'src/components/CallToAction'; +import { Link } from 'src/components/Link'; +import { MainText } from 'src/components/MainText'; +import { Title } from 'src/components/Title'; + +type PasswordResetLinkEmailProps = { + duration: string; + link: string; +}; + +export const PasswordResetLinkEmail = ({ + duration, + link, +}: PasswordResetLinkEmailProps) => { + return ( + + + <CallToAction href={link} value="Reset" /> + <MainText> + This link is only valid for the next {duration}. If link does not work, + you can use the login verification link directly: + <br /> + <Link href={link} value={link} /> + </MainText> + </BaseEmail> + ); +}; diff --git a/packages/twenty-emails/src/emails/password-update-notify.email.tsx b/packages/twenty-emails/src/emails/password-update-notify.email.tsx new file mode 100644 index 000000000000..6eaf77b99bad --- /dev/null +++ b/packages/twenty-emails/src/emails/password-update-notify.email.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { format } from 'date-fns'; +import { BaseEmail } from 'src/components/BaseEmail'; +import { CallToAction } from 'src/components/CallToAction'; +import { MainText } from 'src/components/MainText'; +import { Title } from 'src/components/Title'; + +type PasswordUpdateNotifyEmailProps = { + userName: string; + email: string; + link: string; +}; + +export const PasswordUpdateNotifyEmail = ({ + userName, + email, + link, +}: PasswordUpdateNotifyEmailProps) => { + const helloString = userName?.length > 1 ? `Dear ${userName}` : 'Dear'; + return ( + <BaseEmail> + <Title value="Password updated" /> + <MainText> + {helloString}, + <br /> + <br /> + This is a confirmation that password for your account ({email}) was + successfully changed on {format(new Date(), 'MMMM d, yyyy')}. + <br /> + <br /> + If you did not initiate this change, please contact your workspace owner + immediately. + <br /> + </MainText> + <CallToAction value="Connect to Twenty" href={link} /> + </BaseEmail> + ); +}; diff --git a/packages/twenty-emails/tsup.index.tsx b/packages/twenty-emails/tsup.index.tsx index e816bb7363b1..ca2deeeaaab3 100644 --- a/packages/twenty-emails/tsup.index.tsx +++ b/packages/twenty-emails/tsup.index.tsx @@ -1,2 +1,4 @@ export * from './src/emails/clean-inactive-workspaces.email'; export * from './src/emails/delete-inactive-workspaces.email'; +export * from './src/emails/password-reset-link.email'; +export * from './src/emails/password-update-notify.email'; diff --git a/packages/twenty-front/src/App.tsx b/packages/twenty-front/src/App.tsx index 0d9506317b5b..8f17069e20d6 100644 --- a/packages/twenty-front/src/App.tsx +++ b/packages/twenty-front/src/App.tsx @@ -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 />} /> diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx index 7b0ee2434e2e..9a165f349f77 100644 --- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx @@ -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 ( diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index c647e4a6449e..d126a0f9ef61 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -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; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 9d21cdb89973..6d49e69e5be6 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -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 { diff --git a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts index 1aedc914fc86..cd2b62704f62 100644 --- a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts +++ b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts @@ -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); } diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/emailPasswordResetLink.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/emailPasswordResetLink.ts new file mode 100644 index 000000000000..7b9ebac99e56 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/emailPasswordResetLink.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const EMAIL_PASSWORD_RESET_Link = gql` + mutation EmailPasswordResetLink { + emailPasswordResetLink { + success + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/updatePasswordViaResetToken.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/updatePasswordViaResetToken.ts new file mode 100644 index 000000000000..2ae5ab168d51 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/updatePasswordViaResetToken.ts @@ -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 + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/validatePasswordResetToken.ts b/packages/twenty-front/src/modules/auth/graphql/queries/validatePasswordResetToken.ts new file mode 100644 index 000000000000..b6631e4bf755 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/queries/validatePasswordResetToken.ts @@ -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 + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/profile/components/ChangePassword.tsx b/packages/twenty-front/src/modules/settings/profile/components/ChangePassword.tsx new file mode 100644 index 000000000000..51743ac07e6f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/components/ChangePassword.tsx @@ -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" + /> + </> + ); +}; diff --git a/packages/twenty-front/src/modules/types/AppPath.ts b/packages/twenty-front/src/modules/types/AppPath.ts index b01080c59a36..e35563a1ef86 100644 --- a/packages/twenty-front/src/modules/types/AppPath.ts +++ b/packages/twenty-front/src/modules/types/AppPath.ts @@ -4,6 +4,7 @@ export enum AppPath { SignIn = '/sign-in', SignUp = '/sign-up', Invite = '/invite/:workspaceInviteHash', + ResetPassword = '/reset-password/:passwordResetToken', // Onboarding CreateWorkspace = '/create/workspace', diff --git a/packages/twenty-front/src/pages/auth/PasswordReset.tsx b/packages/twenty-front/src/pages/auth/PasswordReset.tsx new file mode 100644 index 000000000000..72d8816950f7 --- /dev/null +++ b/packages/twenty-front/src/pages/auth/PasswordReset.tsx @@ -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 + + {isValidatingToken && ( + + + + )} + {email && ( + + + + + + + + ( + + + + )} + /> + + + + + )} + + + By using Twenty, you agree to the Terms of Service and Data Processing + Agreement. + + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/SettingsProfile.tsx b/packages/twenty-front/src/pages/settings/SettingsProfile.tsx index f97aba94e381..bebda10dc967 100644 --- a/packages/twenty-front/src/pages/settings/SettingsProfile.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsProfile.tsx @@ -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 = () => ( /> +
+ +
diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index d0e72ada77bd..2e196cccc9a3 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -51,3 +51,4 @@ SIGN_IN_PREFILLED=true # EMAIL_SMTP_PORT= # EMAIL_SMTP_USER= # EMAIL_SMTP_PASSWORD= +# PASSWORD_RESET_TOKEN_EXPIRES_IN=5m diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index 3bd481fd9afe..a352206bc8d0 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -16,11 +16,11 @@ "start:debug": "yarn build-twenty-emails && nest start --debug --watch", "start:prod": "node dist/src/main", "lint": "eslint \"src/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register ../../node_modules/.bin/jest --runInBand", - "test:e2e": "./scripts/run-integration.sh", + "test": "yarn build-twenty-emails && jest", + "test:watch": "yarn build-twenty-emails && jest --watch", + "test:cov": "yarn build-twenty-emails && jest --coverage", + "test:debug": "yarn build-twenty-emails && node --inspect-brk -r tsconfig-paths/register -r ts-node/register ../../node_modules/.bin/jest --runInBand", + "test:e2e": "yarn build-twenty-emails && ./scripts/run-integration.sh", "typeorm": "npx ts-node -r tsconfig-paths/register ../../node_modules/typeorm/cli.js", "typeorm:migrate": "yarn typeorm migration:run -d ./src/database/typeorm/metadata/metadata.datasource.ts && yarn typeorm migration:run -d ./src/database/typeorm/core/core.datasource.ts", "database:init": "yarn database:setup && yarn database:seed:dev", diff --git a/packages/twenty-server/src/core/auth/auth.resolver.ts b/packages/twenty-server/src/core/auth/auth.resolver.ts index 7a498abbfa46..55bad8033a8c 100644 --- a/packages/twenty-server/src/core/auth/auth.resolver.ts +++ b/packages/twenty-server/src/core/auth/auth.resolver.ts @@ -2,6 +2,8 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { BadRequestException, ForbiddenException, + InternalServerErrorException, + NotFoundException, UseGuards, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; @@ -15,8 +17,13 @@ import { Workspace } from 'src/core/workspace/workspace.entity'; import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; import { User } from 'src/core/user/user.entity'; import { ApiKeyTokenInput } from 'src/core/auth/dto/api-key-token.input'; +import { ValidatePasswordResetToken } from 'src/core/auth/dto/validate-password-reset-token.entity'; import { TransientToken } from 'src/core/auth/dto/transient-token.entity'; import { UserService } from 'src/core/user/services/user.service'; +import { ValidatePasswordResetTokenInput } from 'src/core/auth/dto/validate-password-reset-token.input'; +import { UpdatePasswordViaResetTokenInput } from 'src/core/auth/dto/update-password-via-reset-token.input'; +import { EmailPasswordResetLink } from 'src/core/auth/dto/email-password-reset-link.entity'; +import { InvalidatePassword } from 'src/core/auth/dto/invalidate-password.entity'; import { ApiKeyToken, AuthTokens } from './dto/token.entity'; import { TokenService } from './services/token.service'; @@ -150,4 +157,47 @@ export class AuthResolver { args.expiresAt, ); } + + @UseGuards(JwtAuthGuard) + @Mutation(() => EmailPasswordResetLink) + async emailPasswordResetLink( + @AuthUser() { email }: User, + ): Promise { + const resetToken = + await this.tokenService.generatePasswordResetToken(email); + + return await this.tokenService.sendEmailPasswordResetLink( + resetToken, + email, + ); + } + + @Mutation(() => InvalidatePassword) + async updatePasswordViaResetToken( + @Args() args: UpdatePasswordViaResetTokenInput, + ): Promise { + const { id } = await this.tokenService.validatePasswordResetToken( + args.passwordResetToken, + ); + + assert(id, 'User not found', NotFoundException); + + const { success } = await this.authService.updatePassword( + id, + args.newPassword, + ); + + assert(success, 'Password update failed', InternalServerErrorException); + + return await this.tokenService.invalidatePasswordResetToken(id); + } + + @Query(() => ValidatePasswordResetToken) + async validatePasswordResetToken( + @Args() args: ValidatePasswordResetTokenInput, + ): Promise { + return this.tokenService.validatePasswordResetToken( + args.passwordResetToken, + ); + } } diff --git a/packages/twenty-server/src/core/auth/dto/email-password-reset-link.entity.ts b/packages/twenty-server/src/core/auth/dto/email-password-reset-link.entity.ts new file mode 100644 index 000000000000..b42baaa985db --- /dev/null +++ b/packages/twenty-server/src/core/auth/dto/email-password-reset-link.entity.ts @@ -0,0 +1,9 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class EmailPasswordResetLink { + @Field(() => Boolean, { + description: 'Boolean that confirms query was dispatched', + }) + success: boolean; +} diff --git a/packages/twenty-server/src/core/auth/dto/invalidate-password.entity.ts b/packages/twenty-server/src/core/auth/dto/invalidate-password.entity.ts new file mode 100644 index 000000000000..83d27bf0a642 --- /dev/null +++ b/packages/twenty-server/src/core/auth/dto/invalidate-password.entity.ts @@ -0,0 +1,9 @@ +import { ObjectType, Field } from '@nestjs/graphql'; + +@ObjectType() +export class InvalidatePassword { + @Field(() => Boolean, { + description: 'Boolean that confirms query was dispatched', + }) + success: boolean; +} diff --git a/packages/twenty-server/src/core/auth/dto/password-reset-token.input.ts b/packages/twenty-server/src/core/auth/dto/password-reset-token.input.ts new file mode 100644 index 000000000000..5120af2942c6 --- /dev/null +++ b/packages/twenty-server/src/core/auth/dto/password-reset-token.input.ts @@ -0,0 +1,11 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsEmail, IsNotEmpty } from 'class-validator'; + +@ArgsType() +export class PasswordResetTokenInput { + @Field(() => String) + @IsNotEmpty() + @IsEmail() + email: string; +} diff --git a/packages/twenty-server/src/core/auth/dto/token.entity.ts b/packages/twenty-server/src/core/auth/dto/token.entity.ts index 391501041726..3f6872dc4d0b 100644 --- a/packages/twenty-server/src/core/auth/dto/token.entity.ts +++ b/packages/twenty-server/src/core/auth/dto/token.entity.ts @@ -29,3 +29,12 @@ export class AuthTokens { @Field(() => AuthTokenPair) tokens: AuthTokenPair; } + +@ObjectType() +export class PasswordResetToken { + @Field(() => String) + passwordResetToken: string; + + @Field(() => Date) + passwordResetTokenExpiresAt: Date; +} diff --git a/packages/twenty-server/src/core/auth/dto/update-password-via-reset-token.input.ts b/packages/twenty-server/src/core/auth/dto/update-password-via-reset-token.input.ts new file mode 100644 index 000000000000..805d7d2699cf --- /dev/null +++ b/packages/twenty-server/src/core/auth/dto/update-password-via-reset-token.input.ts @@ -0,0 +1,16 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@ArgsType() +export class UpdatePasswordViaResetTokenInput { + @Field(() => String) + @IsNotEmpty() + @IsString() + passwordResetToken: string; + + @Field(() => String) + @IsNotEmpty() + @IsString() + newPassword: string; +} diff --git a/packages/twenty-server/src/core/auth/dto/update-password.entity.ts b/packages/twenty-server/src/core/auth/dto/update-password.entity.ts new file mode 100644 index 000000000000..21c1b00ae0a5 --- /dev/null +++ b/packages/twenty-server/src/core/auth/dto/update-password.entity.ts @@ -0,0 +1,9 @@ +import { ObjectType, Field } from '@nestjs/graphql'; + +@ObjectType() +export class UpdatePassword { + @Field(() => Boolean, { + description: 'Boolean that confirms query was dispatched', + }) + success: boolean; +} diff --git a/packages/twenty-server/src/core/auth/dto/validate-password-reset-token.entity.ts b/packages/twenty-server/src/core/auth/dto/validate-password-reset-token.entity.ts new file mode 100644 index 000000000000..52ebcad7be4f --- /dev/null +++ b/packages/twenty-server/src/core/auth/dto/validate-password-reset-token.entity.ts @@ -0,0 +1,10 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class ValidatePasswordResetToken { + @Field(() => String) + id: string; + + @Field(() => String) + email: string; +} diff --git a/packages/twenty-server/src/core/auth/dto/validate-password-reset-token.input.ts b/packages/twenty-server/src/core/auth/dto/validate-password-reset-token.input.ts new file mode 100644 index 000000000000..222e25fbc73b --- /dev/null +++ b/packages/twenty-server/src/core/auth/dto/validate-password-reset-token.input.ts @@ -0,0 +1,11 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@ArgsType() +export class ValidatePasswordResetTokenInput { + @Field(() => String) + @IsNotEmpty() + @IsString() + passwordResetToken: string; +} diff --git a/packages/twenty-server/src/core/auth/services/auth.service.spec.ts b/packages/twenty-server/src/core/auth/services/auth.service.spec.ts index eba5c39cb28a..5776705a0277 100644 --- a/packages/twenty-server/src/core/auth/services/auth.service.spec.ts +++ b/packages/twenty-server/src/core/auth/services/auth.service.spec.ts @@ -8,6 +8,7 @@ import { FileUploadService } from 'src/core/file/services/file-upload.service'; import { Workspace } from 'src/core/workspace/workspace.entity'; import { User } from 'src/core/user/user.entity'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { EmailService } from 'src/integrations/email/email.service'; import { AuthService } from './auth.service'; import { TokenService } from './token.service'; @@ -51,6 +52,10 @@ describe('AuthService', () => { provide: EnvironmentService, useValue: {}, }, + { + provide: EmailService, + useValue: {}, + }, ], }).compile(); diff --git a/packages/twenty-server/src/core/auth/services/auth.service.ts b/packages/twenty-server/src/core/auth/services/auth.service.ts index 1c1bf80ee5c3..7d3940219cdd 100644 --- a/packages/twenty-server/src/core/auth/services/auth.service.ts +++ b/packages/twenty-server/src/core/auth/services/auth.service.ts @@ -10,6 +10,8 @@ import { HttpService } from '@nestjs/axios'; import FileType from 'file-type'; import { Repository } from 'typeorm'; import { v4 } from 'uuid'; +import { render } from '@react-email/components'; +import { PasswordUpdateNotifyEmail } from 'twenty-emails'; import { FileFolder } from 'src/core/file/interfaces/file-folder.interface'; @@ -30,6 +32,8 @@ import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspa import { getImageBufferFromUrl } from 'src/utils/image'; import { FileUploadService } from 'src/core/file/services/file-upload.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { EmailService } from 'src/integrations/email/email.service'; +import { UpdatePassword } from 'src/core/auth/dto/update-password.entity'; import { TokenService } from './token.service'; @@ -52,6 +56,7 @@ export class AuthService { private readonly userRepository: Repository, private readonly httpService: HttpService, private readonly environmentService: EnvironmentService, + private readonly emailService: EmailService, ) {} async challenge(challengeInput: ChallengeInput) { @@ -241,4 +246,50 @@ export class AuthService { }, }; } + + async updatePassword( + userId: string, + newPassword: string, + ): Promise { + const user = await this.userRepository.findOneBy({ id: userId }); + + assert(user, 'User not found', NotFoundException); + + const isPasswordValid = PASSWORD_REGEX.test(newPassword); + + assert(isPasswordValid, 'Password too weak', BadRequestException); + + const isPasswordSame = await compareHash(newPassword, user.passwordHash); + + assert(!isPasswordSame, 'Password cannot be repeated', BadRequestException); + + const newPasswordHash = await hashPassword(newPassword); + + await this.userRepository.update(userId, { + passwordHash: newPasswordHash, + }); + + const emailTemplate = PasswordUpdateNotifyEmail({ + userName: `${user.firstName} ${user.lastName}`, + email: user.email, + link: this.environmentService.getFrontBaseUrl(), + }); + + const html = render(emailTemplate, { + pretty: true, + }); + const text = render(emailTemplate, { + plainText: true, + }); + + this.emailService.send({ + from: `${this.environmentService.getEmailFromName()} <${this.environmentService.getEmailFromAddress()}>`, + to: user.email, + subject: 'Your Password Has Been Successfully Changed', + text, + html, + }); + + return { success: true }; + } } diff --git a/packages/twenty-server/src/core/auth/services/token.service.spec.ts b/packages/twenty-server/src/core/auth/services/token.service.spec.ts index 25bcb1a6113e..709799bcfe32 100644 --- a/packages/twenty-server/src/core/auth/services/token.service.spec.ts +++ b/packages/twenty-server/src/core/auth/services/token.service.spec.ts @@ -6,6 +6,7 @@ import { EnvironmentService } from 'src/integrations/environment/environment.ser import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity'; import { User } from 'src/core/user/user.entity'; import { JwtAuthStrategy } from 'src/core/auth/strategies/jwt.auth.strategy'; +import { EmailService } from 'src/integrations/email/email.service'; import { TokenService } from './token.service'; @@ -28,6 +29,10 @@ describe('TokenService', () => { provide: EnvironmentService, useValue: {}, }, + { + provide: EmailService, + useValue: {}, + }, { provide: getRepositoryToken(User, 'core'), useValue: {}, diff --git a/packages/twenty-server/src/core/auth/services/token.service.ts b/packages/twenty-server/src/core/auth/services/token.service.ts index a95c8f9efc16..8fbc41bfb0e5 100644 --- a/packages/twenty-server/src/core/auth/services/token.service.ts +++ b/packages/twenty-server/src/core/auth/services/token.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ForbiddenException, Injectable, InternalServerErrorException, @@ -9,23 +10,35 @@ import { import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; -import { addMilliseconds } from 'date-fns'; +import crypto from 'crypto'; + +import { addMilliseconds, differenceInMilliseconds, isFuture } from 'date-fns'; import ms from 'ms'; import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; import { Repository } from 'typeorm'; import { Request } from 'express'; import { ExtractJwt } from 'passport-jwt'; +import { render } from '@react-email/render'; +import { PasswordResetLinkEmail } from 'twenty-emails'; import { JwtAuthStrategy, JwtPayload, } from 'src/core/auth/strategies/jwt.auth.strategy'; import { assert } from 'src/utils/assert'; -import { ApiKeyToken, AuthToken } from 'src/core/auth/dto/token.entity'; +import { + ApiKeyToken, + AuthToken, + PasswordResetToken, +} from 'src/core/auth/dto/token.entity'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; import { User } from 'src/core/user/user.entity'; import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity'; import { Workspace } from 'src/core/workspace/workspace.entity'; +import { ValidatePasswordResetToken } from 'src/core/auth/dto/validate-password-reset-token.entity'; +import { EmailService } from 'src/integrations/email/email.service'; +import { InvalidatePassword } from 'src/core/auth/dto/invalidate-password.entity'; +import { EmailPasswordResetLink } from 'src/core/auth/dto/email-password-reset-link.entity'; @Injectable() export class TokenService { @@ -37,6 +50,7 @@ export class TokenService { private readonly userRepository: Repository, @InjectRepository(RefreshToken, 'core') private readonly refreshTokenRepository: Repository, + private readonly emailService: EmailService, ) {} async generateAccessToken(userId: string): Promise { @@ -312,4 +326,149 @@ export class TokenService { } } } + + async generatePasswordResetToken(email: string): Promise { + const user = await this.userRepository.findOneBy({ + email, + }); + + assert(user, 'User not found', NotFoundException); + + const expiresIn = this.environmentService.getPasswordResetTokenExpiresIn(); + + assert( + expiresIn, + 'PASSWORD_RESET_TOKEN_EXPIRES_IN constant value not found', + InternalServerErrorException, + ); + + if ( + user.passwordResetToken && + user.passwordResetTokenExpiresAt && + isFuture(user.passwordResetTokenExpiresAt) + ) { + assert( + false, + `Token has been already generated. Please wait for ${ms( + differenceInMilliseconds( + user.passwordResetTokenExpiresAt, + new Date(), + ), + { + long: true, + }, + )} to generate again.`, + BadRequestException, + ); + } + + const plainResetToken = crypto.randomBytes(32).toString('hex'); + const hashedResetToken = crypto + .createHash('sha256') + .update(plainResetToken) + .digest('hex'); + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + await this.userRepository.update(user.id, { + passwordResetToken: hashedResetToken, + passwordResetTokenExpiresAt: expiresAt, + }); + + return { + passwordResetToken: plainResetToken, + passwordResetTokenExpiresAt: expiresAt, + }; + } + + async sendEmailPasswordResetLink( + resetToken: PasswordResetToken, + email: string, + ): Promise { + const user = await this.userRepository.findOneBy({ + email, + }); + + assert(user, 'User not found', NotFoundException); + + const frontBaseURL = this.environmentService.getFrontBaseUrl(); + const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`; + + const emailData = { + link: resetLink, + duration: ms( + differenceInMilliseconds( + resetToken.passwordResetTokenExpiresAt, + new Date(), + ), + { + long: true, + }, + ), + }; + + const emailTemplate = PasswordResetLinkEmail(emailData); + const html = render(emailTemplate, { + pretty: true, + }); + + const text = render(emailTemplate, { + plainText: true, + }); + + this.emailService.send({ + from: `${this.environmentService.getEmailFromName()} <${this.environmentService.getEmailFromAddress()}>`, + to: email, + subject: 'Action Needed to Reset Password', + text, + html, + }); + + return { success: true }; + } + + async validatePasswordResetToken( + resetToken: string, + ): Promise { + const hashedResetToken = crypto + .createHash('sha256') + .update(resetToken) + .digest('hex'); + + const user = await this.userRepository.findOneBy({ + passwordResetToken: hashedResetToken, + }); + + assert(user, 'Token is invalid', NotFoundException); + + const tokenExpiresAt = user.passwordResetTokenExpiresAt; + + assert( + tokenExpiresAt && isFuture(tokenExpiresAt), + 'Token has expired. Please regenerate', + NotFoundException, + ); + + return { + id: user.id, + email: user.email, + }; + } + + async invalidatePasswordResetToken( + userId: string, + ): Promise { + const user = await this.userRepository.findOneBy({ + id: userId, + }); + + assert(user, 'User not found', NotFoundException); + + await this.userRepository.update(user.id, { + passwordResetToken: '', + passwordResetTokenExpiresAt: undefined, + }); + + return { success: true }; + } } diff --git a/packages/twenty-server/src/core/user/user.entity.ts b/packages/twenty-server/src/core/user/user.entity.ts index 808df5de970c..bc6980bcd5e7 100644 --- a/packages/twenty-server/src/core/user/user.entity.ts +++ b/packages/twenty-server/src/core/user/user.entity.ts @@ -68,6 +68,14 @@ export class User { }) defaultWorkspace: Workspace; + @Field({ nullable: true }) + @Column({ nullable: true }) + passwordResetToken: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + passwordResetTokenExpiresAt: Date; + @OneToMany(() => RefreshToken, (refreshToken) => refreshToken.user, { cascade: true, }) diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1704825571702-addPasswordResetToken.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1704825571702-addPasswordResetToken.ts new file mode 100644 index 000000000000..b4a3c4cdfdd4 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/1704825571702-addPasswordResetToken.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPasswordResetToken1704825571702 implements MigrationInterface { + name = 'AddPasswordResetToken1704825571702'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."user" ADD "passwordResetToken" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "core"."user" ADD "passwordResetTokenExpiresAt" TIMESTAMP`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."user" DROP COLUMN "passwordResetTokenExpiresAt"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."user" DROP COLUMN "passwordResetToken"`, + ); + } +} diff --git a/packages/twenty-server/src/integrations/environment/environment.service.ts b/packages/twenty-server/src/integrations/environment/environment.service.ts index 1a7209075456..3840df1da12a 100644 --- a/packages/twenty-server/src/integrations/environment/environment.service.ts +++ b/packages/twenty-server/src/integrations/environment/environment.service.ts @@ -266,6 +266,12 @@ export class EnvironmentService { return this.configService.get('OPENROUTER_API_KEY'); } + getPasswordResetTokenExpiresIn(): string { + return ( + this.configService.get('PASSWORD_RESET_TOKEN_EXPIRES_IN') ?? '5m' + ); + } + getInactiveDaysBeforeEmail(): number | undefined { return this.configService.get( 'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION', diff --git a/packages/twenty-server/src/integrations/environment/environment.validation.ts b/packages/twenty-server/src/integrations/environment/environment.validation.ts index 837a2975f2e6..c712632f2a25 100644 --- a/packages/twenty-server/src/integrations/environment/environment.validation.ts +++ b/packages/twenty-server/src/integrations/environment/environment.validation.ts @@ -172,6 +172,10 @@ export class EnvironmentVariables { @IsString() SENTRY_DSN?: string; + @IsDuration() + @IsOptional() + PASSWORD_RESET_TOKEN_EXPIRES_IN?: number; + @CastToPositiveNumber() @IsNumber() @ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0)