From 89c97993e3ff021ba81b529a9ce4be7663e90bf0 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 18 Sep 2024 23:27:31 +0200 Subject: [PATCH] feat(invitation): Improve invitation flow - Milestone 2 (#6804) From PR: #6626 Resolves #6763 Resolves #6055 Resolves #6782 ## GTK I retain the 'Invite by link' feature to prevent any breaking changes. We could make the invitation by link optional through an admin setting, allowing users to rely solely on personal invitations. ## Todo - [x] Add an expiration date to an invitation - [x] Allow to renew an invitation to postpone the expiration date - [x] Refresh the UI - [x] Add the new personal token in the link sent to new user - [x] Display an error if a user tries to use an expired invitation - [x] Display an error if a user uses another mail than the one in the invitation --------- Co-authored-by: Charles Bochet --- .gitignore | 3 +- packages/twenty-front/jest.config.ts | 6 +- .../twenty-front/src/generated/graphql.tsx | 297 +++++++++++++++--- .../modules/auth/graphql/mutations/signUp.ts | 2 + .../src/modules/auth/hooks/useAuth.ts | 50 ++- .../auth/sign-in-up/hooks/useSignInUp.tsx | 7 +- .../sign-in-up/hooks/useSignInWithGoogle.ts | 10 +- .../hooks/useSignInWithMicrosoft.ts | 11 +- .../hooks/useWorkspaceFromInviteHash.ts | 1 + .../hooks/useObjectMetadataItem.ts | 2 +- .../ui/layout/table/components/TableRow.tsx | 6 +- .../mutations/deleteWorkspaceInvitation.ts | 7 + .../mutations/resendWorkspaceInvitation.ts | 17 + .../graphql/mutations/sendInvitations.ts | 17 + .../queries/getWorkspaceInvitations.ts | 11 + .../hooks/useCreateWorkspaceInvitation.ts | 26 ++ .../hooks/useDeleteWorkspaceInvitation.ts | 34 ++ .../hooks/useResendWorkspaceInvitation.ts | 35 +++ .../states/workspaceInvitationsStates.ts | 9 + .../addUserToWorkspaceByInviteToken.ts | 9 + .../workspace-member/types/WorkspaceMember.ts | 7 + .../components/WorkspaceInviteTeam.tsx | 41 ++- .../components/WorkspaceMemberCard.tsx | 57 ---- .../graphql/mutations/sendInviteLink.ts | 9 - .../twenty-front/src/pages/auth/Invite.tsx | 34 +- .../src/pages/onboarding/InviteTeam.tsx | 13 +- .../settings/SettingsWorkspaceMembers.tsx | 232 ++++++++++++-- .../migrations/1724056827317-addInvitation.ts | 51 +++ .../graphql-config/graphql-config.service.ts | 2 +- ...graphql-query-find-one-resolver.service.ts | 11 +- .../core-query-builder.factory.ts | 2 +- .../metadata/rest-api-metadata.service.ts | 4 +- .../app-token/app-token.entity.ts | 8 +- .../engine/core-modules/auth/auth.module.ts | 6 +- .../core-modules/auth/auth.resolver.spec.ts | 12 +- .../engine/core-modules/auth/auth.resolver.ts | 2 +- .../google-apis-auth.controller.ts | 4 +- .../controllers/google-auth.controller.ts | 13 +- .../controllers/microsoft-auth.controller.ts | 13 +- .../verify-auth.controller.spec.ts | 2 +- .../controllers/verify-auth.controller.ts | 2 +- .../core-modules/auth/dto/sign-up.input.ts | 5 + .../auth/dto/workspace-invite-token.input.ts | 12 + .../auth/guards/google-oauth.guard.ts | 9 + .../auth/guards/microsoft-oauth.guard.ts | 9 + .../auth/services/auth.service.spec.ts | 16 +- .../auth/services/auth.service.ts | 6 +- .../auth/services/sign-in-up.service.spec.ts | 5 + .../auth/services/sign-in-up.service.ts | 106 +++++-- .../auth/strategies/google.auth.strategy.ts | 8 + .../strategies/microsoft.auth.strategy.ts | 8 + .../services/token.service.spec.ts | 0 .../{ => token}/services/token.service.ts | 31 +- .../core-modules/auth/token/token.module.ts | 26 ++ .../core-modules/billing/billing.module.ts | 4 +- .../billing-portal.workspace-service.ts | 12 +- .../engine/core-modules/core-engine.module.ts | 3 + .../environment/environment-variables.ts | 4 + .../core-modules/message-queue/jobs.module.ts | 2 + .../open-api/open-api.service.spec.ts | 4 +- .../core-modules/open-api/open-api.service.ts | 4 +- .../user-workspace/user-workspace.module.ts | 10 +- .../user-workspace/user-workspace.resolver.ts | 21 +- .../user-workspace/user-workspace.service.ts | 57 ++++ .../dtos/send-invitations.input.ts} | 2 +- .../dtos/send-invitations.output.ts | 17 + .../dtos/workspace-invitation.dto.ts | 17 + .../workspace-invitation.service.spec.ts | 55 ++++ .../services/workspace-invitation.service.ts | 293 +++++++++++++++++ .../workspace-invitation.exception.ts | 17 + .../workspace-invitation.module.ts | 21 ++ .../workspace-invitation.resolver.ts | 66 ++++ .../workspace/dtos/send-invite-link.entity.ts | 9 - .../services/workspace.service.spec.ts | 5 + .../workspace/services/workspace.service.ts | 65 +--- .../workspace/workspace.module.ts | 2 + .../workspace/workspace.resolver.ts | 16 - .../src/engine/guards/jwt-auth.guard.ts | 2 +- ...l-hydrate-request-from-token.middleware.ts | 2 +- packages/twenty-ui/src/display/index.ts | 1 + .../typography/components/StyledText.tsx | 52 +++ 81 files changed, 1726 insertions(+), 363 deletions(-) create mode 100644 packages/twenty-front/src/modules/workspace-invitation/graphql/mutations/deleteWorkspaceInvitation.ts create mode 100644 packages/twenty-front/src/modules/workspace-invitation/graphql/mutations/resendWorkspaceInvitation.ts create mode 100644 packages/twenty-front/src/modules/workspace-invitation/graphql/mutations/sendInvitations.ts create mode 100644 packages/twenty-front/src/modules/workspace-invitation/graphql/queries/getWorkspaceInvitations.ts create mode 100644 packages/twenty-front/src/modules/workspace-invitation/hooks/useCreateWorkspaceInvitation.ts create mode 100644 packages/twenty-front/src/modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation.ts create mode 100644 packages/twenty-front/src/modules/workspace-invitation/hooks/useResendWorkspaceInvitation.ts create mode 100644 packages/twenty-front/src/modules/workspace-invitation/states/workspaceInvitationsStates.ts create mode 100644 packages/twenty-front/src/modules/workspace-member/grapqhql/mutations/addUserToWorkspaceByInviteToken.ts delete mode 100644 packages/twenty-front/src/modules/workspace/components/WorkspaceMemberCard.tsx delete mode 100644 packages/twenty-front/src/modules/workspace/graphql/mutations/sendInviteLink.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/1724056827317-addInvitation.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/dto/workspace-invite-token.input.ts rename packages/twenty-server/src/engine/core-modules/auth/{ => token}/services/token.service.spec.ts (100%) rename packages/twenty-server/src/engine/core-modules/auth/{ => token}/services/token.service.ts (96%) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts rename packages/twenty-server/src/engine/core-modules/{workspace/dtos/send-invite-link.input.ts => workspace-invitation/dtos/send-invitations.input.ts} (86%) create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/send-invitations.output.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.resolver.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.entity.ts create mode 100644 packages/twenty-ui/src/display/typography/components/StyledText.tsx diff --git a/.gitignore b/.gitignore index febe678d45..f8b0d9f6cd 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ storybook-static *.tsbuildinfo .eslintcache .nyc_output -test-results/ \ No newline at end of file +test-results/ + diff --git a/packages/twenty-front/jest.config.ts b/packages/twenty-front/jest.config.ts index b9c8205189..c71df7b77a 100644 --- a/packages/twenty-front/jest.config.ts +++ b/packages/twenty-front/jest.config.ts @@ -24,9 +24,9 @@ const jestConfig: JestConfigWithTsJest = { extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageThreshold: { global: { - statements: 62, - lines: 61, - functions: 52, + statements: 60, + lines: 60, + functions: 50, }, }, collectCoverageFrom: ['/src/**/*.ts'], diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index ee913c8bb6..0bd71dcaed 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -340,6 +340,7 @@ export type Mutation = { activateWorkflowVersion: Scalars['Boolean']; activateWorkspace: Workspace; addUserToWorkspace: User; + addUserToWorkspaceByInviteToken: User; authorizeApp: AuthorizeApp; challenge: LoginToken; checkoutSession: SessionEntity; @@ -352,6 +353,7 @@ export type Mutation = { deleteOneObject: Object; deleteOneServerlessFunction: ServerlessFunction; deleteUser: User; + deleteWorkspaceInvitation: Scalars['String']; disablePostgresProxy: PostgresCredentials; emailPasswordResetLink: EmailPasswordResetLink; enablePostgresProxy: PostgresCredentials; @@ -363,8 +365,9 @@ export type Mutation = { impersonate: Verify; publishServerlessFunction: ServerlessFunction; renewToken: AuthTokens; + resendWorkspaceInvitation: SendInvitationsOutput; runWorkflowVersion: WorkflowRun; - sendInviteLink: SendInviteLink; + sendInvitations: SendInvitationsOutput; signUp: LoginToken; skipSyncEmailOnboardingStep: OnboardingStepSuccess; track: Analytics; @@ -396,6 +399,11 @@ export type MutationAddUserToWorkspaceArgs = { }; +export type MutationAddUserToWorkspaceByInviteTokenArgs = { + inviteToken: Scalars['String']; +}; + + export type MutationAuthorizeAppArgs = { clientId: Scalars['String']; codeChallenge?: InputMaybe; @@ -442,6 +450,11 @@ export type MutationDeleteOneServerlessFunctionArgs = { }; +export type MutationDeleteWorkspaceInvitationArgs = { + appTokenId: Scalars['String']; +}; + + export type MutationEmailPasswordResetLinkArgs = { email: Scalars['String']; }; @@ -485,12 +498,17 @@ export type MutationRenewTokenArgs = { }; +export type MutationResendWorkspaceInvitationArgs = { + appTokenId: Scalars['String']; +}; + + export type MutationRunWorkflowVersionArgs = { input: RunWorkflowVersionInput; }; -export type MutationSendInviteLinkArgs = { +export type MutationSendInvitationsArgs = { emails: Array; }; @@ -500,6 +518,7 @@ export type MutationSignUpArgs = { email: Scalars['String']; password: Scalars['String']; workspaceInviteHash?: InputMaybe; + workspacePersonalInviteToken?: InputMaybe; }; @@ -636,6 +655,7 @@ export type Query = { currentUser: User; currentWorkspace: Workspace; findWorkspaceFromInviteHash: Workspace; + findWorkspaceInvitations: Array; getAISQLQuery: AisqlQueryResult; getAvailablePackages: Scalars['JSON']; getPostgresCredentials?: Maybe; @@ -790,8 +810,10 @@ export type RunWorkflowVersionInput = { workflowVersionId: Scalars['String']; }; -export type SendInviteLink = { - __typename?: 'SendInviteLink'; +export type SendInvitationsOutput = { + __typename?: 'SendInvitationsOutput'; + errors: Array; + result: Array; /** Boolean that confirms query was dispatched */ success: Scalars['Boolean']; }; @@ -1147,6 +1169,13 @@ export type WorkspaceEdge = { node: Workspace; }; +export type WorkspaceInvitation = { + __typename?: 'WorkspaceInvitation'; + email: Scalars['String']; + expiresAt: Scalars['DateTime']; + id: Scalars['UUID']; +}; + export type WorkspaceInviteHashValid = { __typename?: 'WorkspaceInviteHashValid'; isValid: Scalars['Boolean']; @@ -1415,6 +1444,7 @@ export type SignUpMutationVariables = Exact<{ email: Scalars['String']; password: Scalars['String']; workspaceInviteHash?: InputMaybe; + workspacePersonalInviteToken?: InputMaybe; captchaToken?: InputMaybe; }>; @@ -1514,6 +1544,32 @@ export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; +export type DeleteWorkspaceInvitationMutationVariables = Exact<{ + appTokenId: Scalars['String']; +}>; + + +export type DeleteWorkspaceInvitationMutation = { __typename?: 'Mutation', deleteWorkspaceInvitation: string }; + +export type ResendWorkspaceInvitationMutationVariables = Exact<{ + appTokenId: Scalars['String']; +}>; + + +export type ResendWorkspaceInvitationMutation = { __typename?: 'Mutation', resendWorkspaceInvitation: { __typename?: 'SendInvitationsOutput', success: boolean, errors: Array, result: Array<{ __typename?: 'WorkspaceInvitation', id: any, email: string, expiresAt: string }> } }; + +export type SendInvitationsMutationVariables = Exact<{ + emails: Array | Scalars['String']; +}>; + + +export type SendInvitationsMutation = { __typename?: 'Mutation', sendInvitations: { __typename?: 'SendInvitationsOutput', success: boolean, errors: Array, result: Array<{ __typename?: 'WorkspaceInvitation', id: any, email: string, expiresAt: string }> } }; + +export type GetWorkspaceInvitationsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetWorkspaceInvitationsQuery = { __typename?: 'Query', findWorkspaceInvitations: Array<{ __typename?: 'WorkspaceInvitation', id: any, email: string, expiresAt: string }> }; + export type WorkspaceMemberQueryFragmentFragment = { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }; export type AddUserToWorkspaceMutationVariables = Exact<{ @@ -1523,6 +1579,13 @@ export type AddUserToWorkspaceMutationVariables = Exact<{ export type AddUserToWorkspaceMutation = { __typename?: 'Mutation', addUserToWorkspace: { __typename?: 'User', id: any } }; +export type AddUserToWorkspaceByInviteTokenMutationVariables = Exact<{ + inviteToken: Scalars['String']; +}>; + + +export type AddUserToWorkspaceByInviteTokenMutation = { __typename?: 'Mutation', addUserToWorkspaceByInviteToken: { __typename?: 'User', id: any } }; + export type ActivateWorkspaceMutationVariables = Exact<{ input: ActivateWorkspaceInput; }>; @@ -1535,13 +1598,6 @@ export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: nev export type DeleteCurrentWorkspaceMutation = { __typename?: 'Mutation', deleteCurrentWorkspace: { __typename?: 'Workspace', id: any } }; -export type SendInviteLinkMutationVariables = Exact<{ - emails: Array | Scalars['String']; -}>; - - -export type SendInviteLinkMutation = { __typename?: 'Mutation', sendInviteLink: { __typename?: 'SendInviteLink', success: boolean } }; - export type UpdateWorkspaceMutationVariables = Exact<{ input: UpdateWorkspaceInput; }>; @@ -2262,11 +2318,12 @@ export type RenewTokenMutationHookResult = ReturnType; export type RenewTokenMutationOptions = Apollo.BaseMutationOptions; export const SignUpDocument = gql` - mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $captchaToken: String) { + mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String) { signUp( email: $email password: $password workspaceInviteHash: $workspaceInviteHash + workspacePersonalInviteToken: $workspacePersonalInviteToken captchaToken: $captchaToken ) { loginToken { @@ -2293,6 +2350,7 @@ export type SignUpMutationFn = Apollo.MutationFunction; export type GetCurrentUserLazyQueryHookResult = ReturnType; export type GetCurrentUserQueryResult = Apollo.QueryResult; +export const DeleteWorkspaceInvitationDocument = gql` + mutation DeleteWorkspaceInvitation($appTokenId: String!) { + deleteWorkspaceInvitation(appTokenId: $appTokenId) +} + `; +export type DeleteWorkspaceInvitationMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteWorkspaceInvitationMutation__ + * + * To run a mutation, you first call `useDeleteWorkspaceInvitationMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteWorkspaceInvitationMutation` 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 [deleteWorkspaceInvitationMutation, { data, loading, error }] = useDeleteWorkspaceInvitationMutation({ + * variables: { + * appTokenId: // value for 'appTokenId' + * }, + * }); + */ +export function useDeleteWorkspaceInvitationMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteWorkspaceInvitationDocument, options); + } +export type DeleteWorkspaceInvitationMutationHookResult = ReturnType; +export type DeleteWorkspaceInvitationMutationResult = Apollo.MutationResult; +export type DeleteWorkspaceInvitationMutationOptions = Apollo.BaseMutationOptions; +export const ResendWorkspaceInvitationDocument = gql` + mutation ResendWorkspaceInvitation($appTokenId: String!) { + resendWorkspaceInvitation(appTokenId: $appTokenId) { + success + errors + result { + ... on WorkspaceInvitation { + id + email + expiresAt + } + } + } +} + `; +export type ResendWorkspaceInvitationMutationFn = Apollo.MutationFunction; + +/** + * __useResendWorkspaceInvitationMutation__ + * + * To run a mutation, you first call `useResendWorkspaceInvitationMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useResendWorkspaceInvitationMutation` 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 [resendWorkspaceInvitationMutation, { data, loading, error }] = useResendWorkspaceInvitationMutation({ + * variables: { + * appTokenId: // value for 'appTokenId' + * }, + * }); + */ +export function useResendWorkspaceInvitationMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ResendWorkspaceInvitationDocument, options); + } +export type ResendWorkspaceInvitationMutationHookResult = ReturnType; +export type ResendWorkspaceInvitationMutationResult = Apollo.MutationResult; +export type ResendWorkspaceInvitationMutationOptions = Apollo.BaseMutationOptions; +export const SendInvitationsDocument = gql` + mutation SendInvitations($emails: [String!]!) { + sendInvitations(emails: $emails) { + success + errors + result { + ... on WorkspaceInvitation { + id + email + expiresAt + } + } + } +} + `; +export type SendInvitationsMutationFn = Apollo.MutationFunction; + +/** + * __useSendInvitationsMutation__ + * + * To run a mutation, you first call `useSendInvitationsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useSendInvitationsMutation` 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 [sendInvitationsMutation, { data, loading, error }] = useSendInvitationsMutation({ + * variables: { + * emails: // value for 'emails' + * }, + * }); + */ +export function useSendInvitationsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SendInvitationsDocument, options); + } +export type SendInvitationsMutationHookResult = ReturnType; +export type SendInvitationsMutationResult = Apollo.MutationResult; +export type SendInvitationsMutationOptions = Apollo.BaseMutationOptions; +export const GetWorkspaceInvitationsDocument = gql` + query GetWorkspaceInvitations { + findWorkspaceInvitations { + id + email + expiresAt + } +} + `; + +/** + * __useGetWorkspaceInvitationsQuery__ + * + * To run a query within a React component, call `useGetWorkspaceInvitationsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetWorkspaceInvitationsQuery` 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 } = useGetWorkspaceInvitationsQuery({ + * variables: { + * }, + * }); + */ +export function useGetWorkspaceInvitationsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetWorkspaceInvitationsDocument, options); + } +export function useGetWorkspaceInvitationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetWorkspaceInvitationsDocument, options); + } +export type GetWorkspaceInvitationsQueryHookResult = ReturnType; +export type GetWorkspaceInvitationsLazyQueryHookResult = ReturnType; +export type GetWorkspaceInvitationsQueryResult = Apollo.QueryResult; export const AddUserToWorkspaceDocument = gql` mutation AddUserToWorkspace($inviteHash: String!) { addUserToWorkspace(inviteHash: $inviteHash) { @@ -2861,6 +3068,39 @@ export function useAddUserToWorkspaceMutation(baseOptions?: Apollo.MutationHookO export type AddUserToWorkspaceMutationHookResult = ReturnType; export type AddUserToWorkspaceMutationResult = Apollo.MutationResult; export type AddUserToWorkspaceMutationOptions = Apollo.BaseMutationOptions; +export const AddUserToWorkspaceByInviteTokenDocument = gql` + mutation AddUserToWorkspaceByInviteToken($inviteToken: String!) { + addUserToWorkspaceByInviteToken(inviteToken: $inviteToken) { + id + } +} + `; +export type AddUserToWorkspaceByInviteTokenMutationFn = Apollo.MutationFunction; + +/** + * __useAddUserToWorkspaceByInviteTokenMutation__ + * + * To run a mutation, you first call `useAddUserToWorkspaceByInviteTokenMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useAddUserToWorkspaceByInviteTokenMutation` 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 [addUserToWorkspaceByInviteTokenMutation, { data, loading, error }] = useAddUserToWorkspaceByInviteTokenMutation({ + * variables: { + * inviteToken: // value for 'inviteToken' + * }, + * }); + */ +export function useAddUserToWorkspaceByInviteTokenMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(AddUserToWorkspaceByInviteTokenDocument, options); + } +export type AddUserToWorkspaceByInviteTokenMutationHookResult = ReturnType; +export type AddUserToWorkspaceByInviteTokenMutationResult = Apollo.MutationResult; +export type AddUserToWorkspaceByInviteTokenMutationOptions = Apollo.BaseMutationOptions; export const ActivateWorkspaceDocument = gql` mutation ActivateWorkspace($input: ActivateWorkspaceInput!) { activateWorkspace(data: $input) { @@ -2926,39 +3166,6 @@ export function useDeleteCurrentWorkspaceMutation(baseOptions?: Apollo.MutationH export type DeleteCurrentWorkspaceMutationHookResult = ReturnType; export type DeleteCurrentWorkspaceMutationResult = Apollo.MutationResult; export type DeleteCurrentWorkspaceMutationOptions = Apollo.BaseMutationOptions; -export const SendInviteLinkDocument = gql` - mutation SendInviteLink($emails: [String!]!) { - sendInviteLink(emails: $emails) { - success - } -} - `; -export type SendInviteLinkMutationFn = Apollo.MutationFunction; - -/** - * __useSendInviteLinkMutation__ - * - * To run a mutation, you first call `useSendInviteLinkMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useSendInviteLinkMutation` 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 [sendInviteLinkMutation, { data, loading, error }] = useSendInviteLinkMutation({ - * variables: { - * emails: // value for 'emails' - * }, - * }); - */ -export function useSendInviteLinkMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(SendInviteLinkDocument, options); - } -export type SendInviteLinkMutationHookResult = ReturnType; -export type SendInviteLinkMutationResult = Apollo.MutationResult; -export type SendInviteLinkMutationOptions = Apollo.BaseMutationOptions; export const UpdateWorkspaceDocument = gql` mutation UpdateWorkspace($input: UpdateWorkspaceInput!) { updateWorkspace(data: $input) { diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts index 85285b7769..57499f773f 100644 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts @@ -5,12 +5,14 @@ export const SIGN_UP = gql` $email: String! $password: String! $workspaceInviteHash: String + $workspacePersonalInviteToken: String = null $captchaToken: String ) { signUp( email: $email password: $password workspaceInviteHash: $workspaceInviteHash + workspacePersonalInviteToken: $workspacePersonalInviteToken captchaToken: $captchaToken ) { loginToken { diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index fef424a8fe..677932161d 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -264,6 +264,7 @@ export const useAuth = () => { email: string, password: string, workspaceInviteHash?: string, + workspacePersonalInviteToken?: string, captchaToken?: string, ) => { setIsVerifyPendingState(true); @@ -273,6 +274,7 @@ export const useAuth = () => { email, password, workspaceInviteHash, + workspacePersonalInviteToken, captchaToken, }, }); @@ -296,21 +298,43 @@ export const useAuth = () => { [setIsVerifyPendingState, signUp, handleVerify], ); - const handleGoogleLogin = useCallback((workspaceInviteHash?: string) => { + const buildRedirectUrl = ( + path: string, + params: { + workspacePersonalInviteToken?: string; + workspaceInviteHash?: string; + }, + ) => { const authServerUrl = REACT_APP_SERVER_BASE_URL; - window.location.href = - `${authServerUrl}/auth/google/${ - workspaceInviteHash ? '?inviteHash=' + workspaceInviteHash : '' - }` || ''; - }, []); + const url = new URL(`${authServerUrl}${path}`); + if (isDefined(params.workspaceInviteHash)) { + url.searchParams.set('inviteHash', params.workspaceInviteHash); + } + if (isDefined(params.workspacePersonalInviteToken)) { + url.searchParams.set('inviteToken', params.workspacePersonalInviteToken); + } + return url.toString(); + }; - const handleMicrosoftLogin = useCallback((workspaceInviteHash?: string) => { - const authServerUrl = REACT_APP_SERVER_BASE_URL; - window.location.href = - `${authServerUrl}/auth/microsoft/${ - workspaceInviteHash ? '?inviteHash=' + workspaceInviteHash : '' - }` || ''; - }, []); + const handleGoogleLogin = useCallback( + (params: { + workspacePersonalInviteToken?: string; + workspaceInviteHash?: string; + }) => { + window.location.href = buildRedirectUrl('/auth/google', params); + }, + [], + ); + + const handleMicrosoftLogin = useCallback( + (params: { + workspacePersonalInviteToken?: string; + workspaceInviteHash?: string; + }) => { + window.location.href = buildRedirectUrl('/auth/microsoft', params); + }, + [], + ); return { challenge: handleChallenge, 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 24111466a6..48c66f54ed 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,6 +1,6 @@ import { useCallback, useState } from 'react'; import { SubmitHandler, UseFormReturn } from 'react-hook-form'; -import { useParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; @@ -29,6 +29,9 @@ export const useSignInUp = (form: UseFormReturn
) => { const isMatchingLocation = useIsMatchingLocation(); const workspaceInviteHash = useParams().workspaceInviteHash; + const [searchParams] = useSearchParams(); + const workspacePersonalInviteToken = + searchParams.get('inviteToken') ?? undefined; const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite)); @@ -112,6 +115,7 @@ export const useSignInUp = (form: UseFormReturn) => { data.email.toLowerCase().trim(), data.password, workspaceInviteHash, + workspacePersonalInviteToken, token, ); } catch (err: any) { @@ -128,6 +132,7 @@ export const useSignInUp = (form: UseFormReturn) => { signInWithCredentials, signUpWithCredentials, workspaceInviteHash, + workspacePersonalInviteToken, enqueueSnackBar, requestFreshCaptchaToken, ], 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 index 8eb008b6f3..58ce165f75 100644 --- 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 @@ -1,9 +1,15 @@ -import { useParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import { useAuth } from '@/auth/hooks/useAuth'; export const useSignInWithGoogle = () => { const workspaceInviteHash = useParams().workspaceInviteHash; + const [searchParams] = useSearchParams(); + const workspacePersonalInviteToken = + searchParams.get('inviteToken') ?? undefined; const { signInWithGoogle } = useAuth(); - return { signInWithGoogle: () => signInWithGoogle(workspaceInviteHash) }; + return { + signInWithGoogle: () => + signInWithGoogle({ workspaceInviteHash, workspacePersonalInviteToken }), + }; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithMicrosoft.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithMicrosoft.ts index 444bff19d3..2f471c176c 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithMicrosoft.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithMicrosoft.ts @@ -1,11 +1,18 @@ -import { useParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import { useAuth } from '@/auth/hooks/useAuth'; export const useSignInWithMicrosoft = () => { const workspaceInviteHash = useParams().workspaceInviteHash; + const [searchParams] = useSearchParams(); + const workspacePersonalInviteToken = + searchParams.get('inviteToken') ?? undefined; const { signInWithMicrosoft } = useAuth(); return { - signInWithMicrosoft: () => signInWithMicrosoft(workspaceInviteHash), + signInWithMicrosoft: () => + signInWithMicrosoft({ + workspaceInviteHash, + workspacePersonalInviteToken, + }), }; }; 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 index feee086ef7..a51365b98a 100644 --- 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 @@ -7,6 +7,7 @@ import { AppPath } from '@/types/AppPath'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { isDefaultLayoutAuthModalVisibleState } from '@/ui/layout/states/isDefaultLayoutAuthModalVisibleState'; + import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts index 4d2a9bdc82..5659c41a72 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts @@ -15,7 +15,7 @@ export const useObjectMetadataItem = ({ }: ObjectMetadataItemIdentifier) => { const currentWorkspace = useRecoilValue(currentWorkspaceState); - // Todo: deprecate this logic as mocked objectMetadataItems are laod in ObjectMetadataItemsLoadEffect anyway + // Todo: deprecate this logic as mocked objectMetadataItems are load in ObjectMetadataItemsLoadEffect anyway const mockObjectMetadataItems = getObjectMetadataItemsMock(); let objectMetadataItem = useRecoilValue( diff --git a/packages/twenty-front/src/modules/ui/layout/table/components/TableRow.tsx b/packages/twenty-front/src/modules/ui/layout/table/components/TableRow.tsx index 486dafaa03..a73b21d956 100644 --- a/packages/twenty-front/src/modules/ui/layout/table/components/TableRow.tsx +++ b/packages/twenty-front/src/modules/ui/layout/table/components/TableRow.tsx @@ -9,12 +9,13 @@ const StyledTableRow = styled('div', { isSelected?: boolean; onClick?: () => void; to?: string; + gridAutoColumns?: string; }>` background-color: ${({ isSelected, theme }) => isSelected ? theme.accent.quaternary : 'transparent'}; border-radius: ${({ theme }) => theme.border.radius.sm}; display: grid; - grid-auto-columns: 1fr; + grid-auto-columns: ${({ gridAutoColumns }) => gridAutoColumns ?? '1fr'}; grid-auto-flow: column; transition: background-color ${({ theme }) => theme.animation.duration.normal}s; @@ -33,6 +34,7 @@ type TableRowProps = { onClick?: () => void; to?: string; className?: string; + gridAutoColumns?: string; }; export const TableRow = ({ @@ -41,10 +43,12 @@ export const TableRow = ({ to, className, children, + gridAutoColumns, }: React.PropsWithChildren) => ( { + const [sendInvitationsMutation] = useSendInvitationsMutation(); + + const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState); + + const sendInvitation = async (emails: SendInvitationsMutationVariables) => { + return await sendInvitationsMutation({ + variables: emails, + onCompleted: (data) => { + setWorkspaceInvitations((workspaceInvitations) => [ + ...workspaceInvitations, + ...data.sendInvitations.result, + ]); + }, + }); + }; + + return { + sendInvitation, + }; +}; diff --git a/packages/twenty-front/src/modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation.ts b/packages/twenty-front/src/modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation.ts new file mode 100644 index 0000000000..5731548c1d --- /dev/null +++ b/packages/twenty-front/src/modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation.ts @@ -0,0 +1,34 @@ +import { useSetRecoilState } from 'recoil'; +import { + DeleteWorkspaceInvitationMutationVariables, + useDeleteWorkspaceInvitationMutation, +} from '~/generated/graphql'; +import { workspaceInvitationsState } from '../states/workspaceInvitationsStates'; + +export const useDeleteWorkspaceInvitation = () => { + const [deleteWorkspaceInvitationMutation] = + useDeleteWorkspaceInvitationMutation(); + + const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState); + + const deleteWorkspaceInvitation = async ({ + appTokenId, + }: DeleteWorkspaceInvitationMutationVariables) => { + return await deleteWorkspaceInvitationMutation({ + variables: { + appTokenId, + }, + onCompleted: () => { + setWorkspaceInvitations((workspaceInvitations) => + workspaceInvitations.filter( + (workspaceInvitation) => workspaceInvitation.id !== appTokenId, + ), + ); + }, + }); + }; + + return { + deleteWorkspaceInvitation, + }; +}; diff --git a/packages/twenty-front/src/modules/workspace-invitation/hooks/useResendWorkspaceInvitation.ts b/packages/twenty-front/src/modules/workspace-invitation/hooks/useResendWorkspaceInvitation.ts new file mode 100644 index 0000000000..acda3cffd6 --- /dev/null +++ b/packages/twenty-front/src/modules/workspace-invitation/hooks/useResendWorkspaceInvitation.ts @@ -0,0 +1,35 @@ +import { useSetRecoilState } from 'recoil'; +import { + ResendWorkspaceInvitationMutationVariables, + useResendWorkspaceInvitationMutation, +} from '~/generated/graphql'; +import { workspaceInvitationsState } from '../states/workspaceInvitationsStates'; + +export const useResendWorkspaceInvitation = () => { + const [resendWorkspaceInvitationMutation] = + useResendWorkspaceInvitationMutation(); + + const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState); + + const resendInvitation = async ({ + appTokenId, + }: ResendWorkspaceInvitationMutationVariables) => { + return await resendWorkspaceInvitationMutation({ + variables: { + appTokenId, + }, + onCompleted: (data) => { + setWorkspaceInvitations((workspaceInvitations) => [ + ...data.resendWorkspaceInvitation.result, + ...workspaceInvitations.filter( + (workspaceInvitation) => workspaceInvitation.id !== appTokenId, + ), + ]); + }, + }); + }; + + return { + resendInvitation, + }; +}; diff --git a/packages/twenty-front/src/modules/workspace-invitation/states/workspaceInvitationsStates.ts b/packages/twenty-front/src/modules/workspace-invitation/states/workspaceInvitationsStates.ts new file mode 100644 index 0000000000..8f550a1ad2 --- /dev/null +++ b/packages/twenty-front/src/modules/workspace-invitation/states/workspaceInvitationsStates.ts @@ -0,0 +1,9 @@ +import { createState } from 'twenty-ui'; +import { WorkspaceInvitation } from '@/workspace-member/types/WorkspaceMember'; + +export const workspaceInvitationsState = createState< + Omit[] +>({ + key: 'workspaceInvitationsState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/workspace-member/grapqhql/mutations/addUserToWorkspaceByInviteToken.ts b/packages/twenty-front/src/modules/workspace-member/grapqhql/mutations/addUserToWorkspaceByInviteToken.ts new file mode 100644 index 0000000000..4850c10596 --- /dev/null +++ b/packages/twenty-front/src/modules/workspace-member/grapqhql/mutations/addUserToWorkspaceByInviteToken.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const ADD_USER_TO_WORKSPACE_BY_INVITE_TOKEN = gql` + mutation AddUserToWorkspaceByInviteToken($inviteToken: String!) { + addUserToWorkspaceByInviteToken(inviteToken: $inviteToken) { + id + } + } +`; diff --git a/packages/twenty-front/src/modules/workspace-member/types/WorkspaceMember.ts b/packages/twenty-front/src/modules/workspace-member/types/WorkspaceMember.ts index 46365c9349..ded69618f0 100644 --- a/packages/twenty-front/src/modules/workspace-member/types/WorkspaceMember.ts +++ b/packages/twenty-front/src/modules/workspace-member/types/WorkspaceMember.ts @@ -24,3 +24,10 @@ export type WorkspaceMember = { dateFormat?: WorkspaceMemberDateFormatEnum | null; timeFormat?: WorkspaceMemberTimeFormatEnum | null; }; + +export type WorkspaceInvitation = { + __typename: 'WorkspaceInvitation'; + id: string; + email: string; + expiresAt: string; +}; diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx index 3bb6bbc024..1f5b277abe 100644 --- a/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx @@ -1,9 +1,9 @@ -import { useEffect } from 'react'; -import { Controller, useForm } from 'react-hook-form'; import styled from '@emotion/styled'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; import { Key } from 'ts-key-enum'; -import { IconMail, IconSend } from 'twenty-ui'; +import { IconSend } from 'twenty-ui'; import { z } from 'zod'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; @@ -11,12 +11,13 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { Button } from '@/ui/input/button/components/Button'; import { TextInput } from '@/ui/input/components/TextInput'; import { sanitizeEmailList } from '@/workspace/utils/sanitizeEmailList'; -import { useSendInviteLinkMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; +import { useCreateWorkspaceInvitation } from '../../workspace-invitation/hooks/useCreateWorkspaceInvitation'; const StyledContainer = styled.div` display: flex; flex-direction: row; + padding-bottom: ${({ theme }) => theme.spacing(3)}; `; const StyledLinkContainer = styled.div` @@ -69,7 +70,7 @@ type FormInput = { export const WorkspaceInviteTeam = () => { const { enqueueSnackBar } = useSnackBar(); - const [sendInviteLink] = useSendInviteLinkMutation(); + const { sendInvitation } = useCreateWorkspaceInvitation(); const { reset, handleSubmit, control, formState } = useForm({ mode: 'onSubmit', @@ -79,16 +80,27 @@ export const WorkspaceInviteTeam = () => { }, }); - const submit = handleSubmit(async (data) => { - const emailsList = sanitizeEmailList(data.emails.split(',')); - const result = await sendInviteLink({ variables: { emails: emailsList } }); - if (isDefined(result.errors)) { - throw result.errors; + const submit = handleSubmit(async ({ emails }) => { + const emailsList = sanitizeEmailList(emails.split(',')); + const { data } = await sendInvitation({ emails: emailsList }); + if (isDefined(data) && data.sendInvitations.result.length > 0) { + enqueueSnackBar( + `${data.sendInvitations.result.length} invitations sent`, + { + variant: SnackBarVariant.Success, + duration: 2000, + }, + ); + return; + } + if (isDefined(data) && !data.sendInvitations.success) { + data.sendInvitations.errors.forEach((error) => { + enqueueSnackBar(error, { + variant: SnackBarVariant.Error, + duration: 5000, + }); + }); } - enqueueSnackBar('Invite link sent to email addresses', { - variant: SnackBarVariant.Success, - duration: 2000, - }); }); const handleKeyDown = (e: React.KeyboardEvent) => { @@ -116,7 +128,6 @@ export const WorkspaceInviteTeam = () => { return ( theme.background.secondary}; - border: 1px solid ${({ theme }) => theme.border.color.medium}; - border-radius: ${({ theme }) => theme.spacing(2)}; - display: flex; - flex-direction: row; - margin-bottom: ${({ theme }) => theme.spacing(0)}; - margin-top: ${({ theme }) => theme.spacing(4)}; - padding: ${({ theme }) => theme.spacing(3)}; -`; - -const StyledContent = styled.div` - display: flex; - flex: 1; - flex-direction: column; - justify-content: center; - margin-left: ${({ theme }) => theme.spacing(3)}; - overflow: auto; -`; - -const StyledEmailText = styled.span` - color: ${({ theme }) => theme.font.color.tertiary}; -`; - -type WorkspaceMemberCardProps = { - workspaceMember: WorkspaceMember; - accessory?: React.ReactNode; -}; - -export const WorkspaceMemberCard = ({ - workspaceMember, - accessory, -}: WorkspaceMemberCardProps) => ( - - - - - {workspaceMember.userEmail} - - {accessory} - -); diff --git a/packages/twenty-front/src/modules/workspace/graphql/mutations/sendInviteLink.ts b/packages/twenty-front/src/modules/workspace/graphql/mutations/sendInviteLink.ts deleted file mode 100644 index c34f4734c4..0000000000 --- a/packages/twenty-front/src/modules/workspace/graphql/mutations/sendInviteLink.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { gql } from '@apollo/client'; - -export const SEND_INVITE_LINK = gql` - mutation SendInviteLink($emails: [String!]!) { - sendInviteLink(emails: $emails) { - success - } - } -`; diff --git a/packages/twenty-front/src/pages/auth/Invite.tsx b/packages/twenty-front/src/pages/auth/Invite.tsx index 177758afa1..7260a6b590 100644 --- a/packages/twenty-front/src/pages/auth/Invite.tsx +++ b/packages/twenty-front/src/pages/auth/Invite.tsx @@ -13,8 +13,12 @@ import { Loader } from '@/ui/feedback/loader/components/Loader'; import { MainButton } from '@/ui/input/button/components/MainButton'; import { useWorkspaceSwitching } from '@/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching'; import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn'; -import { useAddUserToWorkspaceMutation } from '~/generated/graphql'; +import { + useAddUserToWorkspaceMutation, + useAddUserToWorkspaceByInviteTokenMutation, +} from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; +import { useSearchParams } from 'react-router-dom'; const StyledContentContainer = styled.div` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -24,26 +28,40 @@ const StyledContentContainer = styled.div` export const Invite = () => { const { workspace: workspaceFromInviteHash, workspaceInviteHash } = useWorkspaceFromInviteHash(); + const { form } = useSignInUpForm(); const currentWorkspace = useRecoilValue(currentWorkspaceState); const [addUserToWorkspace] = useAddUserToWorkspaceMutation(); + const [addUserToWorkspaceByInviteToken] = + useAddUserToWorkspaceByInviteTokenMutation(); const { switchWorkspace } = useWorkspaceSwitching(); + const [searchParams] = useSearchParams(); + const workspaceInviteToken = searchParams.get('inviteToken'); const title = useMemo(() => { return `Join ${workspaceFromInviteHash?.displayName ?? ''} team`; }, [workspaceFromInviteHash?.displayName]); const handleUserJoinWorkspace = async () => { - if ( - !(isDefined(workspaceInviteHash) && isDefined(workspaceFromInviteHash)) + if (isDefined(workspaceInviteToken) && isDefined(workspaceFromInviteHash)) { + await addUserToWorkspaceByInviteToken({ + variables: { + inviteToken: workspaceInviteToken, + }, + }); + } else if ( + isDefined(workspaceInviteHash) && + isDefined(workspaceFromInviteHash) ) { + await addUserToWorkspace({ + variables: { + inviteHash: workspaceInviteHash, + }, + }); + } else { return; } - await addUserToWorkspace({ - variables: { - inviteHash: workspaceInviteHash, - }, - }); + await switchWorkspace(workspaceFromInviteHash.id); }; diff --git a/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx b/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx index 53a7b21249..6259bb3638 100644 --- a/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx +++ b/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx @@ -27,11 +27,9 @@ import { MainButton } from '@/ui/input/button/components/MainButton'; import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import { AnimatedTranslation } from '@/ui/utilities/animation/components/AnimatedTranslation'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { - OnboardingStatus, - useSendInviteLinkMutation, -} from '~/generated/graphql'; +import { OnboardingStatus } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; +import { useCreateWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useCreateWorkspaceInvitation'; const StyledAnimatedContainer = styled.div` display: flex; @@ -65,7 +63,8 @@ type FormInput = z.infer; export const InviteTeam = () => { const theme = useTheme(); const { enqueueSnackBar } = useSnackBar(); - const [sendInviteLink] = useSendInviteLinkMutation(); + const { sendInvitation } = useCreateWorkspaceInvitation(); + const setNextOnboardingStatus = useSetNextOnboardingStatus(); const currentUser = useRecoilValue(currentUserState); const currentWorkspace = useRecoilValue(currentWorkspaceState); @@ -134,7 +133,7 @@ export const InviteTeam = () => { .filter((email) => email.length > 0), ), ); - const result = await sendInviteLink({ variables: { emails } }); + const result = await sendInvitation({ emails }); setNextOnboardingStatus(); @@ -148,7 +147,7 @@ export const InviteTeam = () => { }); } }, - [enqueueSnackBar, sendInviteLink, setNextOnboardingStatus], + [enqueueSnackBar, sendInvitation, setNextOnboardingStatus], ); useScopedHotkeys( diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx index d0399a65ca..0079fb8885 100644 --- a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx @@ -1,7 +1,17 @@ import styled from '@emotion/styled'; import { useState } from 'react'; -import { useRecoilValue } from 'recoil'; -import { H2Title, IconTrash, IconUsers } from 'twenty-ui'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { + H2Title, + IconTrash, + IconUsers, + IconReload, + IconMail, + StyledText, + Avatar, +} from 'twenty-ui'; +import { isNonEmptyArray } from '@sniptt/guards'; +import { useTheme } from '@emotion/react'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; @@ -18,7 +28,19 @@ import { Section } from '@/ui/layout/section/components/Section'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; import { WorkspaceInviteLink } from '@/workspace/components/WorkspaceInviteLink'; import { WorkspaceInviteTeam } from '@/workspace/components/WorkspaceInviteTeam'; -import { WorkspaceMemberCard } from '@/workspace/components/WorkspaceMemberCard'; +import { useGetWorkspaceInvitationsQuery } from '~/generated/graphql'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { Table } from '@/ui/layout/table/components/Table'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { workspaceInvitationsState } from '../../modules/workspace-invitation/states/workspaceInvitationsStates'; +import { TableRow } from '../../modules/ui/layout/table/components/TableRow'; +import { TableCell } from '../../modules/ui/layout/table/components/TableCell'; +import { Status } from '../../modules/ui/display/status/components/Status'; +import { formatDistanceToNow } from 'date-fns'; +import { useResendWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useResendWorkspaceInvitation'; +import { isDefined } from '~/utils/isDefined'; +import { useDeleteWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation'; const StyledButtonContainer = styled.div` align-items: center; @@ -27,7 +49,17 @@ const StyledButtonContainer = styled.div` margin-left: ${({ theme }) => theme.spacing(3)}; `; +const StyledTable = styled(Table)` + margin-top: ${({ theme }) => theme.spacing(0.5)}; +`; + +const StyledTableHeaderRow = styled(Table)` + margin-bottom: ${({ theme }) => theme.spacing(1.5)}; +`; + export const SettingsWorkspaceMembers = () => { + const { enqueueSnackBar } = useSnackBar(); + const theme = useTheme(); const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false); const [workspaceMemberToDelete, setWorkspaceMemberToDelete] = useState< string | undefined @@ -39,6 +71,10 @@ export const SettingsWorkspaceMembers = () => { const { deleteOneRecord: deleteOneWorkspaceMember } = useDeleteOneRecord({ objectNameSingular: CoreObjectNameSingular.WorkspaceMember, }); + + const { resendInvitation } = useResendWorkspaceInvitation(); + const { deleteWorkspaceInvitation } = useDeleteWorkspaceInvitation(); + const currentWorkspace = useRecoilValue(currentWorkspaceState); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); @@ -47,6 +83,47 @@ export const SettingsWorkspaceMembers = () => { setIsConfirmationModalOpen(false); }; + const workspaceInvitations = useRecoilValue(workspaceInvitationsState); + const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState); + + useGetWorkspaceInvitationsQuery({ + onError: (error: Error) => { + enqueueSnackBar(error.message, { + variant: SnackBarVariant.Error, + }); + }, + onCompleted: (data) => { + setWorkspaceInvitations(data?.findWorkspaceInvitations ?? []); + }, + }); + + const handleRemoveWorkspaceInvitation = async (appTokenId: string) => { + const result = await deleteWorkspaceInvitation({ appTokenId }); + if (isDefined(result.errors)) { + enqueueSnackBar('Error deleting invitation', { + variant: SnackBarVariant.Error, + duration: 2000, + }); + } + }; + + const handleResendWorkspaceInvitation = async (appTokenId: string) => { + const result = await resendInvitation({ appTokenId }); + if (isDefined(result.errors)) { + enqueueSnackBar('Error resending invitation', { + variant: SnackBarVariant.Error, + duration: 2000, + }); + } + }; + + const getExpiresAtText = (expiresAt: string) => { + const expiresAtDate = new Date(expiresAt); + return expiresAtDate < new Date() + ? 'Expired' + : formatDistanceToNow(new Date(expiresAt)); + }; + return ( { ]} > -
- - -
{currentWorkspace?.inviteHash && (
{ title="Members" description="Manage the members of your space here" /> - {workspaceMembers?.map((member) => ( - - { - setIsConfirmationModalOpen(true); - setWorkspaceMemberToDelete(member.id); - }} - variant="tertiary" - size="medium" - Icon={IconTrash} + + + + Name + Email + + + + {workspaceMembers?.map((workspaceMember) => ( + + + + + } + text={ + workspaceMember.name.firstName + + ' ' + + workspaceMember.name.lastName + } /> - - ) - } - /> - ))} + + + + + + {currentWorkspaceMember?.id !== workspaceMember.id && ( + + { + setIsConfirmationModalOpen(true); + setWorkspaceMemberToDelete(workspaceMember.id); + }} + variant="tertiary" + size="medium" + Icon={IconTrash} + /> + + )} + + + + ))} +
+
+
+ + + {isNonEmptyArray(workspaceInvitations) && ( + + + + Email + Expires in + + + + {workspaceInvitations?.map((workspaceInvitation) => ( + + + + + } + text={workspaceInvitation.email} + /> + + + + + + + { + handleResendWorkspaceInvitation( + workspaceInvitation.id, + ); + }} + variant="tertiary" + size="medium" + Icon={IconReload} + /> + { + handleRemoveWorkspaceInvitation( + workspaceInvitation.id, + ); + }} + variant="tertiary" + size="medium" + Icon={IconTrash} + /> + + + + + ))} +
+ )}
{ + await queryRunner.query( + 'ALTER TABLE core."appToken" ALTER COLUMN "userId" DROP NOT NULL', + ); + + await queryRunner.query( + `ALTER TABLE core."appToken" ADD CONSTRAINT "userIdIsNullWhenTypeIsInvitation" CHECK ("appToken".type != 'INVITATION_TOKEN' OR "appToken"."userId" IS NULL)`, + ); + + await queryRunner.query( + `ALTER TABLE core."appToken" ADD CONSTRAINT "userIdNotNullWhenTypeIsNotInvitation" CHECK ("appToken".type = 'INVITATION_TOKEN' OR "appToken"."userId" NOTNULL)`, + ); + + await queryRunner.query('ALTER TABLE core."appToken" ADD "context" jsonb'); + + await queryRunner.query( + 'CREATE UNIQUE INDEX apptoken_unique_invitation_by_user_workspace ON core."appToken" ("workspaceId", ("context" ->> \'email\')) WHERE type = \'INVITATION_TOKEN\';', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX core.apptoken_unique_invitation_by_user_workspace;`, + ); + + await queryRunner.query( + 'DELETE FROM "core"."appToken" WHERE "userId" IS NULL', + ); + + await queryRunner.query( + 'ALTER TABLE core."appToken" DROP CONSTRAINT "userIdIsNullWhenTypeIsInvitation"', + ); + + await queryRunner.query( + 'ALTER TABLE core."appToken" DROP CONSTRAINT "userIdNotNullWhenTypeIsNotInvitation"', + ); + + await queryRunner.query( + 'ALTER TABLE core."appToken" DROP COLUMN "context"', + ); + + await queryRunner.query( + 'ALTER TABLE core."appToken" ALTER COLUMN "userId" SET NOT NULL', + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index 062aebe47c..5725313081 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -14,7 +14,7 @@ import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; import { useThrottler } from 'src/engine/api/graphql/graphql-config/hooks/use-throttler'; import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts index f7bba35bb5..4f478565eb 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts @@ -35,11 +35,15 @@ export class GraphqlQueryFindOneResolverService { ): Promise { const { authContext, objectMetadataItem, info, objectMetadataCollection } = options; - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( + const dataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace( authContext.workspace.id, - objectMetadataItem.nameSingular, ); + + const repository = await dataSource.getRepository( + objectMetadataItem.nameSingular, + ); + const objectMetadataMap = generateObjectMetadataMap( objectMetadataCollection, ); @@ -89,6 +93,7 @@ export class GraphqlQueryFindOneResolverService { relations, limit, authContext, + dataSource, ); } diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts index 1c30fa89df..43c9b0198b 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts @@ -18,7 +18,7 @@ import { computeDepth } from 'src/engine/api/rest/core/query-builder/utils/compu import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-batch-path.utils'; import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; import { Query } from 'src/engine/api/rest/core/types/query.type'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; diff --git a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts index afd0e59efd..29f1178948 100644 --- a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts +++ b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts @@ -2,12 +2,12 @@ import { Injectable } from '@nestjs/common'; import { Request } from 'express'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory'; import { GraphqlApiType, RestApiService, } from 'src/engine/api/rest/rest-api.service'; -import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; @Injectable() export class RestApiMetadataService { diff --git a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts index 010effe4d7..998fa634a7 100644 --- a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts @@ -21,6 +21,7 @@ export enum AppTokenType { CodeChallenge = 'CODE_CHALLENGE', AuthorizationCode = 'AUTHORIZATION_CODE', PasswordResetToken = 'PASSWORD_RESET_TOKEN', + InvitationToken = 'INVITATION_TOKEN', } @Entity({ name: 'appToken', schema: 'core' }) @@ -37,8 +38,8 @@ export class AppToken { @JoinColumn({ name: 'userId' }) user: Relation; - @Column() - userId: string; + @Column({ nullable: true }) + userId: string | null; @ManyToOne(() => Workspace, (workspace) => workspace.appTokens, { onDelete: 'CASCADE', @@ -73,4 +74,7 @@ export class AppToken { @Field() @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; + + @Column({ nullable: true, type: 'jsonb' }) + context: { email: string } | null; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index e2563b888e..1560c7f97c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -12,7 +12,8 @@ import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controller import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; @@ -50,6 +51,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; ConnectedAccountWorkspaceEntity, ]), HttpModule, + TokenModule, UserWorkspaceModule, WorkspaceModule, OnboardingModule, @@ -65,9 +67,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; providers: [ SignInUpService, AuthService, - TokenService, JwtAuthStrategy, AuthResolver, + TokenService, GoogleAPIsService, AppTokenService, ], diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts index cca5dc1deb..2d08a6b4ea 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts @@ -1,17 +1,17 @@ +import { CanActivate } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { CanActivate } from '@nestjs/common'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { UserService } from 'src/engine/core-modules/user/services/user.service'; -import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; -import { User } from 'src/engine/core-modules/user/user.entity'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthResolver } from './auth.resolver'; -import { TokenService } from './services/token.service'; import { AuthService } from './services/auth.service'; +import { TokenService } from './token/services/token.service'; describe('AuthResolver', () => { let resolver: AuthResolver; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 7276749d3d..033210af12 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -37,7 +37,7 @@ import { VerifyInput } from './dto/verify.input'; import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity'; import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input'; import { AuthService } from './services/auth.service'; -import { TokenService } from './services/token.service'; +import { TokenService } from './token/services/token.service'; @Resolver() @UseFilters(AuthGraphqlApiExceptionFilter) diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index 330e382b27..615d4c6071 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -17,10 +17,10 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters import { GoogleAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard'; import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type'; -import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; @Controller('auth/google-apis') @UseFilters(AuthRestApiExceptionFilter) diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index 42674953ed..6ae9b11d74 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -13,8 +13,8 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters import { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oauth.guard'; import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/google-provider-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; @Controller('auth/google') @UseFilters(AuthRestApiExceptionFilter) @@ -34,8 +34,14 @@ export class GoogleAuthController { @Get('redirect') @UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard) async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) { - const { firstName, lastName, email, picture, workspaceInviteHash } = - req.user; + const { + firstName, + lastName, + email, + picture, + workspaceInviteHash, + workspacePersonalInviteToken, + } = req.user; const user = await this.authService.signInUp({ email, @@ -43,6 +49,7 @@ export class GoogleAuthController { lastName, picture, workspaceInviteHash, + workspacePersonalInviteToken, fromSSO: true, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index 62f3364b6b..49fa5384b3 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -14,8 +14,8 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard'; import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; @Controller('auth/microsoft') @UseFilters(AuthRestApiExceptionFilter) @@ -39,8 +39,14 @@ export class MicrosoftAuthController { @Req() req: MicrosoftRequest, @Res() res: Response, ) { - const { firstName, lastName, email, picture, workspaceInviteHash } = - req.user; + const { + firstName, + lastName, + email, + picture, + workspaceInviteHash, + workspacePersonalInviteToken, + } = req.user; const user = await this.authService.signInUp({ email, @@ -48,6 +54,7 @@ export class MicrosoftAuthController { lastName, picture, workspaceInviteHash, + workspacePersonalInviteToken, fromSSO: true, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts index 0aef1bea10..a73754fbdd 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { VerifyAuthController } from './verify-auth.controller'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts index 40869c5f7d..25a52dc3b2 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts @@ -4,7 +4,7 @@ import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input'; import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; @Controller('auth/verify') @UseFilters(AuthRestApiExceptionFilter) diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts index 53a9a47880..4d952e0f2d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts @@ -19,6 +19,11 @@ export class SignUpInput { @IsOptional() workspaceInviteHash?: string; + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + workspacePersonalInviteToken?: string; + @Field(() => String, { nullable: true }) @IsString() @IsOptional() diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/workspace-invite-token.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/workspace-invite-token.input.ts new file mode 100644 index 0000000000..756300a964 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/workspace-invite-token.input.ts @@ -0,0 +1,12 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; + +@ArgsType() +export class WorkspaceInviteTokenInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + @MinLength(10) + inviteToken: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts index c7185ff883..dd9fbf17f2 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts @@ -12,11 +12,20 @@ export class GoogleOauthGuard extends AuthGuard('google') { async canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); const workspaceInviteHash = request.query.inviteHash; + const workspacePersonalInviteToken = request.query.inviteToken; if (workspaceInviteHash && typeof workspaceInviteHash === 'string') { request.params.workspaceInviteHash = workspaceInviteHash; } + if ( + workspacePersonalInviteToken && + typeof workspacePersonalInviteToken === 'string' + ) { + request.params.workspacePersonalInviteToken = + workspacePersonalInviteToken; + } + return (await super.canActivate(context)) as boolean; } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts index 44f084a26c..dd67b67683 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts @@ -12,11 +12,20 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { async canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); const workspaceInviteHash = request.query.inviteHash; + const workspacePersonalInviteToken = request.query.inviteToken; if (workspaceInviteHash && typeof workspaceInviteHash === 'string') { request.params.workspaceInviteHash = workspaceInviteHash; } + if ( + workspacePersonalInviteToken && + typeof workspacePersonalInviteToken === 'string' + ) { + request.params.workspacePersonalInviteToken = + workspacePersonalInviteToken; + } + return (await super.canActivate(context)) as boolean; } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts index 7b4f1309a3..f52023891f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts @@ -1,17 +1,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { UserService } from 'src/engine/core-modules/user/services/user.service'; -import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { User } from 'src/engine/core-modules/user/user.entity'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { EmailService } from 'src/engine/core-modules/email/email.service'; -import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { AuthService } from './auth.service'; -import { TokenService } from './token.service'; describe('AuthService', () => { let service: AuthService; diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index c3a604e149..bba83839a3 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -32,14 +32,13 @@ import { UserExists } from 'src/engine/core-modules/auth/dto/user-exists.entity' import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { TokenService } from './token.service'; - @Injectable() export class AuthService { constructor( @@ -94,6 +93,7 @@ export class AuthService { email, password, workspaceInviteHash, + workspacePersonalInviteToken, firstName, lastName, picture, @@ -104,6 +104,7 @@ export class AuthService { firstName?: string | null; lastName?: string | null; workspaceInviteHash?: string | null; + workspacePersonalInviteToken?: string | null; picture?: string | null; fromSSO: boolean; }) { @@ -113,6 +114,7 @@ export class AuthService { firstName, lastName, workspaceInviteHash, + workspacePersonalInviteToken, picture, fromSSO, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index eb2974d9d5..639f6cb689 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -9,6 +9,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; describe('SignInUpService', () => { let service: SignInUpService; @@ -29,6 +30,10 @@ describe('SignInUpService', () => { provide: getRepositoryToken(User, 'core'), useValue: {}, }, + { + provide: getRepositoryToken(AppToken, 'core'), + useValue: {}, + }, { provide: UserWorkspaceService, useValue: {}, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 63286c3727..6e0e962038 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import FileType from 'file-type'; import { Repository } from 'typeorm'; import { v4 } from 'uuid'; +import { isDefined } from 'class-validator'; import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; @@ -27,6 +28,7 @@ import { } from 'src/engine/core-modules/workspace/workspace.entity'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { getImageBufferFromUrl } from 'src/utils/image'; +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; export type SignInUpServiceInput = { email: string; @@ -34,6 +36,7 @@ export type SignInUpServiceInput = { firstName?: string | null; lastName?: string | null; workspaceInviteHash?: string | null; + workspacePersonalInviteToken?: string | null; picture?: string | null; fromSSO: boolean; }; @@ -45,6 +48,8 @@ export class SignInUpService { private readonly fileUploadService: FileUploadService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, + @InjectRepository(AppToken, 'core') + private readonly appTokenRepository: Repository, @InjectRepository(User, 'core') private readonly userRepository: Repository, private readonly userWorkspaceService: UserWorkspaceService, @@ -56,6 +61,7 @@ export class SignInUpService { async signInUp({ email, workspaceInviteHash, + workspacePersonalInviteToken, password, firstName, lastName, @@ -111,6 +117,7 @@ export class SignInUpService { email, passwordHash, workspaceInviteHash, + workspacePersonalInviteToken, firstName, lastName, picture, @@ -134,6 +141,7 @@ export class SignInUpService { email, passwordHash, workspaceInviteHash, + workspacePersonalInviteToken, firstName, lastName, picture, @@ -141,19 +149,25 @@ export class SignInUpService { }: { email: string; passwordHash: string | undefined; - workspaceInviteHash: string; + workspaceInviteHash: string | null; + workspacePersonalInviteToken: string | null | undefined; firstName: string; lastName: string; picture: SignInUpServiceInput['picture']; existingUser: User | null; }) { - const workspace = await this.workspaceRepository.findOneBy({ - inviteHash: workspaceInviteHash, + const isNewUser = !isDefined(existingUser); + let user = existingUser; + + const workspace = await this.findWorkspaceAndValidateInvitation({ + workspacePersonalInviteToken, + workspaceInviteHash, + email, }); if (!workspace) { throw new AuthException( - 'Invit hash is invalid', + 'Workspace not found', AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } @@ -165,32 +179,76 @@ export class SignInUpService { ); } - if (existingUser) { - const updatedUser = await this.userWorkspaceService.addUserToWorkspace( - existingUser, - workspace, - ); + if (isNewUser) { + const imagePath = await this.uploadPicture(picture, workspace.id); - return Object.assign(existingUser, updatedUser); + const userToCreate = this.userRepository.create({ + email: email, + firstName: firstName, + lastName: lastName, + defaultAvatarUrl: imagePath, + canImpersonate: false, + passwordHash, + defaultWorkspace: workspace, + }); + + user = await this.userRepository.save(userToCreate); } - const imagePath = await this.uploadPicture(picture, workspace.id); + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } - const userToCreate = this.userRepository.create({ - email: email, - firstName: firstName, - lastName: lastName, - defaultAvatarUrl: imagePath, - canImpersonate: false, - passwordHash, - defaultWorkspace: workspace, - }); + const updatedUser = workspacePersonalInviteToken + ? await this.userWorkspaceService.addUserToWorkspaceByInviteToken( + workspacePersonalInviteToken, + user, + ) + : await this.userWorkspaceService.addUserToWorkspace(user, workspace); - const user = await this.userRepository.save(userToCreate); + if (isNewUser) { + await this.activateOnboardingForNewUser(user, workspace, { + firstName, + lastName, + }); + } - await this.userWorkspaceService.create(user.id, workspace.id); - await this.userWorkspaceService.createWorkspaceMember(workspace.id, user); + return Object.assign(user, updatedUser); + } + private async findWorkspaceAndValidateInvitation({ + workspacePersonalInviteToken, + workspaceInviteHash, + email, + }) { + if (!workspacePersonalInviteToken && !workspaceInviteHash) { + throw new Error('No invite token or hash provided'); + } + + if (!workspacePersonalInviteToken && workspaceInviteHash) { + return ( + (await this.workspaceRepository.findOneBy({ + inviteHash: workspaceInviteHash, + })) ?? undefined + ); + } + + const appToken = await this.userWorkspaceService.validateInvitation( + workspacePersonalInviteToken, + email, + ); + + return appToken?.workspace; + } + + private async activateOnboardingForNewUser( + user: User, + workspace: Workspace, + { firstName, lastName }: { firstName: string; lastName: string }, + ) { await this.onboardingService.setOnboardingConnectAccountPending({ userId: user.id, workspaceId: workspace.id, @@ -204,8 +262,6 @@ export class SignInUpService { value: true, }); } - - return user; } private async signUpOnNewWorkspace({ diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts index 22ba144477..932e4c4e3e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts @@ -16,6 +16,7 @@ export type GoogleRequest = Omit< email: string; picture: string | null; workspaceInviteHash?: string; + workspacePersonalInviteToken?: string; }; }; @@ -36,6 +37,12 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { ...options, state: JSON.stringify({ workspaceInviteHash: req.params.workspaceInviteHash, + ...(req.params.workspacePersonalInviteToken + ? { + workspacePersonalInviteToken: + req.params.workspacePersonalInviteToken, + } + : {}), }), }; @@ -61,6 +68,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { lastName: name.familyName, picture: photos?.[0]?.value, workspaceInviteHash: state.workspaceInviteHash, + workspacePersonalInviteToken: state.workspacePersonalInviteToken, }; done(null, user); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts index 48a097734b..babcf1540b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts @@ -20,6 +20,7 @@ export type MicrosoftRequest = Omit< email: string; picture: string | null; workspaceInviteHash?: string; + workspacePersonalInviteToken?: string; }; }; @@ -40,6 +41,12 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { ...options, state: JSON.stringify({ workspaceInviteHash: req.params.workspaceInviteHash, + ...(req.params.workspacePersonalInviteToken + ? { + workspacePersonalInviteToken: + req.params.workspacePersonalInviteToken, + } + : {}), }), }; @@ -75,6 +82,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { lastName: name.familyName, picture: photos?.[0]?.value, workspaceInviteHash: state.workspaceInviteHash, + workspacePersonalInviteToken: state.workspacePersonalInviteToken, }; done(null, user); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/auth/services/token.service.spec.ts rename to packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts similarity index 96% rename from packages/twenty-server/src/engine/core-modules/auth/services/token.service.ts rename to packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts index 9ff47fe991..c740cb9a06 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts @@ -175,6 +175,33 @@ export class TokenService { }; } + async generateInvitationToken(workspaceId: string, email: string) { + const expiresIn = this.environmentService.get( + 'INVITATION_TOKEN_EXPIRES_IN', + ); + + if (!expiresIn) { + throw new AuthException( + 'Expiration time for invitation token is not set', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const invitationToken = this.appTokenRepository.create({ + workspaceId, + expiresAt, + type: AppTokenType.InvitationToken, + value: crypto.randomBytes(32).toString('hex'), + context: { + email, + }, + }); + + return this.appTokenRepository.save(invitationToken); + } + async generateLoginToken(email: string): Promise { const secret = this.environmentService.get('LOGIN_TOKEN_SECRET'); const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN'); @@ -416,7 +443,7 @@ export class TokenService { }, }); - if (!codeChallengeAppToken) { + if (!codeChallengeAppToken || !codeChallengeAppToken.userId) { throw new AuthException( 'code verifier doesnt match the challenge', AuthExceptionCode.FORBIDDEN_EXCEPTION, @@ -750,7 +777,7 @@ export class TokenService { }, }); - if (!token) { + if (!token || !token.userId) { throw new AuthException( 'Token is invalid', AuthExceptionCode.FORBIDDEN_EXCEPTION, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts new file mode 100644 index 0000000000..ea10468729 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -0,0 +1,26 @@ +/* eslint-disable no-restricted-imports */ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { EmailModule } from 'src/engine/core-modules/email/email.module'; +import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; + +@Module({ + imports: [ + JwtModule, + TypeOrmModule.forFeature([User, AppToken, Workspace], 'core'), + TypeORMModule, + DataSourceModule, + EmailModule, + ], + providers: [TokenService, JwtAuthStrategy], + exports: [TokenService], +}) +export class TokenModule {} diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index c1c6760df8..ff33a896ea 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -13,19 +13,19 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; -import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Module({ imports: [ FeatureFlagModule, StripeModule, - UserWorkspaceModule, TypeOrmModule.forFeature( [ BillingSubscription, BillingSubscriptionItem, Workspace, + UserWorkspace, FeatureFlagEntity, ], 'core', diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts index b63e1fd042..6c031463f6 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts @@ -6,10 +6,10 @@ import { Repository } from 'typeorm'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; -import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { assert } from 'src/utils/assert'; export enum WebhookEvent { @@ -24,10 +24,11 @@ export class BillingPortalWorkspaceService { protected readonly logger = new Logger(BillingPortalWorkspaceService.name); constructor( private readonly stripeService: StripeService, - private readonly userWorkspaceService: UserWorkspaceService, private readonly environmentService: EnvironmentService, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, + @InjectRepository(UserWorkspace, 'core') + private readonly userWorkspaceRepository: Repository, private readonly billingSubscriptionService: BillingSubscriptionService, ) {} @@ -42,8 +43,9 @@ export class BillingPortalWorkspaceService { ? frontBaseUrl + successUrlPath : frontBaseUrl; - const quantity = - (await this.userWorkspaceService.getUserCount(workspace.id)) || 1; + const quantity = await this.userWorkspaceRepository.countBy({ + workspaceId: workspace.id, + }); const stripeCustomerId = ( await this.billingSubscriptionRepository.findOneBy({ diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 501afd4cbd..a78b636e2a 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -39,6 +39,7 @@ import { llmTracingModuleFactory } from 'src/engine/core-modules/llm-tracing/llm import { ServerlessModule } from 'src/engine/core-modules/serverless/serverless.module'; import { serverlessModuleFactory } from 'src/engine/core-modules/serverless/serverless-module.factory'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { FileModule } from './file/file.module'; import { ClientConfigModule } from './client-config/client-config.module'; @@ -59,6 +60,7 @@ import { AnalyticsModule } from './analytics/analytics.module'; TimelineCalendarEventModule, UserModule, WorkspaceModule, + WorkspaceInvitationModule, AISQLQueryModule, PostgresCredentialsModule, WorkflowTriggerApiModule, @@ -114,6 +116,7 @@ import { AnalyticsModule } from './analytics/analytics.module'; TimelineCalendarEventModule, UserModule, WorkspaceModule, + WorkspaceInvitationModule, ], }) export class CoreEngineModule {} diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index c676f15392..c3ecf518a0 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -150,6 +150,10 @@ export class EnvironmentVariables { @IsOptional() FILE_TOKEN_EXPIRES_IN = '1d'; + @IsDuration() + @IsOptional() + INVITATION_TOKEN_EXPIRES_IN = '30d'; + // Auth @IsUrl({ require_tld: false }) @IsOptional() diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts index 7c78a1fd99..71fb7c279e 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts @@ -6,6 +6,7 @@ import { DataSeedDemoWorkspaceModule } from 'src/database/commands/data-seed-dem import { DataSeedDemoWorkspaceJob } from 'src/database/commands/data-seed-demo-workspace/jobs/data-seed-demo-workspace.job'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { WorkspaceQueryRunnerJobModule } from 'src/engine/api/graphql/workspace-query-runner/jobs/workspace-query-runner-job.module'; +import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { UpdateSubscriptionJob } from 'src/engine/core-modules/billing/jobs/update-subscription.job'; import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module'; @@ -39,6 +40,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module'; BillingModule, UserWorkspaceModule, WorkspaceModule, + AuthModule, MessagingModule, CalendarModule, CalendarEventParticipantManagerModule, diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.spec.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.spec.ts index 7e6f2d26e5..d4dce437eb 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.spec.ts @@ -1,9 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; describe('OpenApiService', () => { let service: OpenApiService; diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts index 49e7ed9ac1..4628fef4f2 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts @@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common'; import { Request } from 'express'; import { OpenAPIV3_1 } from 'openapi-types'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils'; import { computeMetadataSchemaComponents, @@ -33,7 +34,6 @@ import { getFindOneResponse200, getUpdateOneResponse200, } from 'src/engine/core-modules/open-api/utils/responses.utils'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { capitalize } from 'src/utils/capitalize'; import { getServerUrl } from 'src/utils/get-server-url'; diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts index 570c1103f8..c7967cd63d 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts @@ -6,18 +6,24 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; -import { User } from 'src/engine/core-modules/user/user.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; @Module({ imports: [ NestjsQueryGraphQLModule.forFeature({ imports: [ - NestjsQueryTypeOrmModule.forFeature([User, UserWorkspace], 'core'), + NestjsQueryTypeOrmModule.forFeature( + [User, UserWorkspace, AppToken], + 'core', + ), TypeORMModule, DataSourceModule, WorkspaceDataSourceModule, + WorkspaceInvitationModule, ], services: [UserWorkspaceService], }), diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts index 738b602417..338b6e9499 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts @@ -11,6 +11,8 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; +import { WorkspaceInviteTokenInput } from 'src/engine/core-modules/auth/dto/workspace-invite-token.input'; @UseGuards(WorkspaceAuthGuard) @Resolver(() => UserWorkspace) @@ -18,9 +20,8 @@ export class UserWorkspaceResolver { constructor( @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, - @InjectRepository(User, 'core') - private readonly userRepository: Repository, private readonly userWorkspaceService: UserWorkspaceService, + private readonly workspaceInvitationService: WorkspaceInvitationService, ) {} @Mutation(() => User) @@ -36,6 +37,22 @@ export class UserWorkspaceResolver { return; } + await this.workspaceInvitationService.invalidateWorkspaceInvitation( + workspace.id, + user.email, + ); + return await this.userWorkspaceService.addUserToWorkspace(user, workspace); } + + @Mutation(() => User) + async addUserToWorkspaceByInviteToken( + @AuthUser() user: User, + @Args() workspaceInviteTokenInput: WorkspaceInviteTokenInput, + ) { + return this.userWorkspaceService.addUserToWorkspaceByInviteToken( + workspaceInviteTokenInput.inviteToken, + user, + ); + } } diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 69972542b1..31fdd6379a 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -13,6 +13,11 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data- import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { assert } from 'src/utils/assert'; +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; export class UserWorkspaceService extends TypeOrmQueryService { constructor( @@ -20,8 +25,11 @@ export class UserWorkspaceService extends TypeOrmQueryService { private readonly userWorkspaceRepository: Repository, @InjectRepository(User, 'core') private readonly userRepository: Repository, + @InjectRepository(AppToken, 'core') + private readonly appTokenRepository: Repository, private readonly dataSourceService: DataSourceService, private readonly typeORMService: TypeORMService, + private readonly workspaceInvitationService: WorkspaceInvitationService, private workspaceEventEmitter: WorkspaceEventEmitter, ) { super(userWorkspaceRepository); @@ -105,6 +113,41 @@ export class UserWorkspaceService extends TypeOrmQueryService { }); } + async validateInvitation(inviteToken: string, email: string) { + const appToken = await this.appTokenRepository.findOne({ + where: { + value: inviteToken, + type: AppTokenType.InvitationToken, + }, + relations: ['workspace'], + }); + + if (!appToken) { + throw new Error('Invalid invitation token'); + } + + if (appToken.context?.email !== email) { + throw new Error('Email does not match the invitation'); + } + + if (new Date(appToken.expiresAt) < new Date()) { + throw new Error('Invitation expired'); + } + + return appToken; + } + + async addUserToWorkspaceByInviteToken(inviteToken: string, user: User) { + const appToken = await this.validateInvitation(inviteToken, user.email); + + await this.workspaceInvitationService.invalidateWorkspaceInvitation( + appToken.workspace.id, + user.email, + ); + + return await this.addUserToWorkspace(user, appToken.workspace); + } + public async getUserCount(workspaceId): Promise { return await this.userWorkspaceRepository.countBy({ workspaceId, @@ -120,4 +163,18 @@ export class UserWorkspaceService extends TypeOrmQueryService { workspaceId, }); } + + async checkUserWorkspaceExistsByEmail(email: string, workspaceId: string) { + return this.userWorkspaceRepository.exists({ + where: { + workspaceId, + user: { + email, + }, + }, + relations: { + user: true, + }, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.input.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/send-invitations.input.ts similarity index 86% rename from packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.input.ts rename to packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/send-invitations.input.ts index ac80111bab..682a970ded 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/send-invitations.input.ts @@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql'; import { ArrayUnique, IsArray, IsEmail } from 'class-validator'; @ArgsType() -export class SendInviteLinkInput { +export class SendInvitationsInput { @Field(() => [String]) @IsArray() @IsEmail({}, { each: true }) diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/send-invitations.output.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/send-invitations.output.ts new file mode 100644 index 0000000000..aa72b2c61c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/send-invitations.output.ts @@ -0,0 +1,17 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { WorkspaceInvitation } from 'src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto'; + +@ObjectType() +export class SendInvitationsOutput { + @Field(() => Boolean, { + description: 'Boolean that confirms query was dispatched', + }) + success: boolean; + + @Field(() => [String]) + errors: Array; + + @Field(() => [WorkspaceInvitation]) + result: Array; +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto.ts new file mode 100644 index 0000000000..76f2f65f5c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto.ts @@ -0,0 +1,17 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('WorkspaceInvitation') +export class WorkspaceInvitation { + @IDField(() => UUIDScalarType) + id: string; + + @Field({ nullable: false }) + email: string; + + @Field({ nullable: false }) + expiresAt: Date; +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts new file mode 100644 index 0000000000..3fce16c4c3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts @@ -0,0 +1,55 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; + +import { WorkspaceInvitationService } from './workspace-invitation.service'; + +describe('WorkspaceInvitationService', () => { + let service: WorkspaceInvitationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceInvitationService, + { + provide: getRepositoryToken(AppToken, 'core'), + useValue: {}, + }, + { + provide: EnvironmentService, + useValue: {}, + }, + { + provide: EmailService, + useValue: {}, + }, + { + provide: TokenService, + useValue: {}, + }, + { + provide: getRepositoryToken(UserWorkspace, 'core'), + useValue: {}, + }, + { + provide: OnboardingService, + useValue: {}, + }, + ], + }).compile(); + + service = module.get( + WorkspaceInvitationService, + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts new file mode 100644 index 0000000000..8c193efefa --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -0,0 +1,293 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { render } from '@react-email/render'; +import { SendInviteLinkEmail } from 'twenty-emails'; +import { IsNull, Repository } from 'typeorm'; + +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { SendInvitationsOutput } from 'src/engine/core-modules/workspace-invitation/dtos/send-invitations.output'; +import { + WorkspaceInvitationException, + WorkspaceInvitationExceptionCode, +} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository +export class WorkspaceInvitationService { + constructor( + @InjectRepository(AppToken, 'core') + private readonly appTokenRepository: Repository, + private readonly environmentService: EnvironmentService, + private readonly emailService: EmailService, + private readonly tokenService: TokenService, + @InjectRepository(UserWorkspace, 'core') + private readonly userWorkspaceRepository: Repository, + private readonly onboardingService: OnboardingService, + ) {} + + private async getOneWorkspaceInvitation(workspaceId: string, email: string) { + return await this.appTokenRepository + .createQueryBuilder('appToken') + .where('"appToken"."workspaceId" = :workspaceId', { + workspaceId, + }) + .andWhere('"appToken".type = :type', { + type: AppTokenType.InvitationToken, + }) + .andWhere('"appToken".context->>\'email\' = :email', { email }) + .getOne(); + } + + castAppTokenToWorkspaceInvitation(appToken: AppToken) { + if (appToken.type !== AppTokenType.InvitationToken) { + throw new WorkspaceInvitationException( + `Token type must be "${AppTokenType.InvitationToken}"`, + WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE, + ); + } + + if (!appToken.context?.email) { + throw new WorkspaceInvitationException( + `Invitation corrupted: Missing email in context`, + WorkspaceInvitationExceptionCode.INVITATION_CORRUPTED, + ); + } + + return { + id: appToken.id, + email: appToken.context.email, + expiresAt: appToken.expiresAt, + }; + } + + async createWorkspaceInvitation(email: string, workspace: Workspace) { + const maybeWorkspaceInvitation = await this.getOneWorkspaceInvitation( + workspace.id, + email.toLowerCase(), + ); + + if (maybeWorkspaceInvitation) { + throw new WorkspaceInvitationException( + `${email} already invited`, + WorkspaceInvitationExceptionCode.INVITATION_ALREADY_EXIST, + ); + } + + const isUserAlreadyInWorkspace = await this.userWorkspaceRepository.exists({ + where: { + workspaceId: workspace.id, + user: { + email, + }, + }, + relations: { + user: true, + }, + }); + + if (isUserAlreadyInWorkspace) { + throw new WorkspaceInvitationException( + `${email} is already in the workspace`, + WorkspaceInvitationExceptionCode.USER_ALREADY_EXIST, + ); + } + + return this.tokenService.generateInvitationToken(workspace.id, email); + } + + async loadWorkspaceInvitations(workspace: Workspace) { + const appTokens = await this.appTokenRepository.find({ + where: { + workspaceId: workspace.id, + type: AppTokenType.InvitationToken, + deletedAt: IsNull(), + }, + select: { + value: false, + }, + }); + + return appTokens.map(this.castAppTokenToWorkspaceInvitation); + } + + async deleteWorkspaceInvitation(appTokenId: string, workspaceId: string) { + const appToken = await this.appTokenRepository.findOne({ + where: { + id: appTokenId, + workspaceId, + type: AppTokenType.InvitationToken, + }, + }); + + if (!appToken) { + return 'error'; + } + + await this.appTokenRepository.delete(appToken.id); + + return 'success'; + } + + async invalidateWorkspaceInvitation(workspaceId: string, email: string) { + const appToken = await this.getOneWorkspaceInvitation(workspaceId, email); + + if (appToken) { + await this.appTokenRepository.delete(appToken.id); + } + } + + async resendWorkspaceInvitation( + appTokenId: string, + workspace: Workspace, + sender: User, + ) { + const appToken = await this.appTokenRepository.findOne({ + where: { + id: appTokenId, + workspaceId: workspace.id, + type: AppTokenType.InvitationToken, + }, + }); + + if (!appToken || !appToken.context || !('email' in appToken.context)) { + throw new WorkspaceInvitationException( + 'Invalid appToken', + WorkspaceInvitationExceptionCode.INVALID_INVITATION, + ); + } + + await this.appTokenRepository.delete(appToken.id); + + return this.sendInvitations([appToken.context.email], workspace, sender); + } + + async sendInvitations( + emails: string[], + workspace: Workspace, + sender: User, + usePersonalInvitation = true, + ): Promise { + if (!workspace?.inviteHash) { + return { + success: false, + errors: ['Workspace invite hash not found'], + result: [], + }; + } + + const invitationsPr = await Promise.allSettled( + emails.map(async (email) => { + if (usePersonalInvitation) { + const appToken = await this.createWorkspaceInvitation( + email, + workspace, + ); + + if (!appToken.context?.email) { + throw new WorkspaceInvitationException( + 'Invalid email', + WorkspaceInvitationExceptionCode.EMAIL_MISSING, + ); + } + + return { + isPersonalInvitation: true as const, + appToken, + email: appToken.context.email, + }; + } + + return { + isPersonalInvitation: false as const, + email, + }; + }), + ); + + const frontBaseURL = this.environmentService.get('FRONT_BASE_URL'); + + for (const invitation of invitationsPr) { + if (invitation.status === 'fulfilled') { + const link = new URL(`${frontBaseURL}/invite/${workspace?.inviteHash}`); + + if (invitation.value.isPersonalInvitation) { + link.searchParams.set('inviteToken', invitation.value.appToken.value); + } + const emailData = { + link: link.toString(), + workspace: { name: workspace.displayName, logo: workspace.logo }, + sender: { email: sender.email, firstName: sender.firstName }, + serverUrl: this.environmentService.get('SERVER_URL'), + }; + + const emailTemplate = SendInviteLinkEmail(emailData); + const html = render(emailTemplate, { + pretty: true, + }); + + const text = render(emailTemplate, { + plainText: true, + }); + + await this.emailService.send({ + from: `${this.environmentService.get( + 'EMAIL_FROM_NAME', + )} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`, + to: invitation.value.email, + subject: 'Join your team on Twenty', + text, + html, + }); + } + } + + await this.onboardingService.setOnboardingInviteTeamPending({ + workspaceId: workspace.id, + value: false, + }); + + const result = invitationsPr.reduce<{ + errors: string[]; + result: ReturnType< + typeof this.workspaceInvitationService.createWorkspaceInvitation + >['status'] extends 'rejected' + ? never + : ReturnType< + typeof this.workspaceInvitationService.appTokenToWorkspaceInvitation + >; + }>( + (acc, invitation) => { + if (invitation.status === 'rejected') { + acc.errors.push(invitation.reason?.message ?? 'Unknown error'); + } else { + acc.result.push( + invitation.value.isPersonalInvitation + ? this.castAppTokenToWorkspaceInvitation( + invitation.value.appToken, + ) + : { email: invitation.value.email }, + ); + } + + return acc; + }, + { errors: [], result: [] }, + ); + + return { + success: result.errors.length === 0, + ...result, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.exception.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.exception.ts new file mode 100644 index 0000000000..6dce1ea3bc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.exception.ts @@ -0,0 +1,17 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class WorkspaceInvitationException extends CustomException { + code: WorkspaceInvitationExceptionCode; + constructor(message: string, code: WorkspaceInvitationExceptionCode) { + super(message, code); + } +} + +export enum WorkspaceInvitationExceptionCode { + INVALID_APP_TOKEN_TYPE = 'INVALID_APP_TOKEN_TYPE', + INVITATION_CORRUPTED = 'INVITATION_CORRUPTED', + INVITATION_ALREADY_EXIST = 'INVITATION_ALREADY_EXIST', + USER_ALREADY_EXIST = 'USER_ALREADY_EXIST', + INVALID_INVITATION = 'INVALID_INVITATION', + EMAIL_MISSING = 'EMAIL_MISSING', +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts new file mode 100644 index 0000000000..f09bb770a3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; + +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; +import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; +import { WorkspaceInvitationResolver } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.resolver'; + +@Module({ + imports: [ + NestjsQueryTypeOrmModule.forFeature([AppToken, UserWorkspace], 'core'), + TokenModule, + OnboardingModule, + ], + exports: [WorkspaceInvitationService], + providers: [WorkspaceInvitationService, WorkspaceInvitationResolver], +}) +export class WorkspaceInvitationModule {} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.resolver.ts new file mode 100644 index 0000000000..3faf6201cd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.resolver.ts @@ -0,0 +1,66 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; +import { WorkspaceInvitation } from 'src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { SendInvitationsOutput } from 'src/engine/core-modules/workspace-invitation/dtos/send-invitations.output'; +import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; + +import { SendInvitationsInput } from './dtos/send-invitations.input'; + +@UseGuards(WorkspaceAuthGuard) +@Resolver() +export class WorkspaceInvitationResolver { + constructor( + private readonly workspaceInvitationService: WorkspaceInvitationService, + ) {} + + @Mutation(() => String) + async deleteWorkspaceInvitation( + @Args('appTokenId') appTokenId: string, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.workspaceInvitationService.deleteWorkspaceInvitation( + appTokenId, + workspaceId, + ); + } + + @Mutation(() => SendInvitationsOutput) + @UseGuards(UserAuthGuard) + async resendWorkspaceInvitation( + @Args('appTokenId') appTokenId: string, + @AuthWorkspace() workspace: Workspace, + @AuthUser() user: User, + ) { + return this.workspaceInvitationService.resendWorkspaceInvitation( + appTokenId, + workspace, + user, + ); + } + + @Query(() => [WorkspaceInvitation]) + async findWorkspaceInvitations(@AuthWorkspace() workspace: Workspace) { + return this.workspaceInvitationService.loadWorkspaceInvitations(workspace); + } + + @Mutation(() => SendInvitationsOutput) + @UseGuards(UserAuthGuard) + async sendInvitations( + @Args() sendInviteLinkInput: SendInvitationsInput, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return await this.workspaceInvitationService.sendInvitations( + sendInviteLinkInput.emails, + workspace, + user, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.entity.ts deleted file mode 100644 index bd3d116077..0000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.entity.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Field, ObjectType } from '@nestjs/graphql'; - -@ObjectType() -export class SendInviteLink { - @Field(() => Boolean, { - description: 'Boolean that confirms query was dispatched', - }) - success: boolean; -} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts index 2b1c4c171e..6a3d8b4400 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts @@ -11,6 +11,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { WorkspaceService } from './workspace.service'; @@ -61,6 +62,10 @@ describe('WorkspaceService', () => { provide: OnboardingService, useValue: {}, }, + { + provide: WorkspaceInvitationService, + useValue: {}, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 56ae3ce906..0be6fd02f9 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -1,22 +1,17 @@ import { BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { ModuleRef } from '@nestjs/core'; import assert from 'assert'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; -import { render } from '@react-email/render'; -import { SendInviteLinkEmail } from 'twenty-emails'; import { Repository } from 'typeorm'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; -import { EmailService } from 'src/engine/core-modules/email/email.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-input'; -import { SendInviteLink } from 'src/engine/core-modules/workspace/dtos/send-invite-link.entity'; import { Workspace, WorkspaceActivationStatus, @@ -25,6 +20,7 @@ import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace- // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class WorkspaceService extends TypeOrmQueryService { + private userWorkspaceService: UserWorkspaceService; constructor( @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @@ -33,13 +29,13 @@ export class WorkspaceService extends TypeOrmQueryService { @InjectRepository(UserWorkspace, 'core') private readonly userWorkspaceRepository: Repository, private readonly workspaceManagerService: WorkspaceManagerService, - private readonly userWorkspaceService: UserWorkspaceService, private readonly billingSubscriptionService: BillingSubscriptionService, - private readonly environmentService: EnvironmentService, - private readonly emailService: EmailService, - private readonly onboardingService: OnboardingService, + private moduleRef: ModuleRef, ) { super(workspaceRepository); + this.userWorkspaceService = this.moduleRef.get(UserWorkspaceService, { + strict: false, + }); } async activateWorkspace(user: User, data: ActivateWorkspaceInput) { @@ -66,7 +62,7 @@ export class WorkspaceService extends TypeOrmQueryService { existingWorkspace.activationStatus !== WorkspaceActivationStatus.PENDING_CREATION ) { - throw new Error('Worspace is not pending creation'); + throw new Error('Workspace is not pending creation'); } await this.workspaceRepository.update(user.defaultWorkspaceId, { @@ -123,53 +119,6 @@ export class WorkspaceService extends TypeOrmQueryService { await this.reassignOrRemoveUserDefaultWorkspace(workspaceId, userId); } - async sendInviteLink( - emails: string[], - workspace: Workspace, - sender: User, - ): Promise { - if (!workspace?.inviteHash) { - return { success: false }; - } - - const frontBaseURL = this.environmentService.get('FRONT_BASE_URL'); - const inviteLink = `${frontBaseURL}/invite/${workspace.inviteHash}`; - - for (const email of emails) { - const emailData = { - link: inviteLink, - workspace: { name: workspace.displayName, logo: workspace.logo }, - sender: { email: sender.email, firstName: sender.firstName }, - serverUrl: this.environmentService.get('SERVER_URL'), - }; - const emailTemplate = SendInviteLinkEmail(emailData); - const html = render(emailTemplate, { - pretty: true, - }); - - const text = render(emailTemplate, { - plainText: true, - }); - - await this.emailService.send({ - from: `${this.environmentService.get( - 'EMAIL_FROM_NAME', - )} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`, - to: email, - subject: 'Join your team on Twenty', - text, - html, - }); - } - - await this.onboardingService.setOnboardingInviteTeamPending({ - workspaceId: workspace.id, - value: false, - }); - - return { success: true }; - } - private async reassignOrRemoveUserDefaultWorkspace( workspaceId: string, userId: string, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index 4c227709c9..040b945329 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -18,6 +18,7 @@ import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.r import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; +import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts'; import { Workspace } from './workspace.entity'; @@ -42,6 +43,7 @@ import { WorkspaceService } from './services/workspace.service'; DataSourceModule, OnboardingModule, TypeORMModule, + WorkspaceInvitationModule, ], services: [WorkspaceService], resolvers: workspaceAutoResolverOpts, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index d5ab03b23a..958738005a 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -19,8 +19,6 @@ import { FileService } from 'src/engine/core-modules/file/services/file.service' import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-input'; -import { SendInviteLink } from 'src/engine/core-modules/workspace/dtos/send-invite-link.entity'; -import { SendInviteLinkInput } from 'src/engine/core-modules/workspace/dtos/send-invite-link.input'; import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -138,18 +136,4 @@ export class WorkspaceResolver { return workspace.logo ?? ''; } - - @Mutation(() => SendInviteLink) - @UseGuards(UserAuthGuard) - async sendInviteLink( - @Args() sendInviteLinkInput: SendInviteLinkInput, - @AuthUser() user: User, - @AuthWorkspace() workspace: Workspace, - ): Promise { - return await this.workspaceService.sendInviteLink( - sendInviteLinkInput.emails, - workspace, - user, - ); - } } diff --git a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts index 5d00466ad7..d1f9208487 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -1,6 +1,6 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; @Injectable() diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts index f8ea8cea99..0afdd84838 100644 --- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts @@ -3,7 +3,7 @@ import { Injectable, NestMiddleware } from '@nestjs/common'; import { NextFunction, Request, Response } from 'express'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util'; diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index 750694edae..bdccc2a098 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -51,3 +51,4 @@ export * from './tooltip/OverflowingTextWithTooltip'; export * from './typography/components/H1Title'; export * from './typography/components/H2Title'; export * from './typography/components/H3Title'; +export * from './typography/components/StyledText'; diff --git a/packages/twenty-ui/src/display/typography/components/StyledText.tsx b/packages/twenty-ui/src/display/typography/components/StyledText.tsx new file mode 100644 index 0000000000..76bbdf562c --- /dev/null +++ b/packages/twenty-ui/src/display/typography/components/StyledText.tsx @@ -0,0 +1,52 @@ +import { ReactElement, ReactNode } from 'react'; +import styled from '@emotion/styled'; + +type StyledTextProps = { + PrefixComponent?: ReactElement; + text: ReactNode; + color?: string; +}; + +export const StyledTextContent = styled.div` + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + + overflow: hidden; + padding-left: 0; + + white-space: nowrap; +`; + +export const StyledTextWrapper = styled.div<{ + color?: string; +}>` + --horizontal-padding: ${({ theme }) => theme.spacing(1)}; + --vertical-padding: ${({ theme }) => theme.spacing(2)}; + + cursor: initial; + + display: flex; + + flex-direction: row; + + font-size: ${({ theme }) => theme.font.size.sm}; + + gap: ${({ theme }) => theme.spacing(2)}; + + padding: var(--vertical-padding) 0; + + color: ${({ theme, color }) => color ?? theme.font.color.primary}; +`; + +export const StyledText = ({ + PrefixComponent, + text, + color, +}: StyledTextProps) => { + return ( + + {PrefixComponent ? PrefixComponent : null} + {text} + + ); +};