diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index a04b81e94655..92434139dff2 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1,5 +1,5 @@ -import * as Apollo from '@apollo/client'; import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { [K in keyof T]: T[K] }; @@ -219,7 +219,7 @@ export type ExecuteServerlessFunctionInput = { /** Id of the serverless function to execute */ id: Scalars['UUID']; /** Payload in JSON format */ - payload?: InputMaybe; + payload: Scalars['JSON']; /** Version of the serverless function to execute */ version?: Scalars['String']; }; @@ -338,8 +338,10 @@ export enum MessageChannelVisibility { export type Mutation = { __typename?: 'Mutation'; + activateWorkflowVersion: Scalars['Boolean']; activateWorkspace: Workspace; addUserToWorkspace: User; + addUserToWorkspaceByInviteToken: User; authorizeApp: AuthorizeApp; challenge: LoginToken; checkoutSession: SessionEntity; @@ -347,16 +349,15 @@ export type Mutation = { createOneObject: Object; createOneServerlessFunction: ServerlessFunction; createOneServerlessFunctionFromFile: ServerlessFunction; + deactivateWorkflowVersion: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; deleteOneObject: Object; deleteOneServerlessFunction: ServerlessFunction; deleteUser: User; deleteWorkspaceInvitation: Scalars['String']; disablePostgresProxy: PostgresCredentials; - disableWorkflowTrigger: Scalars['Boolean']; emailPasswordResetLink: EmailPasswordResetLink; enablePostgresProxy: PostgresCredentials; - enableWorkflowTrigger: Scalars['Boolean']; exchangeAuthorizationCode: ExchangeAuthCode; executeOneServerlessFunction: ServerlessFunctionExecutionResult; generateApiKeyToken: ApiKeyToken; @@ -384,6 +385,11 @@ export type Mutation = { }; +export type MutationActivateWorkflowVersionArgs = { + workflowVersionId: Scalars['String']; +}; + + export type MutationActivateWorkspaceArgs = { data: ActivateWorkspaceInput; }; @@ -394,6 +400,11 @@ export type MutationAddUserToWorkspaceArgs = { }; +export type MutationAddUserToWorkspaceByInviteTokenArgs = { + inviteToken: Scalars['String']; +}; + + export type MutationAuthorizeAppArgs = { clientId: Scalars['String']; codeChallenge?: InputMaybe; @@ -425,6 +436,11 @@ export type MutationCreateOneServerlessFunctionFromFileArgs = { }; +export type MutationDeactivateWorkflowVersionArgs = { + workflowVersionId: Scalars['String']; +}; + + export type MutationDeleteOneObjectArgs = { input: DeleteOneObjectInput; }; @@ -440,21 +456,11 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; -export type MutationDisableWorkflowTriggerArgs = { - workflowVersionId: Scalars['String']; -}; - - export type MutationEmailPasswordResetLinkArgs = { email: Scalars['String']; }; -export type MutationEnableWorkflowTriggerArgs = { - workflowVersionId: Scalars['String']; -}; - - export type MutationExchangeAuthorizationCodeArgs = { authorizationCode: Scalars['String']; clientSecret?: InputMaybe; @@ -513,6 +519,7 @@ export type MutationSignUpArgs = { email: Scalars['String']; password: Scalars['String']; workspaceInviteHash?: InputMaybe; + workspacePersonalInviteToken?: InputMaybe; }; @@ -651,9 +658,10 @@ export type Query = { findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; getAISQLQuery: AisqlQueryResult; + getAvailablePackages: Scalars['JSON']; getPostgresCredentials?: Maybe; getProductPrices: ProductPricesEntity; - getServerlessFunctionSourceCode: Scalars['String']; + getServerlessFunctionSourceCode?: Maybe; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal; @@ -1437,6 +1445,7 @@ export type SignUpMutationVariables = Exact<{ email: Scalars['String']; password: Scalars['String']; workspaceInviteHash?: InputMaybe; + workspacePersonalInviteToken?: InputMaybe; captchaToken?: InputMaybe; }>; @@ -1564,6 +1573,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; }>; @@ -2303,11 +2319,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 { @@ -2334,6 +2351,7 @@ export type SignUpMutationFn = Apollo.MutationFunction; 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) { 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 85285b7769e6..57499f773f7e 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 229372520c00..265f08531fcf 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -263,6 +263,7 @@ export const useAuth = () => { email: string, password: string, workspaceInviteHash?: string, + workspacePersonalInviteToken?: string, captchaToken?: string, ) => { setIsVerifyPendingState(true); @@ -272,6 +273,7 @@ export const useAuth = () => { email, password, workspaceInviteHash, + workspacePersonalInviteToken, captchaToken, }, }); @@ -295,21 +297,44 @@ export const useAuth = () => { [setIsVerifyPendingState, signUp, handleVerify], ); - const handleGoogleLogin = useCallback((workspaceInviteHash?: string) => { + // TODO: how to test that? + 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 (params.workspaceInviteHash) { + url.searchParams.set('inviteHash', params.workspaceInviteHash); + } + if (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 24111466a69d..48c66f54ed65 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 8eb008b6f3c4..58ce165f7508 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 444bff19d31f..2f471c176c8c 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 feee086ef7c0..a51365b98a3b 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/workspace-member/grapqhql/mutations/addUserToWorkspaceByInviteToken.ts b/packages/twenty-front/src/modules/workspace-member/grapqhql/mutations/addUserToWorkspaceByInviteToken.ts new file mode 100644 index 000000000000..4850c10596e4 --- /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/pages/auth/Invite.tsx b/packages/twenty-front/src/pages/auth/Invite.tsx index 177758afa119..723ef4860092 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,27 +28,41 @@ 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); + + await switchWorkspace(workspaceFromInviteHash?.id); }; if ( diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx index 287bf9258288..c76444c2ab5f 100644 --- a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx @@ -228,16 +228,16 @@ export const SettingsWorkspaceMembers = () => { description="Send an invite email to your team" /> - - - - Email - Expires in - - - - {isNonEmptyArray(workspaceInvitations) && - workspaceInvitations?.map((workspaceInvitation) => ( + {isNonEmptyArray(workspaceInvitations) && ( +
+ + + Email + Expires in + + + + {workspaceInvitations?.map((workspaceInvitation) => ( { ))} -
+ + )} 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 000000000000..756300a964bd --- /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/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index cec729573441..508691b1430e 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 @@ -95,6 +95,7 @@ export class AuthService { email, password, workspaceInviteHash, + workspacePersonalInviteToken, firstName, lastName, picture, @@ -105,6 +106,7 @@ export class AuthService { firstName?: string | null; lastName?: string | null; workspaceInviteHash?: string | null; + workspacePersonalInviteToken?: string | null; picture?: string | null; fromSSO: boolean; }) { @@ -114,6 +116,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.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 63286c37273c..f98cfe7f75d8 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 @@ -27,6 +27,11 @@ 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, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { isDefined } from 'class-validator'; export type SignInUpServiceInput = { email: string; @@ -34,6 +39,7 @@ export type SignInUpServiceInput = { firstName?: string | null; lastName?: string | null; workspaceInviteHash?: string | null; + workspacePersonalInviteToken?: string | null; picture?: string | null; fromSSO: boolean; }; @@ -45,6 +51,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 +64,7 @@ export class SignInUpService { async signInUp({ email, workspaceInviteHash, + workspacePersonalInviteToken, password, firstName, lastName, @@ -111,6 +120,7 @@ export class SignInUpService { email, passwordHash, workspaceInviteHash, + workspacePersonalInviteToken, firstName, lastName, picture, @@ -134,6 +144,7 @@ export class SignInUpService { email, passwordHash, workspaceInviteHash, + workspacePersonalInviteToken, firstName, lastName, picture, @@ -141,19 +152,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 +182,83 @@ export class SignInUpService { ); } - if (existingUser) { - const updatedUser = await this.userWorkspaceService.addUserToWorkspace( - existingUser, - workspace, + if (isNewUser) { + const imagePath = await this.uploadPicture(picture, workspace.id); + + const userToCreate = this.userRepository.create({ + email: email, + firstName: firstName, + lastName: lastName, + defaultAvatarUrl: imagePath, + canImpersonate: false, + passwordHash, + defaultWorkspace: workspace, + }); + + user = await this.userRepository.save(userToCreate); + } + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.FORBIDDEN_EXCEPTION, ); + } + + const updatedUser = workspacePersonalInviteToken + ? await this.userWorkspaceService.addUserToWorkspaceByInviteToken( + workspacePersonalInviteToken, + user, + ) + : await this.userWorkspaceService.addUserToWorkspace(user, workspace); - return Object.assign(existingUser, updatedUser); + if (isNewUser) { + await this.activateOnboardingForNewUser(user, workspace, { + firstName, + lastName, + }); } - const imagePath = await this.uploadPicture(picture, workspace.id); + return Object.assign(user, updatedUser); + } - const userToCreate = this.userRepository.create({ - email: email, - firstName: firstName, - lastName: lastName, - defaultAvatarUrl: imagePath, - canImpersonate: false, - passwordHash, - defaultWorkspace: workspace, + 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.appTokenRepository.findOne({ + where: { + value: workspacePersonalInviteToken, + type: AppTokenType.InvitationToken, + }, + relations: ['workspace'], }); - const user = await this.userRepository.save(userToCreate); + if (!appToken || appToken.context?.email !== email) { + throw new Error('Email does not match the invitation'); + } - await this.userWorkspaceService.create(user.id, workspace.id); - await this.userWorkspaceService.createWorkspaceMember(workspace.id, user); + 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 +272,6 @@ export class SignInUpService { value: true, }); } - - return user; } private async signUpOnNewWorkspace({ diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/token.service.ts index 7ca71d987bdd..c9221e9d0f83 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/token.service.ts @@ -193,6 +193,7 @@ export class TokenService { workspaceId, expiresAt, type: AppTokenType.InvitationToken, + value: crypto.randomBytes(32).toString('hex'), context: { email, }, 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 5f21e9b6d7e9..c7967cd63d27 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 @@ -9,13 +9,17 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use 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, 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 380e53e69754..e5aa41463597 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 @@ -12,6 +12,7 @@ import { WorkspaceInviteHashValidInput } from 'src/engine/core-modules/auth/dto/ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; 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(JwtAuthGuard) @Resolver(() => UserWorkspace) @@ -19,8 +20,6 @@ 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, ) {} @@ -45,4 +44,15 @@ export class UserWorkspaceResolver { 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 65b23cbb71a5..ecf51f4b5551 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,34 @@ export class UserWorkspaceService extends TypeOrmQueryService { }); } + public async addUserToWorkspaceByInviteToken( + inviteToken: string, + user: User, + ) { + const appToken = await this.appTokenRepository.findOne({ + where: { + value: inviteToken, + type: AppTokenType.InvitationToken, + }, + relations: ['workspace'], + }); + + if (!appToken || !appToken.workspace) { + return; + } + + if (appToken.context?.email !== user.email) { + throw new Error('Email does not match the invitation'); + } + + await this.workspaceInvitationService.useWorkspaceInvitation( + appToken.workspace.id, + user.email, + ); + + return await this.addUserToWorkspace(user, appToken.workspace); + } + public async getUserCount(workspaceId): Promise { return await this.userWorkspaceRepository.countBy({ workspaceId, 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 index 11df65113a4b..a7c87a3c5064 100644 --- 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 @@ -47,7 +47,7 @@ export class WorkspaceInvitationService { .getOne(); } - private appTokenToWorkspaceInvitation(appToken: AppToken) { + appTokenToWorkspaceInvitation(appToken: AppToken) { if (appToken.type !== AppTokenType.InvitationToken) { throw new Error(`Token type must be "${AppTokenType.InvitationToken}"`); } @@ -83,9 +83,7 @@ export class WorkspaceInvitationService { throw new Error(`${email} is already in the workspace`); } - return this.appTokenToWorkspaceInvitation( - await this.tokenService.generateInvitationToken(workspace.id, email), - ); + return this.tokenService.generateInvitationToken(workspace.id, email); } async loadWorkspaceInvitations(workspace: Workspace) { 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 de67affc4e2f..19e0fae94f0a 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,5 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { ModuleRef } from '@nestjs/core'; import assert from 'assert'; @@ -26,6 +27,8 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class WorkspaceService extends TypeOrmQueryService { + private userWorkspaceService: UserWorkspaceService; + private workspaceInvitationService: WorkspaceInvitationService; constructor( @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @@ -34,14 +37,22 @@ 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 readonly workspaceInvitationService: WorkspaceInvitationService, + private moduleRef: ModuleRef, ) { super(workspaceRepository); + this.userWorkspaceService = this.moduleRef.get(UserWorkspaceService, { + strict: false, + }); + this.workspaceInvitationService = this.moduleRef.get( + WorkspaceInvitationService, + { + strict: false, + }, + ); } async activateWorkspace(user: User, data: ActivateWorkspaceInput) { @@ -129,6 +140,7 @@ export class WorkspaceService extends TypeOrmQueryService { emails: string[], workspace: Workspace, sender: User, + usePersonalInvitation = true, ): Promise { if (!workspace?.inviteHash) { return { @@ -138,26 +150,45 @@ export class WorkspaceService extends TypeOrmQueryService { }; } - const frontBaseURL = this.environmentService.get('FRONT_BASE_URL'); - const inviteLink = `${frontBaseURL}/invite/${workspace.inviteHash}`; - - const workspaceInvitationsPr = await Promise.allSettled( - emails.map((email) => - this.workspaceInvitationService.createWorkspaceInvitation( + const invitationsPr = await Promise.allSettled( + emails.map(async (email) => { + if (usePersonalInvitation) { + const appToken = + await this.workspaceInvitationService.createWorkspaceInvitation( + email, + workspace, + ); + if (!appToken.context?.email) { + throw new Error('Invalid email'); + } + return { + isPersonalInvitation: true as const, + appToken, + email: appToken.context.email, + }; + } + return { + isPersonalInvitation: false as const, email, - workspace, - ), - ), + }; + }), ); - for (const workspaceInvitationPr of workspaceInvitationsPr) { - if (workspaceInvitationPr.status === 'fulfilled') { + 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: inviteLink, + 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, @@ -171,7 +202,7 @@ export class WorkspaceService extends TypeOrmQueryService { from: `${this.environmentService.get( 'EMAIL_FROM_NAME', )} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`, - to: workspaceInvitationPr.value.email, + to: invitation.value.email, subject: 'Join your team on Twenty', text, html, @@ -184,23 +215,27 @@ export class WorkspaceService extends TypeOrmQueryService { value: false, }); - const result = workspaceInvitationsPr.reduce<{ + const result = invitationsPr.reduce<{ errors: string[]; result: ReturnType< typeof this.workspaceInvitationService.createWorkspaceInvitation >['status'] extends 'rejected' ? never : ReturnType< - typeof this.workspaceInvitationService.createWorkspaceInvitation - >['value']; + typeof this.workspaceInvitationService.appTokenToWorkspaceInvitation + >; }>( - (acc, workspaceInvitationPr) => { - if (workspaceInvitationPr.status === 'rejected') { - acc.errors.push( - workspaceInvitationPr.reason?.message ?? 'Unknown error', - ); + (acc, invitation) => { + if (invitation.status === 'rejected') { + acc.errors.push(invitation.reason?.message ?? 'Unknown error'); } else { - acc.result.push(workspaceInvitationPr.value); + acc.result.push( + invitation.value.isPersonalInvitation + ? this.workspaceInvitationService.appTokenToWorkspaceInvitation( + invitation.value.appToken, + ) + : { email: invitation.value.email }, + ); } return acc;