diff --git a/.gitignore b/.gitignore index febe678d4..f8b0d9f6c 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 b9c820518..c71df7b77 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 ee913c8bb..0bd71dcae 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 85285b776..57499f773 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 fef424a8f..677932161 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 24111466a..48c66f54e 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 8eb008b6f..58ce165f7 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 444bff19d..2f471c176 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 feee086ef..a51365b98 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 4d2a9bdc8..5659c41a7 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 486dafaa0..a73b21d95 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 000000000..5731548c1 --- /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 000000000..acda3cffd --- /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 000000000..8f550a1ad --- /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 000000000..4850c1059 --- /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 46365c934..ded69618f 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 3bb6bbc02..1f5b277ab 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 c34f4734c..000000000 --- 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 177758afa..7260a6b59 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 53a7b2124..6259bb363 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 d0399a65c..0079fb888 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 062aebe47..572531308 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 f7bba35bb..4f478565e 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 1c30fa89d..43c9b0198 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 afd0e59ef..29f117894 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 010effe4d..998fa634a 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 e2563b888..1560c7f97 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 cca5dc1de..2d08a6b4e 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 7276749d3..033210af1 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 330e382b2..615d4c607 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 42674953e..6ae9b11d7 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 62f3364b6..49fa5384b 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 0aef1bea1..a73754fbd 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 40869c5f7..25a52dc3b 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 53a9a4788..4d952e0f2 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 000000000..756300a96 --- /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 c7185ff88..dd9fbf17f 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 44f084a26..dd67b6768 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 7b4f1309a..f52023891 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 c3a604e14..bba83839a 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 eb2974d9d..639f6cb68 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 63286c372..6e0e96203 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 22ba14447..932e4c4e3 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 48a097734..babcf1540 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 9ff47fe99..c740cb9a0 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 000000000..ea1046872 --- /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 c1c6760df..ff33a896e 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 b63e1fd04..6c031463f 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 501afd4cb..a78b636e2 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 c676f1539..c3ecf518a 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 7c78a1fd9..71fb7c279 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 7e6f2d26e..d4dce437e 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 49e7ed9ac..4628fef4f 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 570c1103f..c7967cd63 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 738b60241..338b6e949 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 69972542b..31fdd6379 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 ac80111ba..682a970de 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 000000000..aa72b2c61 --- /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 000000000..76f2f65f5 --- /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 000000000..3fce16c4c --- /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 000000000..8c193efef --- /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 000000000..6dce1ea3b --- /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 000000000..f09bb770a --- /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 000000000..3faf6201c --- /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 bd3d11607..000000000 --- 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 2b1c4c171..6a3d8b440 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 56ae3ce90..0be6fd02f 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 4c227709c..040b94532 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 d5ab03b23..958738005 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 5d00466ad..d1f920848 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 f8ea8cea9..0afdd8483 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 750694eda..bdccc2a09 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 000000000..76bbdf562 --- /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} + + ); +};