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:
@ -57,6 +57,7 @@ import TabItem from '@theme/TabItem';
|
|||||||
['AUTH_GOOGLE_CALLBACK_URL', '', 'Google auth callback'],
|
['AUTH_GOOGLE_CALLBACK_URL', '', 'Google auth callback'],
|
||||||
['FRONT_AUTH_CALLBACK_URL', 'http://localhost:3001/verify ', 'Callback used for Login page'],
|
['FRONT_AUTH_CALLBACK_URL', 'http://localhost:3001/verify ', 'Callback used for Login page'],
|
||||||
['IS_SIGN_UP_DISABLED', 'false', 'Disable sign-up'],
|
['IS_SIGN_UP_DISABLED', 'false', 'Disable sign-up'],
|
||||||
|
['PASSWORD_RESET_TOKEN_EXPIRES_IN', '5m', 'Password reset token expiration time'],
|
||||||
]}></OptionTable>
|
]}></OptionTable>
|
||||||
|
|
||||||
### Email
|
### Email
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export const emailTheme = {
|
|||||||
colors: {
|
colors: {
|
||||||
highlighted: grayScale.gray60,
|
highlighted: grayScale.gray60,
|
||||||
primary: grayScale.gray50,
|
primary: grayScale.gray50,
|
||||||
|
tertiary: grayScale.gray40,
|
||||||
inverted: grayScale.gray0,
|
inverted: grayScale.gray0,
|
||||||
},
|
},
|
||||||
weight: {
|
weight: {
|
||||||
|
|||||||
16
packages/twenty-emails/src/components/Link.tsx
Normal file
16
packages/twenty-emails/src/components/Link.tsx
Normal file
@ -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 (
|
||||||
|
<EmailLink href={href} style={linkStyle}>
|
||||||
|
{value}
|
||||||
|
</EmailLink>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 (
|
||||||
|
<BaseEmail>
|
||||||
|
<Title value="Reset your password 🗝" />
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,2 +1,4 @@
|
|||||||
export * from './src/emails/clean-inactive-workspaces.email';
|
export * from './src/emails/clean-inactive-workspaces.email';
|
||||||
export * from './src/emails/delete-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';
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
|
|||||||
import { useDefaultHomePagePath } from '~/hooks/useDefaultHomePagePath';
|
import { useDefaultHomePagePath } from '~/hooks/useDefaultHomePagePath';
|
||||||
import { CreateProfile } from '~/pages/auth/CreateProfile';
|
import { CreateProfile } from '~/pages/auth/CreateProfile';
|
||||||
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
|
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
|
||||||
|
import { PasswordReset } from '~/pages/auth/PasswordReset';
|
||||||
import { PlanRequired } from '~/pages/auth/PlanRequired';
|
import { PlanRequired } from '~/pages/auth/PlanRequired';
|
||||||
import { SignInUp } from '~/pages/auth/SignInUp';
|
import { SignInUp } from '~/pages/auth/SignInUp';
|
||||||
import { VerifyEffect } from '~/pages/auth/VerifyEffect';
|
import { VerifyEffect } from '~/pages/auth/VerifyEffect';
|
||||||
@ -59,6 +60,7 @@ export const App = () => {
|
|||||||
<Route path={AppPath.SignIn} element={<SignInUp />} />
|
<Route path={AppPath.SignIn} element={<SignInUp />} />
|
||||||
<Route path={AppPath.SignUp} element={<SignInUp />} />
|
<Route path={AppPath.SignUp} element={<SignInUp />} />
|
||||||
<Route path={AppPath.Invite} element={<SignInUp />} />
|
<Route path={AppPath.Invite} element={<SignInUp />} />
|
||||||
|
<Route path={AppPath.ResetPassword} element={<PasswordReset />} />
|
||||||
<Route path={AppPath.CreateWorkspace} element={<CreateWorkspace />} />
|
<Route path={AppPath.CreateWorkspace} element={<CreateWorkspace />} />
|
||||||
<Route path={AppPath.CreateProfile} element={<CreateProfile />} />
|
<Route path={AppPath.CreateProfile} element={<CreateProfile />} />
|
||||||
<Route path={AppPath.PlanRequired} element={<PlanRequired />} />
|
<Route path={AppPath.PlanRequired} element={<PlanRequired />} />
|
||||||
|
|||||||
@ -54,14 +54,14 @@ export const PageChangeEffect = () => {
|
|||||||
}, [location, previousLocation]);
|
}, [location, previousLocation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isMachinOngoingUserCreationRoute =
|
const isMatchingOngoingUserCreationRoute =
|
||||||
isMatchingLocation(AppPath.SignUp) ||
|
isMatchingLocation(AppPath.SignUp) ||
|
||||||
isMatchingLocation(AppPath.SignIn) ||
|
isMatchingLocation(AppPath.SignIn) ||
|
||||||
isMatchingLocation(AppPath.Invite) ||
|
isMatchingLocation(AppPath.Invite) ||
|
||||||
isMatchingLocation(AppPath.Verify);
|
isMatchingLocation(AppPath.Verify);
|
||||||
|
|
||||||
const isMatchingOnboardingRoute =
|
const isMatchingOnboardingRoute =
|
||||||
isMachinOngoingUserCreationRoute ||
|
isMatchingOngoingUserCreationRoute ||
|
||||||
isMatchingLocation(AppPath.CreateWorkspace) ||
|
isMatchingLocation(AppPath.CreateWorkspace) ||
|
||||||
isMatchingLocation(AppPath.CreateProfile) ||
|
isMatchingLocation(AppPath.CreateProfile) ||
|
||||||
isMatchingLocation(AppPath.PlanRequired);
|
isMatchingLocation(AppPath.PlanRequired);
|
||||||
@ -75,7 +75,8 @@ export const PageChangeEffect = () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
onboardingStatus === OnboardingStatus.OngoingUserCreation &&
|
onboardingStatus === OnboardingStatus.OngoingUserCreation &&
|
||||||
!isMachinOngoingUserCreationRoute
|
!isMatchingOngoingUserCreationRoute &&
|
||||||
|
!isMatchingLocation(AppPath.ResetPassword)
|
||||||
) {
|
) {
|
||||||
navigate(AppPath.SignIn);
|
navigate(AppPath.SignIn);
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@ -495,6 +495,8 @@ export type User = {
|
|||||||
id: Scalars['ID']['output'];
|
id: Scalars['ID']['output'];
|
||||||
lastName: Scalars['String']['output'];
|
lastName: Scalars['String']['output'];
|
||||||
passwordHash?: Maybe<Scalars['String']['output']>;
|
passwordHash?: Maybe<Scalars['String']['output']>;
|
||||||
|
passwordResetToken?: Maybe<Scalars['String']['output']>;
|
||||||
|
passwordResetTokenExpiresAt?: Maybe<Scalars['DateTime']['output']>;
|
||||||
supportUserHash?: Maybe<Scalars['String']['output']>;
|
supportUserHash?: Maybe<Scalars['String']['output']>;
|
||||||
updatedAt: Scalars['DateTime']['output'];
|
updatedAt: Scalars['DateTime']['output'];
|
||||||
workspaceMember: WorkspaceMember;
|
workspaceMember: WorkspaceMember;
|
||||||
|
|||||||
@ -97,6 +97,12 @@ export type CursorPaging = {
|
|||||||
last?: InputMaybe<Scalars['Int']>;
|
last?: InputMaybe<Scalars['Int']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EmailPasswordResetLink = {
|
||||||
|
__typename?: 'EmailPasswordResetLink';
|
||||||
|
/** Boolean that confirms query was dispatched */
|
||||||
|
success: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
export type FeatureFlag = {
|
export type FeatureFlag = {
|
||||||
__typename?: 'FeatureFlag';
|
__typename?: 'FeatureFlag';
|
||||||
id: Scalars['ID'];
|
id: Scalars['ID'];
|
||||||
@ -199,6 +205,12 @@ export type IdFilterComparison = {
|
|||||||
notLike?: InputMaybe<Scalars['ID']>;
|
notLike?: InputMaybe<Scalars['ID']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InvalidatePassword = {
|
||||||
|
__typename?: 'InvalidatePassword';
|
||||||
|
/** Boolean that confirms query was dispatched */
|
||||||
|
success: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
export type LoginToken = {
|
export type LoginToken = {
|
||||||
__typename?: 'LoginToken';
|
__typename?: 'LoginToken';
|
||||||
loginToken: AuthToken;
|
loginToken: AuthToken;
|
||||||
@ -213,12 +225,14 @@ export type Mutation = {
|
|||||||
deleteCurrentWorkspace: Workspace;
|
deleteCurrentWorkspace: Workspace;
|
||||||
deleteOneObject: ObjectDeleteResponse;
|
deleteOneObject: ObjectDeleteResponse;
|
||||||
deleteUser: User;
|
deleteUser: User;
|
||||||
|
emailPasswordResetLink: EmailPasswordResetLink;
|
||||||
generateApiKeyToken: ApiKeyToken;
|
generateApiKeyToken: ApiKeyToken;
|
||||||
generateTransientToken: TransientToken;
|
generateTransientToken: TransientToken;
|
||||||
impersonate: Verify;
|
impersonate: Verify;
|
||||||
renewToken: AuthTokens;
|
renewToken: AuthTokens;
|
||||||
signUp: LoginToken;
|
signUp: LoginToken;
|
||||||
updateOneObject: Object;
|
updateOneObject: Object;
|
||||||
|
updatePasswordViaResetToken: InvalidatePassword;
|
||||||
updateWorkspace: Workspace;
|
updateWorkspace: Workspace;
|
||||||
uploadFile: Scalars['String'];
|
uploadFile: Scalars['String'];
|
||||||
uploadImage: Scalars['String'];
|
uploadImage: Scalars['String'];
|
||||||
@ -268,6 +282,12 @@ export type MutationSignUpArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationUpdatePasswordViaResetTokenArgs = {
|
||||||
|
newPassword: Scalars['String'];
|
||||||
|
passwordResetToken: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationUpdateWorkspaceArgs = {
|
export type MutationUpdateWorkspaceArgs = {
|
||||||
data: UpdateWorkspaceInput;
|
data: UpdateWorkspaceInput;
|
||||||
};
|
};
|
||||||
@ -362,6 +382,7 @@ export type Query = {
|
|||||||
getTimelineThreadsFromPersonId: Array<TimelineThread>;
|
getTimelineThreadsFromPersonId: Array<TimelineThread>;
|
||||||
object: Object;
|
object: Object;
|
||||||
objects: ObjectConnection;
|
objects: ObjectConnection;
|
||||||
|
validatePasswordResetToken: ValidatePasswordResetToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -389,6 +410,11 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = {
|
|||||||
personId: Scalars['String'];
|
personId: Scalars['String'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type QueryValidatePasswordResetTokenArgs = {
|
||||||
|
passwordResetToken: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
export type RefreshToken = {
|
export type RefreshToken = {
|
||||||
__typename?: 'RefreshToken';
|
__typename?: 'RefreshToken';
|
||||||
createdAt: Scalars['DateTime'];
|
createdAt: Scalars['DateTime'];
|
||||||
@ -500,6 +526,8 @@ export type User = {
|
|||||||
id: Scalars['ID'];
|
id: Scalars['ID'];
|
||||||
lastName: Scalars['String'];
|
lastName: Scalars['String'];
|
||||||
passwordHash?: Maybe<Scalars['String']>;
|
passwordHash?: Maybe<Scalars['String']>;
|
||||||
|
passwordResetToken?: Maybe<Scalars['String']>;
|
||||||
|
passwordResetTokenExpiresAt?: Maybe<Scalars['DateTime']>;
|
||||||
supportUserHash?: Maybe<Scalars['String']>;
|
supportUserHash?: Maybe<Scalars['String']>;
|
||||||
updatedAt: Scalars['DateTime'];
|
updatedAt: Scalars['DateTime'];
|
||||||
workspaceMember: WorkspaceMember;
|
workspaceMember: WorkspaceMember;
|
||||||
@ -518,6 +546,12 @@ export type UserExists = {
|
|||||||
exists: Scalars['Boolean'];
|
exists: Scalars['Boolean'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ValidatePasswordResetToken = {
|
||||||
|
__typename?: 'ValidatePasswordResetToken';
|
||||||
|
email: Scalars['String'];
|
||||||
|
id: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
export type Verify = {
|
export type Verify = {
|
||||||
__typename?: 'Verify';
|
__typename?: 'Verify';
|
||||||
tokens: AuthTokenPair;
|
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 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<{
|
export type GenerateApiKeyTokenMutationVariables = Exact<{
|
||||||
apiKeyId: Scalars['String'];
|
apiKeyId: Scalars['String'];
|
||||||
expiresAt: 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 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<{
|
export type VerifyMutationVariables = Exact<{
|
||||||
loginToken: Scalars['String'];
|
loginToken: Scalars['String'];
|
||||||
}>;
|
}>;
|
||||||
@ -744,6 +791,13 @@ export type CheckUserExistsQueryVariables = Exact<{
|
|||||||
|
|
||||||
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename?: 'UserExists', exists: boolean } };
|
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; }>;
|
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 ChallengeMutationHookResult = ReturnType<typeof useChallengeMutation>;
|
||||||
export type ChallengeMutationResult = Apollo.MutationResult<ChallengeMutation>;
|
export type ChallengeMutationResult = Apollo.MutationResult<ChallengeMutation>;
|
||||||
export type ChallengeMutationOptions = Apollo.BaseMutationOptions<ChallengeMutation, ChallengeMutationVariables>;
|
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`
|
export const GenerateApiKeyTokenDocument = gql`
|
||||||
mutation GenerateApiKeyToken($apiKeyId: String!, $expiresAt: String!) {
|
mutation GenerateApiKeyToken($apiKeyId: String!, $expiresAt: String!) {
|
||||||
generateApiKeyToken(apiKeyId: $apiKeyId, expiresAt: $expiresAt) {
|
generateApiKeyToken(apiKeyId: $apiKeyId, expiresAt: $expiresAt) {
|
||||||
@ -1191,6 +1277,43 @@ export function useSignUpMutation(baseOptions?: Apollo.MutationHookOptions<SignU
|
|||||||
export type SignUpMutationHookResult = ReturnType<typeof useSignUpMutation>;
|
export type SignUpMutationHookResult = ReturnType<typeof useSignUpMutation>;
|
||||||
export type SignUpMutationResult = Apollo.MutationResult<SignUpMutation>;
|
export type SignUpMutationResult = Apollo.MutationResult<SignUpMutation>;
|
||||||
export type SignUpMutationOptions = Apollo.BaseMutationOptions<SignUpMutation, SignUpMutationVariables>;
|
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`
|
export const VerifyDocument = gql`
|
||||||
mutation Verify($loginToken: String!) {
|
mutation Verify($loginToken: String!) {
|
||||||
verify(loginToken: $loginToken) {
|
verify(loginToken: $loginToken) {
|
||||||
@ -1265,6 +1388,42 @@ export function useCheckUserExistsLazyQuery(baseOptions?: Apollo.LazyQueryHookOp
|
|||||||
export type CheckUserExistsQueryHookResult = ReturnType<typeof useCheckUserExistsQuery>;
|
export type CheckUserExistsQueryHookResult = ReturnType<typeof useCheckUserExistsQuery>;
|
||||||
export type CheckUserExistsLazyQueryHookResult = ReturnType<typeof useCheckUserExistsLazyQuery>;
|
export type CheckUserExistsLazyQueryHookResult = ReturnType<typeof useCheckUserExistsLazyQuery>;
|
||||||
export type CheckUserExistsQueryResult = Apollo.QueryResult<CheckUserExistsQuery, CheckUserExistsQueryVariables>;
|
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`
|
export const GetClientConfigDocument = gql`
|
||||||
query GetClientConfig {
|
query GetClientConfig {
|
||||||
clientConfig {
|
clientConfig {
|
||||||
|
|||||||
@ -42,7 +42,8 @@ export const useApolloFactory = () => {
|
|||||||
!isMatchingLocation(AppPath.Verify) &&
|
!isMatchingLocation(AppPath.Verify) &&
|
||||||
!isMatchingLocation(AppPath.SignIn) &&
|
!isMatchingLocation(AppPath.SignIn) &&
|
||||||
!isMatchingLocation(AppPath.SignUp) &&
|
!isMatchingLocation(AppPath.SignUp) &&
|
||||||
!isMatchingLocation(AppPath.Invite)
|
!isMatchingLocation(AppPath.Invite) &&
|
||||||
|
!isMatchingLocation(AppPath.ResetPassword)
|
||||||
) {
|
) {
|
||||||
navigate(AppPath.SignIn);
|
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',
|
SignIn = '/sign-in',
|
||||||
SignUp = '/sign-up',
|
SignUp = '/sign-up',
|
||||||
Invite = '/invite/:workspaceInviteHash',
|
Invite = '/invite/:workspaceInviteHash',
|
||||||
|
ResetPassword = '/reset-password/:passwordResetToken',
|
||||||
|
|
||||||
// Onboarding
|
// Onboarding
|
||||||
CreateWorkspace = '/create/workspace',
|
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 styled from '@emotion/styled';
|
||||||
|
|
||||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
|
import { ChangePassword } from '@/settings/profile/components/ChangePassword';
|
||||||
import { DeleteAccount } from '@/settings/profile/components/DeleteAccount';
|
import { DeleteAccount } from '@/settings/profile/components/DeleteAccount';
|
||||||
import { EmailField } from '@/settings/profile/components/EmailField';
|
import { EmailField } from '@/settings/profile/components/EmailField';
|
||||||
import { NameFields } from '@/settings/profile/components/NameFields';
|
import { NameFields } from '@/settings/profile/components/NameFields';
|
||||||
@ -34,6 +35,9 @@ export const SettingsProfile = () => (
|
|||||||
/>
|
/>
|
||||||
<EmailField />
|
<EmailField />
|
||||||
</Section>
|
</Section>
|
||||||
|
<Section>
|
||||||
|
<ChangePassword />
|
||||||
|
</Section>
|
||||||
<Section>
|
<Section>
|
||||||
<DeleteAccount />
|
<DeleteAccount />
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@ -51,3 +51,4 @@ SIGN_IN_PREFILLED=true
|
|||||||
# EMAIL_SMTP_PORT=
|
# EMAIL_SMTP_PORT=
|
||||||
# EMAIL_SMTP_USER=
|
# EMAIL_SMTP_USER=
|
||||||
# EMAIL_SMTP_PASSWORD=
|
# EMAIL_SMTP_PASSWORD=
|
||||||
|
# PASSWORD_RESET_TOKEN_EXPIRES_IN=5m
|
||||||
|
|||||||
@ -16,11 +16,11 @@
|
|||||||
"start:debug": "yarn build-twenty-emails && nest start --debug --watch",
|
"start:debug": "yarn build-twenty-emails && nest start --debug --watch",
|
||||||
"start:prod": "node dist/src/main",
|
"start:prod": "node dist/src/main",
|
||||||
"lint": "eslint \"src/**/*.ts\" --fix",
|
"lint": "eslint \"src/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "yarn build-twenty-emails && jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "yarn build-twenty-emails && jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "yarn build-twenty-emails && jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register ../../node_modules/.bin/jest --runInBand",
|
"test:debug": "yarn build-twenty-emails && node --inspect-brk -r tsconfig-paths/register -r ts-node/register ../../node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "./scripts/run-integration.sh",
|
"test:e2e": "yarn build-twenty-emails && ./scripts/run-integration.sh",
|
||||||
"typeorm": "npx ts-node -r tsconfig-paths/register ../../node_modules/typeorm/cli.js",
|
"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",
|
"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",
|
"database:init": "yarn database:setup && yarn database:seed:dev",
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
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 { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
|
||||||
import { User } from 'src/core/user/user.entity';
|
import { User } from 'src/core/user/user.entity';
|
||||||
import { ApiKeyTokenInput } from 'src/core/auth/dto/api-key-token.input';
|
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 { TransientToken } from 'src/core/auth/dto/transient-token.entity';
|
||||||
import { UserService } from 'src/core/user/services/user.service';
|
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 { ApiKeyToken, AuthTokens } from './dto/token.entity';
|
||||||
import { TokenService } from './services/token.service';
|
import { TokenService } from './services/token.service';
|
||||||
@ -150,4 +157,47 @@ export class AuthResolver {
|
|||||||
args.expiresAt,
|
args.expiresAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Mutation(() => EmailPasswordResetLink)
|
||||||
|
async emailPasswordResetLink(
|
||||||
|
@AuthUser() { email }: User,
|
||||||
|
): Promise<EmailPasswordResetLink> {
|
||||||
|
const resetToken =
|
||||||
|
await this.tokenService.generatePasswordResetToken(email);
|
||||||
|
|
||||||
|
return await this.tokenService.sendEmailPasswordResetLink(
|
||||||
|
resetToken,
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => InvalidatePassword)
|
||||||
|
async updatePasswordViaResetToken(
|
||||||
|
@Args() args: UpdatePasswordViaResetTokenInput,
|
||||||
|
): Promise<InvalidatePassword> {
|
||||||
|
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<ValidatePasswordResetToken> {
|
||||||
|
return this.tokenService.validatePasswordResetToken(
|
||||||
|
args.passwordResetToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -29,3 +29,12 @@ export class AuthTokens {
|
|||||||
@Field(() => AuthTokenPair)
|
@Field(() => AuthTokenPair)
|
||||||
tokens: AuthTokenPair;
|
tokens: AuthTokenPair;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class PasswordResetToken {
|
||||||
|
@Field(() => String)
|
||||||
|
passwordResetToken: string;
|
||||||
|
|
||||||
|
@Field(() => Date)
|
||||||
|
passwordResetTokenExpiresAt: Date;
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class ValidatePasswordResetToken {
|
||||||
|
@Field(() => String)
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import { FileUploadService } from 'src/core/file/services/file-upload.service';
|
|||||||
import { Workspace } from 'src/core/workspace/workspace.entity';
|
import { Workspace } from 'src/core/workspace/workspace.entity';
|
||||||
import { User } from 'src/core/user/user.entity';
|
import { User } from 'src/core/user/user.entity';
|
||||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
import { EmailService } from 'src/integrations/email/email.service';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
@ -51,6 +52,10 @@ describe('AuthService', () => {
|
|||||||
provide: EnvironmentService,
|
provide: EnvironmentService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: EmailService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import { HttpService } from '@nestjs/axios';
|
|||||||
import FileType from 'file-type';
|
import FileType from 'file-type';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { v4 } from 'uuid';
|
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';
|
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 { getImageBufferFromUrl } from 'src/utils/image';
|
||||||
import { FileUploadService } from 'src/core/file/services/file-upload.service';
|
import { FileUploadService } from 'src/core/file/services/file-upload.service';
|
||||||
import { EnvironmentService } from 'src/integrations/environment/environment.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';
|
import { TokenService } from './token.service';
|
||||||
|
|
||||||
@ -52,6 +56,7 @@ export class AuthService {
|
|||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
|
private readonly emailService: EmailService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async challenge(challengeInput: ChallengeInput) {
|
async challenge(challengeInput: ChallengeInput) {
|
||||||
@ -241,4 +246,50 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updatePassword(
|
||||||
|
userId: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<UpdatePassword> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { EnvironmentService } from 'src/integrations/environment/environment.ser
|
|||||||
import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity';
|
import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity';
|
||||||
import { User } from 'src/core/user/user.entity';
|
import { User } from 'src/core/user/user.entity';
|
||||||
import { JwtAuthStrategy } from 'src/core/auth/strategies/jwt.auth.strategy';
|
import { JwtAuthStrategy } from 'src/core/auth/strategies/jwt.auth.strategy';
|
||||||
|
import { EmailService } from 'src/integrations/email/email.service';
|
||||||
|
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
|
|
||||||
@ -28,6 +29,10 @@ describe('TokenService', () => {
|
|||||||
provide: EnvironmentService,
|
provide: EnvironmentService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: EmailService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(User, 'core'),
|
provide: getRepositoryToken(User, 'core'),
|
||||||
useValue: {},
|
useValue: {},
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
@ -9,23 +10,35 @@ import {
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
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 ms from 'ms';
|
||||||
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
|
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { ExtractJwt } from 'passport-jwt';
|
import { ExtractJwt } from 'passport-jwt';
|
||||||
|
import { render } from '@react-email/render';
|
||||||
|
import { PasswordResetLinkEmail } from 'twenty-emails';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
JwtAuthStrategy,
|
JwtAuthStrategy,
|
||||||
JwtPayload,
|
JwtPayload,
|
||||||
} from 'src/core/auth/strategies/jwt.auth.strategy';
|
} from 'src/core/auth/strategies/jwt.auth.strategy';
|
||||||
import { assert } from 'src/utils/assert';
|
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 { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
import { User } from 'src/core/user/user.entity';
|
import { User } from 'src/core/user/user.entity';
|
||||||
import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity';
|
import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity';
|
||||||
import { Workspace } from 'src/core/workspace/workspace.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()
|
@Injectable()
|
||||||
export class TokenService {
|
export class TokenService {
|
||||||
@ -37,6 +50,7 @@ export class TokenService {
|
|||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
@InjectRepository(RefreshToken, 'core')
|
@InjectRepository(RefreshToken, 'core')
|
||||||
private readonly refreshTokenRepository: Repository<RefreshToken>,
|
private readonly refreshTokenRepository: Repository<RefreshToken>,
|
||||||
|
private readonly emailService: EmailService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateAccessToken(userId: string): Promise<AuthToken> {
|
async generateAccessToken(userId: string): Promise<AuthToken> {
|
||||||
@ -312,4 +326,149 @@ export class TokenService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generatePasswordResetToken(email: string): Promise<PasswordResetToken> {
|
||||||
|
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<EmailPasswordResetLink> {
|
||||||
|
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<ValidatePasswordResetToken> {
|
||||||
|
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<InvalidatePassword> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,6 +68,14 @@ export class User {
|
|||||||
})
|
})
|
||||||
defaultWorkspace: Workspace;
|
defaultWorkspace: Workspace;
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
@Column({ nullable: true })
|
||||||
|
passwordResetToken: string;
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
@Column({ nullable: true })
|
||||||
|
passwordResetTokenExpiresAt: Date;
|
||||||
|
|
||||||
@OneToMany(() => RefreshToken, (refreshToken) => refreshToken.user, {
|
@OneToMany(() => RefreshToken, (refreshToken) => refreshToken.user, {
|
||||||
cascade: true,
|
cascade: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddPasswordResetToken1704825571702 implements MigrationInterface {
|
||||||
|
name = 'AddPasswordResetToken1704825571702';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "core"."user" DROP COLUMN "passwordResetTokenExpiresAt"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "core"."user" DROP COLUMN "passwordResetToken"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -266,6 +266,12 @@ export class EnvironmentService {
|
|||||||
return this.configService.get<string | undefined>('OPENROUTER_API_KEY');
|
return this.configService.get<string | undefined>('OPENROUTER_API_KEY');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPasswordResetTokenExpiresIn(): string {
|
||||||
|
return (
|
||||||
|
this.configService.get<string>('PASSWORD_RESET_TOKEN_EXPIRES_IN') ?? '5m'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getInactiveDaysBeforeEmail(): number | undefined {
|
getInactiveDaysBeforeEmail(): number | undefined {
|
||||||
return this.configService.get<number | undefined>(
|
return this.configService.get<number | undefined>(
|
||||||
'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION',
|
'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION',
|
||||||
|
|||||||
@ -172,6 +172,10 @@ export class EnvironmentVariables {
|
|||||||
@IsString()
|
@IsString()
|
||||||
SENTRY_DSN?: string;
|
SENTRY_DSN?: string;
|
||||||
|
|
||||||
|
@IsDuration()
|
||||||
|
@IsOptional()
|
||||||
|
PASSWORD_RESET_TOKEN_EXPIRES_IN?: number;
|
||||||
|
|
||||||
@CastToPositiveNumber()
|
@CastToPositiveNumber()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0)
|
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user