From 3cbf958a1c442d6f0c84c241ea001393ec0d9bc3 Mon Sep 17 00:00:00 2001 From: Deepak Kumar Date: Fri, 9 Feb 2024 22:07:44 +0530 Subject: [PATCH] GH-3652 Add forgot password on sign-in page (#3789) * Remove auth guard from password reset email endpoint * Add arg for GQL mutation and update its usage * Add forgot password button on sign-in page * Generate automated graphql queries * Move utils to dedicated hook * Remove useless hook function * Split simple hook methods * Split workspace hook * Split signInWithGoogle hook * Split useSignInUpForm * Fix error in logs * Add Link Button UI Component * Add storybook doc --------- Co-authored-by: martmull --- .../src/generated-metadata/graphql.ts | 145 ++++++++++++++++++ .../twenty-front/src/generated/graphql.tsx | 14 +- .../mutations/emailPasswordResetLink.ts | 4 +- .../sign-in-up/components/SignInUpForm.tsx | 63 ++++---- .../hooks/useHandleResetPassword.ts | 45 ++++++ .../auth/sign-in-up/hooks/useSignInUp.tsx | 64 +------- .../auth/sign-in-up/hooks/useSignInUpForm.ts | 38 +++++ .../sign-in-up/hooks/useSignInWithGoogle.ts | 9 ++ .../hooks/useWorkspaceFromInviteHash.ts | 11 ++ .../profile/components/ChangePassword.tsx | 20 ++- .../navigation/link/components/ActionLink.tsx | 31 ++++ .../link/components/GithubVersionLink.tsx | 21 +-- .../__stories__/ActionLink.stories.tsx | 27 ++++ packages/twenty-front/tsup.ui.index.tsx | 1 + .../src/core/auth/auth.resolver.ts | 11 +- .../dto/email-password-reset-link.input.ts | 11 ++ 16 files changed, 399 insertions(+), 116 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithGoogle.ts create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts create mode 100644 packages/twenty-front/src/modules/ui/navigation/link/components/ActionLink.tsx create mode 100644 packages/twenty-front/src/modules/ui/navigation/link/components/__stories__/ActionLink.stories.tsx create mode 100644 packages/twenty-server/src/core/auth/dto/email-password-reset-link.input.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index ab20e1b289..f3429607c2 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -24,6 +24,11 @@ export type Scalars = { Upload: { input: any; output: any; } }; +export type ApiKeyToken = { + __typename?: 'ApiKeyToken'; + token: Scalars['String']['output']; +}; + export type AuthProviders = { __typename?: 'AuthProviders'; google: Scalars['Boolean']['output']; @@ -43,6 +48,11 @@ export type AuthTokenPair = { refreshToken: AuthToken; }; +export type AuthTokens = { + __typename?: 'AuthTokens'; + tokens: AuthTokenPair; +}; + export type Billing = { __typename?: 'Billing'; billingUrl: Scalars['String']['output']; @@ -136,6 +146,12 @@ export type DeleteOneRelationInput = { id: Scalars['ID']['input']; }; +export type EmailPasswordResetLink = { + __typename?: 'EmailPasswordResetLink'; + /** Boolean that confirms query was dispatched */ + success: Scalars['Boolean']['output']; +}; + export type FieldConnection = { __typename?: 'FieldConnection'; /** Array of edges. */ @@ -214,8 +230,20 @@ export type IdFilterComparison = { notLike?: InputMaybe; }; +export type InvalidatePassword = { + __typename?: 'InvalidatePassword'; + /** Boolean that confirms query was dispatched */ + success: Scalars['Boolean']['output']; +}; + +export type LoginToken = { + __typename?: 'LoginToken'; + loginToken: AuthToken; +}; + export type Mutation = { __typename?: 'Mutation'; + challenge: LoginToken; createOneField: Field; createOneObject: Object; createOneRelation: Relation; @@ -223,11 +251,25 @@ export type Mutation = { deleteOneObject: Object; deleteOneRelation: RelationDeleteResponse; deleteUser: User; + emailPasswordResetLink: EmailPasswordResetLink; + generateApiKeyToken: ApiKeyToken; + generateTransientToken: TransientToken; + impersonate: Verify; + renewToken: AuthTokens; + signUp: LoginToken; updateOneField: Field; updateOneObject: Object; + updatePasswordViaResetToken: InvalidatePassword; uploadFile: Scalars['String']['output']; uploadImage: Scalars['String']['output']; uploadProfilePicture: Scalars['String']['output']; + verify: Verify; +}; + + +export type MutationChallengeArgs = { + email: Scalars['String']['input']; + password: Scalars['String']['input']; }; @@ -261,6 +303,34 @@ export type MutationDeleteOneRelationArgs = { }; +export type MutationEmailPasswordResetLinkArgs = { + email: Scalars['String']['input']; +}; + + +export type MutationGenerateApiKeyTokenArgs = { + apiKeyId: Scalars['String']['input']; + expiresAt: Scalars['String']['input']; +}; + + +export type MutationImpersonateArgs = { + userId: Scalars['String']['input']; +}; + + +export type MutationRenewTokenArgs = { + refreshToken: Scalars['String']['input']; +}; + + +export type MutationSignUpArgs = { + email: Scalars['String']['input']; + password: Scalars['String']['input']; + workspaceInviteHash?: InputMaybe; +}; + + export type MutationUpdateOneFieldArgs = { input: UpdateOneFieldMetadataInput; }; @@ -271,6 +341,12 @@ export type MutationUpdateOneObjectArgs = { }; +export type MutationUpdatePasswordViaResetTokenArgs = { + newPassword: Scalars['String']['input']; + passwordResetToken: Scalars['String']['input']; +}; + + export type MutationUploadFileArgs = { file: Scalars['Upload']['input']; fileFolder?: InputMaybe; @@ -287,6 +363,11 @@ export type MutationUploadProfilePictureArgs = { file: Scalars['Upload']['input']; }; + +export type MutationVerifyArgs = { + loginToken: Scalars['String']['input']; +}; + export type ObjectConnection = { __typename?: 'ObjectConnection'; /** Array of edges. */ @@ -321,13 +402,27 @@ export type PageInfo = { export type Query = { __typename?: 'Query'; + checkUserExists: UserExists; + checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; currentUser: User; field: Field; fields: FieldConnection; + findWorkspaceFromInviteHash: Workspace; object: Object; objects: ObjectConnection; relation: Relation; relations: RelationConnection; + validatePasswordResetToken: ValidatePasswordResetToken; +}; + + +export type QueryCheckUserExistsArgs = { + email: Scalars['String']['input']; +}; + + +export type QueryCheckWorkspaceInviteHashIsValidArgs = { + inviteHash: Scalars['String']['input']; }; @@ -342,6 +437,11 @@ export type QueryFieldsArgs = { }; +export type QueryFindWorkspaceFromInviteHashArgs = { + inviteHash: Scalars['String']['input']; +}; + + export type QueryObjectArgs = { id: Scalars['ID']['input']; }; @@ -362,6 +462,11 @@ export type QueryRelationsArgs = { paging?: CursorPaging; }; + +export type QueryValidatePasswordResetTokenArgs = { + passwordResetToken: Scalars['String']['input']; +}; + export type RefreshToken = { __typename?: 'RefreshToken'; createdAt: Scalars['DateTime']['output']; @@ -424,6 +529,19 @@ export type Telemetry = { enabled: Scalars['Boolean']['output']; }; +export type TimelineThread = { + __typename?: 'TimelineThread'; + firstParticipant: TimelineThreadParticipant; + id: Scalars['ID']['output']; + lastMessageBody: Scalars['String']['output']; + lastMessageReceivedAt: Scalars['DateTime']['output']; + lastTwoParticipants: Array; + numberOfMessagesInThread: Scalars['Float']['output']; + participantCount: Scalars['Float']['output']; + read: Scalars['Boolean']['output']; + subject: Scalars['String']['output']; +}; + export type TimelineThreadParticipant = { __typename?: 'TimelineThreadParticipant'; avatarUrl: Scalars['String']['output']; @@ -435,6 +553,11 @@ export type TimelineThreadParticipant = { workspaceMemberId?: Maybe; }; +export type TransientToken = { + __typename?: 'TransientToken'; + transientToken: AuthToken; +}; + export type UpdateFieldInput = { defaultValue?: InputMaybe; description?: InputMaybe; @@ -502,6 +625,23 @@ export type UserEdge = { node: User; }; +export type UserExists = { + __typename?: 'UserExists'; + exists: Scalars['Boolean']['output']; +}; + +export type ValidatePasswordResetToken = { + __typename?: 'ValidatePasswordResetToken'; + email: Scalars['String']['output']; + id: Scalars['String']['output']; +}; + +export type Verify = { + __typename?: 'Verify'; + tokens: AuthTokenPair; + user: User; +}; + export type Workspace = { __typename?: 'Workspace'; allowImpersonation: Scalars['Boolean']['output']; @@ -524,6 +664,11 @@ export type WorkspaceEdge = { node: Workspace; }; +export type WorkspaceInviteHashValid = { + __typename?: 'WorkspaceInviteHashValid'; + isValid: Scalars['Boolean']['output']; +}; + export type WorkspaceMember = { __typename?: 'WorkspaceMember'; avatarUrl?: Maybe; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 2b2b4cb534..84b54666bd 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -265,6 +265,11 @@ export type MutationDeleteOneObjectArgs = { }; +export type MutationEmailPasswordResetLinkArgs = { + email: Scalars['String']; +}; + + export type MutationGenerateApiKeyTokenArgs = { apiKeyId: Scalars['String']; expiresAt: Scalars['String']; @@ -752,7 +757,9 @@ 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 EmailPasswordResetLinkMutationVariables = Exact<{ + email: Scalars['String']; +}>; export type EmailPasswordResetLinkMutation = { __typename?: 'Mutation', emailPasswordResetLink: { __typename?: 'EmailPasswordResetLink', success: boolean } }; @@ -1143,8 +1150,8 @@ export type ChallengeMutationHookResult = ReturnType; export type ChallengeMutationOptions = Apollo.BaseMutationOptions; export const EmailPasswordResetLinkDocument = gql` - mutation EmailPasswordResetLink { - emailPasswordResetLink { + mutation EmailPasswordResetLink($email: String!) { + emailPasswordResetLink(email: $email) { success } } @@ -1164,6 +1171,7 @@ export type EmailPasswordResetLinkMutationFn = Apollo.MutationFunction { + const [authProviders] = useRecoilState(authProvidersState); + const [showErrors, setShowErrors] = useState(false); + const { handleResetPassword } = useHandleResetPassword(); + const workspace = useWorkspaceFromInviteHash(); + const { signInWithGoogle } = useSignInWithGoogle(); + const { form } = useSignInUpForm(); + const { - authProviders, - signInWithGoogle, signInUpStep, signInUpMode, - showErrors, - setShowErrors, continueWithCredentials, continueWithEmail, submitCredentials, - form: { - control, - watch, - handleSubmit, - formState: { isSubmitting }, - }, - workspace, - } = useSignInUp(); + } = useSignInUp(form); const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { @@ -72,7 +75,7 @@ export const SignInUpForm = () => { continueWithCredentials(); } else if (signInUpStep === SignInUpStep.Password) { setShowErrors(true); - handleSubmit(submitCredentials)(); + form.handleSubmit(submitCredentials)(); } } }; @@ -88,10 +91,10 @@ export const SignInUpForm = () => { return signInUpMode === SignInUpMode.SignIn ? 'Sign in' - : isSubmitting + : form.formState.isSubmitting ? 'Creating workspace' : 'Sign up'; - }, [signInUpMode, signInUpStep, isSubmitting]); + }, [signInUpMode, signInUpStep, form.formState.isSubmitting]); const title = useMemo(() => { if (signInUpMode === SignInUpMode.Invite) { @@ -141,7 +144,7 @@ export const SignInUpForm = () => { > { > { return; } setShowErrors(true); - handleSubmit(submitCredentials)(); + form.handleSubmit(submitCredentials)(); }} - Icon={() => isSubmitting && } + Icon={() => form.formState.isSubmitting && } disabled={ SignInUpStep.Init ? false : signInUpStep === SignInUpStep.Email - ? !watch('email') - : !watch('email') || !watch('password') || isSubmitting + ? !form.watch('email') + : !form.watch('email') || + !form.watch('password') || + form.formState.isSubmitting } fullWidth /> - - By using Twenty, you agree to the Terms of Service and Data Processing - Agreement. - + {signInUpStep === SignInUpStep.Password ? ( + + Forgot your password? + + ) : ( + + By using Twenty, you agree to the Terms of Service and Data Processing + Agreement. + + )} ); }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts new file mode 100644 index 0000000000..b4dd9f67ec --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts @@ -0,0 +1,45 @@ +import { useCallback } from 'react'; + +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar.tsx'; +import { useEmailPasswordResetLinkMutation } from '~/generated/graphql.tsx'; + +export const useHandleResetPassword = () => { + const { enqueueSnackBar } = useSnackBar(); + const [emailPasswordResetLink] = useEmailPasswordResetLinkMutation(); + + const handleResetPassword = useCallback( + (email: string) => { + return async () => { + if (!email) { + enqueueSnackBar('Invalid email', { + variant: 'error', + }); + return; + } + + try { + const { data } = await emailPasswordResetLink({ + variables: { email }, + }); + + if (data?.emailPasswordResetLink?.success) { + enqueueSnackBar('Password reset link has been sent to the email', { + variant: 'success', + }); + } else { + enqueueSnackBar('There was some issue', { + variant: 'error', + }); + } + } catch (error) { + enqueueSnackBar((error as Error).message, { + variant: 'error', + }); + } + }; + }, + [enqueueSnackBar, emailPasswordResetLink], + ); + + return { handleResetPassword }; +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx index 75fa737687..fdaca81ca6 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx @@ -1,22 +1,17 @@ -import { useCallback, useEffect, useState } from 'react'; -import { SubmitHandler, useForm } from 'react-hook-form'; +import { useCallback, useState } from 'react'; +import { SubmitHandler, UseFormReturn } from 'react-hook-form'; import { useNavigate, useParams } from 'react-router-dom'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useRecoilState, useRecoilValue } from 'recoil'; -import { z } from 'zod'; +import { useRecoilValue } from 'recoil'; -import { authProvidersState } from '@/client-config/states/authProvidersState'; +import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm.ts'; import { billingState } from '@/client-config/states/billingState'; -import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; import { AppPath } from '@/types/AppPath'; import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { useAuth } from '../../hooks/useAuth'; -import { PASSWORD_REGEX } from '../../utils/passwordRegex'; export enum SignInUpMode { SignIn = 'sign-in', @@ -30,27 +25,11 @@ export enum SignInUpStep { Password = 'password', } -const validationSchema = z - .object({ - exist: z.boolean(), - email: z.string().trim().email('Email must be a valid email'), - password: z - .string() - .regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'), - }) - .required(); - -type Form = z.infer; - -export const useSignInUp = () => { +export const useSignInUp = (form: UseFormReturn
) => { const navigate = useNavigate(); const { enqueueSnackBar } = useSnackBar(); const isMatchingLocation = useIsMatchingLocation(); - - const [authProviders] = useRecoilState(authProvidersState); - const isSignInPrefilled = useRecoilValue(isSignInPrefilledState); const billing = useRecoilValue(billingState); - const workspaceInviteHash = useParams().workspaceInviteHash; const [signInUpStep, setSignInUpStep] = useState( SignInUpStep.Init, @@ -64,31 +43,9 @@ export const useSignInUp = () => { ? SignInUpMode.SignIn : SignInUpMode.SignUp; }); - const [showErrors, setShowErrors] = useState(false); - - const { data: workspaceFromInviteHash } = useGetWorkspaceFromInviteHashQuery({ - variables: { inviteHash: workspaceInviteHash || '' }, - }); - - const form = useForm({ - mode: 'onChange', - defaultValues: { - exist: false, - }, - resolver: zodResolver(validationSchema), - }); - - useEffect(() => { - if (isSignInPrefilled) { - form.setValue('email', 'tim@apple.dev'); - form.setValue('password', 'Applecar2025'); - } - }, [form, isSignInPrefilled]); - const { signInWithCredentials, signUpWithCredentials, - signInWithGoogle, checkUserExists: { checkUserExistsQuery }, } = useAuth(); @@ -169,10 +126,6 @@ export const useSignInUp = () => { ], ); - const goBackToEmailStep = useCallback(() => { - setSignInUpStep(SignInUpStep.Email); - }, [setSignInUpStep]); - useScopedHotkeys( 'enter', () => { @@ -193,17 +146,10 @@ export const useSignInUp = () => { ); return { - authProviders, - signInWithGoogle: () => signInWithGoogle(workspaceInviteHash), signInUpStep, signInUpMode, - showErrors, - setShowErrors, continueWithCredentials, continueWithEmail, - goBackToEmailStep, submitCredentials, - form, - workspace: workspaceFromInviteHash?.findWorkspaceFromInviteHash, }; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts new file mode 100644 index 0000000000..591abf368f --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useRecoilValue } from 'recoil'; +import { z } from 'zod'; + +import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex.ts'; +import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState.ts'; + +const validationSchema = z + .object({ + exist: z.boolean(), + email: z.string().trim().email('Email must be a valid email'), + password: z + .string() + .regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'), + }) + .required(); + +export type Form = z.infer; +export const useSignInUpForm = () => { + const isSignInPrefilled = useRecoilValue(isSignInPrefilledState); + const form = useForm({ + mode: 'onChange', + defaultValues: { + exist: false, + }, + resolver: zodResolver(validationSchema), + }); + + useEffect(() => { + if (isSignInPrefilled) { + form.setValue('email', 'tim@apple.dev'); + form.setValue('password', 'Applecar2025'); + } + }, [form, isSignInPrefilled]); + return { form: form }; +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithGoogle.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithGoogle.ts new file mode 100644 index 0000000000..2bd2b31862 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithGoogle.ts @@ -0,0 +1,9 @@ +import { useParams } from 'react-router-dom'; + +import { useAuth } from '@/auth/hooks/useAuth.ts'; + +export const useSignInWithGoogle = () => { + const workspaceInviteHash = useParams().workspaceInviteHash; + const { signInWithGoogle } = useAuth(); + return { signInWithGoogle: () => signInWithGoogle(workspaceInviteHash) }; +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts new file mode 100644 index 0000000000..a821ecee45 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts @@ -0,0 +1,11 @@ +import { useParams } from 'react-router-dom'; + +import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql.tsx'; + +export const useWorkspaceFromInviteHash = () => { + const workspaceInviteHash = useParams().workspaceInviteHash; + const { data: workspaceFromInviteHash } = useGetWorkspaceFromInviteHashQuery({ + variables: { inviteHash: workspaceInviteHash || '' }, + }); + return workspaceFromInviteHash?.findWorkspaceFromInviteHash; +}; diff --git a/packages/twenty-front/src/modules/settings/profile/components/ChangePassword.tsx b/packages/twenty-front/src/modules/settings/profile/components/ChangePassword.tsx index 51743ac07e..9548704d4c 100644 --- a/packages/twenty-front/src/modules/settings/profile/components/ChangePassword.tsx +++ b/packages/twenty-front/src/modules/settings/profile/components/ChangePassword.tsx @@ -1,17 +1,32 @@ +import { useRecoilValue } from 'recoil'; + +import { currentUserState } from '@/auth/states/currentUserState'; import { H2Title } from '@/ui/display/typography/components/H2Title'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { Button } from '@/ui/input/button/components/Button'; import { useEmailPasswordResetLinkMutation } from '~/generated/graphql'; -import { logError } from '~/utils/logError'; export const ChangePassword = () => { const { enqueueSnackBar } = useSnackBar(); + const currentUser = useRecoilValue(currentUserState); + const [emailPasswordResetLink] = useEmailPasswordResetLinkMutation(); const handlePasswordResetClick = async () => { + if (!currentUser?.email) { + enqueueSnackBar('Invalid email', { + variant: 'error', + }); + return; + } + try { - const { data } = await emailPasswordResetLink(); + const { data } = await emailPasswordResetLink({ + variables: { + email: currentUser.email, + }, + }); if (data?.emailPasswordResetLink?.success) { enqueueSnackBar('Password reset link has been sent to the email', { variant: 'success', @@ -22,7 +37,6 @@ export const ChangePassword = () => { }); } } catch (error) { - logError(error); enqueueSnackBar((error as Error).message, { variant: 'error', }); diff --git a/packages/twenty-front/src/modules/ui/navigation/link/components/ActionLink.tsx b/packages/twenty-front/src/modules/ui/navigation/link/components/ActionLink.tsx new file mode 100644 index 0000000000..29b6b3bd17 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/link/components/ActionLink.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +const StyledButtonLink = styled.a` + align-items: center; + color: ${({ theme }) => theme.font.color.light}; + display: flex; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + gap: ${({ theme }) => theme.spacing(1)}; + padding: 0 ${({ theme }) => theme.spacing(1)}; + text-decoration: none; + + :hover { + color: ${({ theme }) => theme.font.color.tertiary}; + cursor: pointer; + } +`; + +export const ActionLink = (props: React.ComponentProps<'a'>) => { + return ( + + {props.children} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/link/components/GithubVersionLink.tsx b/packages/twenty-front/src/modules/ui/navigation/link/components/GithubVersionLink.tsx index d7f6e96a56..5225263a93 100644 --- a/packages/twenty-front/src/modules/ui/navigation/link/components/GithubVersionLink.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/link/components/GithubVersionLink.tsx @@ -1,33 +1,18 @@ import { useTheme } from '@emotion/react'; -import styled from '@emotion/styled'; import { IconBrandGithub } from '@/ui/display/icon'; +import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx'; import packageJson from '../../../../../../package.json'; import { githubLink } from '../constants'; -const StyledVersionLink = styled.a` - align-items: center; - color: ${({ theme }) => theme.font.color.light}; - display: flex; - font-size: ${({ theme }) => theme.font.size.sm}; - font-weight: ${({ theme }) => theme.font.weight.medium}; - gap: ${({ theme }) => theme.spacing(1)}; - padding: 0 ${({ theme }) => theme.spacing(1)}; - text-decoration: none; - - :hover { - color: ${({ theme }) => theme.font.color.tertiary}; - } -`; - export const GithubVersionLink = () => { const theme = useTheme(); return ( - + {packageJson.version} - + ); }; diff --git a/packages/twenty-front/src/modules/ui/navigation/link/components/__stories__/ActionLink.stories.tsx b/packages/twenty-front/src/modules/ui/navigation/link/components/__stories__/ActionLink.stories.tsx new file mode 100644 index 0000000000..6dbb0aea9e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/link/components/__stories__/ActionLink.stories.tsx @@ -0,0 +1,27 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx'; +import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator.tsx'; + +const meta: Meta = { + title: 'UI/navigation/link/ActionLink', + component: ActionLink, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'Need to reset your password?', + onClick: () => alert('Action link clicked'), + target: undefined, + rel: undefined, + }, + argTypes: { + href: { control: false }, + target: { type: 'string' }, + rel: { type: 'string' }, + }, + decorators: [ComponentDecorator], +}; diff --git a/packages/twenty-front/tsup.ui.index.tsx b/packages/twenty-front/tsup.ui.index.tsx index e0d48e468e..13c317e5cc 100644 --- a/packages/twenty-front/tsup.ui.index.tsx +++ b/packages/twenty-front/tsup.ui.index.tsx @@ -27,6 +27,7 @@ export * from './src/modules/ui/input/button/components/FloatingButtonGroup' export * from './src/modules/ui/input/button/components/FloatingIconButton' export * from './src/modules/ui/input/button/components/FloatingIconButtonGroup' export * from './src/modules/ui/input/button/components/LightButton' +export * from '@/ui/navigation/link/components/ActionLink.tsx' export * from './src/modules/ui/input/button/components/LightIconButton' export * from './src/modules/ui/input/button/components/MainButton' export * from './src/modules/ui/input/button/components/RoundedIconButton' diff --git a/packages/twenty-server/src/core/auth/auth.resolver.ts b/packages/twenty-server/src/core/auth/auth.resolver.ts index 91fd56667a..c2b2b2cea8 100644 --- a/packages/twenty-server/src/core/auth/auth.resolver.ts +++ b/packages/twenty-server/src/core/auth/auth.resolver.ts @@ -24,6 +24,7 @@ import { ValidatePasswordResetTokenInput } from 'src/core/auth/dto/validate-pass 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 { EmailPasswordResetLinkInput } from 'src/core/auth/dto/email-password-reset-link.input'; import { ApiKeyToken, AuthTokens } from './dto/token.entity'; import { TokenService } from './services/token.service'; @@ -162,17 +163,17 @@ export class AuthResolver { ); } - @UseGuards(JwtAuthGuard) @Mutation(() => EmailPasswordResetLink) async emailPasswordResetLink( - @AuthUser() { email }: User, + @Args() emailPasswordResetInput: EmailPasswordResetLinkInput, ): Promise { - const resetToken = - await this.tokenService.generatePasswordResetToken(email); + const resetToken = await this.tokenService.generatePasswordResetToken( + emailPasswordResetInput.email, + ); return await this.tokenService.sendEmailPasswordResetLink( resetToken, - email, + emailPasswordResetInput.email, ); } diff --git a/packages/twenty-server/src/core/auth/dto/email-password-reset-link.input.ts b/packages/twenty-server/src/core/auth/dto/email-password-reset-link.input.ts new file mode 100644 index 0000000000..f73a2a6708 --- /dev/null +++ b/packages/twenty-server/src/core/auth/dto/email-password-reset-link.input.ts @@ -0,0 +1,11 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsEmail, IsNotEmpty } from 'class-validator'; + +@ArgsType() +export class EmailPasswordResetLinkInput { + @Field(() => String) + @IsNotEmpty() + @IsEmail() + email: string; +}