diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index d20c11be3..fbf7ffdaf 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -143,6 +143,14 @@ export type AvailableWorkspaceOutput = { workspaceUrls: WorkspaceUrls; }; +export type AvailableWorkspacesToJoin = { + __typename?: 'AvailableWorkspacesToJoin'; + displayName?: Maybe; + id: Scalars['String']['output']; + logo?: Maybe; + workspaceUrl: Scalars['String']['output']; +}; + export type Billing = { __typename?: 'Billing'; billingUrl?: Maybe; @@ -298,6 +306,13 @@ export enum CaptchaDriverType { TURNSTILE = 'TURNSTILE' } +export type CheckUserExistOutput = { + __typename?: 'CheckUserExistOutput'; + availableWorkspaces: Array; + exists: Scalars['Boolean']['output']; + isEmailVerified: Scalars['Boolean']['output']; +}; + export type ClientConfig = { __typename?: 'ClientConfig'; analyticsEnabled: Scalars['Boolean']['output']; @@ -942,6 +957,7 @@ export type Mutation = { createOneRole: Role; createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; + createUserAndWorkspace: SignUpOutput; createWorkflowVersionStep: WorkflowAction; deactivateWorkflowVersion: Scalars['Boolean']['output']; deleteApprovedAccessDomain: Scalars['Boolean']['output']; @@ -1103,6 +1119,16 @@ export type MutationCreateSamlIdentityProviderArgs = { }; +export type MutationCreateUserAndWorkspaceArgs = { + captchaToken?: InputMaybe; + email: Scalars['String']['input']; + firstName?: InputMaybe; + lastName?: InputMaybe; + locale?: InputMaybe; + picture?: InputMaybe; +}; + + export type MutationCreateWorkflowVersionStepArgs = { input: CreateWorkflowVersionStepInput; }; @@ -1607,7 +1633,7 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; billingPortalSession: BillingSessionOutput; - checkUserExists: UserExistsOutput; + checkUserExists: CheckUserExistOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; currentUser: User; @@ -1640,6 +1666,7 @@ export type Query = { getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; index: Index; indexMetadatas: IndexConnection; + listAvailableWorkspaces: Array; object: Object; objects: ObjectConnection; plans: Array; @@ -1771,6 +1798,12 @@ export type QueryIndexMetadatasArgs = { }; +export type QueryListAvailableWorkspacesArgs = { + captchaToken?: InputMaybe; + email: Scalars['String']['input']; +}; + + export type QueryObjectArgs = { id: Scalars['UUID']['input']; }; @@ -2353,6 +2386,7 @@ export type UpsertSettingPermissionsInput = { export type User = { __typename?: 'User'; + availableWorkspaces: Array; canAccessFullAdminPanel: Scalars['Boolean']['output']; canImpersonate: Scalars['Boolean']['output']; createdAt: Scalars['DateTime']['output']; @@ -2386,15 +2420,6 @@ export type UserEdge = { node: User; }; -export type UserExists = { - __typename?: 'UserExists'; - availableWorkspaces: Array; - exists: Scalars['Boolean']['output']; - isEmailVerified: Scalars['Boolean']['output']; -}; - -export type UserExistsOutput = UserExists | UserNotExists; - export type UserInfo = { __typename?: 'UserInfo'; email: Scalars['String']['output']; @@ -2424,11 +2449,6 @@ export type UserMappingOptionsUser = { user?: Maybe; }; -export type UserNotExists = { - __typename?: 'UserNotExists'; - exists: Scalars['Boolean']['output']; -}; - export type UserWorkspace = { __typename?: 'UserWorkspace'; createdAt: Scalars['DateTime']['output']; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 7777bf2b3..1b9e2bfdc 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -126,15 +126,30 @@ export type AuthorizeApp = { redirectUrl: Scalars['String']; }; -export type AvailableWorkspaceOutput = { - __typename?: 'AvailableWorkspaceOutput'; +export type AvailableWorkspace = { + __typename?: 'AvailableWorkspace'; displayName?: Maybe; id: Scalars['String']; + inviteHash?: Maybe; + loginToken?: Maybe; logo?: Maybe; + personalInviteToken?: Maybe; sso: Array; workspaceUrls: WorkspaceUrls; }; +export type AvailableWorkspaces = { + __typename?: 'AvailableWorkspaces'; + availableWorkspacesForSignIn: Array; + availableWorkspacesForSignUp: Array; +}; + +export type AvailableWorkspacesAndAccessTokensOutput = { + __typename?: 'AvailableWorkspacesAndAccessTokensOutput'; + availableWorkspaces: AvailableWorkspaces; + tokens: AuthTokenPair; +}; + export type Billing = { __typename?: 'Billing'; billingUrl?: Maybe; @@ -290,6 +305,13 @@ export enum CaptchaDriverType { TURNSTILE = 'TURNSTILE' } +export type CheckUserExistOutput = { + __typename?: 'CheckUserExistOutput'; + availableWorkspacesCount: Scalars['Float']; + exists: Scalars['Boolean']; + isEmailVerified: Scalars['Boolean']; +}; + export type ClientConfig = { __typename?: 'ClientConfig'; analyticsEnabled: Scalars['Boolean']; @@ -922,8 +944,10 @@ export type Mutation = { resendWorkspaceInvitation: SendInvitationsOutput; runWorkflowVersion: WorkflowRun; sendInvitations: SendInvitationsOutput; - signUp: SignUpOutput; + signIn: AvailableWorkspacesAndAccessTokensOutput; + signUp: AvailableWorkspacesAndAccessTokensOutput; signUpInNewWorkspace: SignUpOutput; + signUpInWorkspace: SignUpOutput; skipSyncEmailOnboardingStep: OnboardingStepSuccess; submitFormStep: Scalars['Boolean']; switchToEnterprisePlan: BillingUpdateOutput; @@ -1172,7 +1196,21 @@ export type MutationSendInvitationsArgs = { }; +export type MutationSignInArgs = { + captchaToken?: InputMaybe; + email: Scalars['String']; + password: Scalars['String']; +}; + + export type MutationSignUpArgs = { + captchaToken?: InputMaybe; + email: Scalars['String']; + password: Scalars['String']; +}; + + +export type MutationSignUpInWorkspaceArgs = { captchaToken?: InputMaybe; email: Scalars['String']; locale?: InputMaybe; @@ -1510,7 +1548,7 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; billingPortalSession: BillingSessionOutput; - checkUserExists: UserExistsOutput; + checkUserExists: CheckUserExistOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; currentUser: User; @@ -2183,6 +2221,7 @@ export type UpsertSettingPermissionsInput = { export type User = { __typename?: 'User'; + availableWorkspaces: AvailableWorkspaces; canAccessFullAdminPanel: Scalars['Boolean']; canImpersonate: Scalars['Boolean']; createdAt: Scalars['DateTime']; @@ -2202,7 +2241,7 @@ export type User = { passwordHash?: Maybe; supportUserHash?: Maybe; updatedAt: Scalars['DateTime']; - userVars: Scalars['JSONObject']; + userVars?: Maybe; workspaceMember?: Maybe; workspaceMembers?: Maybe>; workspaces: Array; @@ -2216,15 +2255,6 @@ export type UserEdge = { node: User; }; -export type UserExists = { - __typename?: 'UserExists'; - availableWorkspaces: Array; - exists: Scalars['Boolean']; - isEmailVerified: Scalars['Boolean']; -}; - -export type UserExistsOutput = UserExists | UserNotExists; - export type UserInfo = { __typename?: 'UserInfo'; email: Scalars['String']; @@ -2244,11 +2274,6 @@ export type UserMappingOptionsUser = { user?: Maybe; }; -export type UserNotExists = { - __typename?: 'UserNotExists'; - exists: Scalars['Boolean']; -}; - export type UserWorkspace = { __typename?: 'UserWorkspace'; createdAt: Scalars['DateTime']; @@ -2510,6 +2535,10 @@ export type AuthTokenFragmentFragment = { __typename?: 'AuthToken', token: strin export type AuthTokensFragmentFragment = { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } }; +export type AvailableWorkspaceFragmentFragment = { __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }; + +export type AvailableWorkspacesFragmentFragment = { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> }; + export type AvailableSsoIdentityProvidersFragmentFragment = { __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }; export type AuthorizeAppMutationVariables = Exact<{ @@ -2600,7 +2629,30 @@ export type ResendEmailVerificationTokenMutationVariables = Exact<{ export type ResendEmailVerificationTokenMutation = { __typename?: 'Mutation', resendEmailVerificationToken: { __typename?: 'ResendEmailVerificationTokenOutput', success: boolean } }; +export type SignInMutationVariables = Exact<{ + email: Scalars['String']; + password: Scalars['String']; + captchaToken?: InputMaybe; +}>; + + +export type SignInMutation = { __typename?: 'Mutation', signIn: { __typename?: 'AvailableWorkspacesAndAccessTokensOutput', availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; + export type SignUpMutationVariables = Exact<{ + email: Scalars['String']; + password: Scalars['String']; + captchaToken?: InputMaybe; +}>; + + +export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'AvailableWorkspacesAndAccessTokensOutput', availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; + +export type SignUpInNewWorkspaceMutationVariables = Exact<{ [key: string]: never; }>; + + +export type SignUpInNewWorkspaceMutation = { __typename?: 'Mutation', signUpInNewWorkspace: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } } }; + +export type SignUpInWorkspaceMutationVariables = Exact<{ email: Scalars['String']; password: Scalars['String']; workspaceInviteHash?: InputMaybe; @@ -2612,12 +2664,7 @@ export type SignUpMutationVariables = Exact<{ }>; -export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } } }; - -export type SignUpInNewWorkspaceMutationVariables = Exact<{ [key: string]: never; }>; - - -export type SignUpInNewWorkspaceMutation = { __typename?: 'Mutation', signUpInNewWorkspace: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } } }; +export type SignUpInWorkspaceMutation = { __typename?: 'Mutation', signUpInWorkspace: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } } }; export type UpdatePasswordViaResetTokenMutationVariables = Exact<{ token: Scalars['String']; @@ -2633,7 +2680,7 @@ export type CheckUserExistsQueryVariables = Exact<{ }>; -export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, isEmailVerified: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } }; +export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename?: 'CheckUserExistOutput', exists: boolean, availableWorkspacesCount: number, isEmailVerified: boolean } }; export type GetPublicWorkspaceDataByDomainQueryVariables = Exact<{ origin: Scalars['String']; @@ -2909,7 +2956,9 @@ export type OnDbEventSubscriptionVariables = Exact<{ export type OnDbEventSubscription = { __typename?: 'Subscription', onDbEvent: { __typename?: 'OnDbEventDTO', eventDate: string, action: DatabaseEventAction, objectNameSingular: string, updatedFields?: Array | null, record: any } }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, 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, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, 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, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; + +export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -2926,7 +2975,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, 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, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, 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, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; export type ActivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['String']; @@ -3208,6 +3257,12 @@ export const ObjectPermissionFragmentFragmentDoc = gql` canDestroyObjectRecords } `; +export const WorkspaceUrlsFragmentFragmentDoc = gql` + fragment WorkspaceUrlsFragment on WorkspaceUrls { + subdomainUrl + customUrl +} + `; export const RoleFragmentFragmentDoc = gql` fragment RoleFragment on Role { id @@ -3222,6 +3277,37 @@ export const RoleFragmentFragmentDoc = gql` canDestroyAllObjectRecords } `; +export const AvailableWorkspaceFragmentFragmentDoc = gql` + fragment AvailableWorkspaceFragment on AvailableWorkspace { + id + displayName + loginToken + inviteHash + personalInviteToken + workspaceUrls { + subdomainUrl + customUrl + } + logo + sso { + type + id + issuer + name + status + } +} + `; +export const AvailableWorkspacesFragmentFragmentDoc = gql` + fragment AvailableWorkspacesFragment on AvailableWorkspaces { + availableWorkspacesForSignIn { + ...AvailableWorkspaceFragment + } + availableWorkspacesForSignUp { + ...AvailableWorkspaceFragment + } +} + ${AvailableWorkspaceFragmentFragmentDoc}`; export const UserQueryFragmentFragmentDoc = gql` fragment UserQueryFragment on User { id @@ -3264,8 +3350,7 @@ export const UserQueryFragmentFragmentDoc = gql` customDomain isCustomDomainEnabled workspaceUrls { - subdomainUrl - customUrl + ...WorkspaceUrlsFragment } featureFlags { key @@ -3302,25 +3387,17 @@ export const UserQueryFragmentFragmentDoc = gql` ...RoleFragment } } - workspaces { - workspace { - id - logo - displayName - subdomain - customDomain - workspaceUrls { - subdomainUrl - customUrl - } - } + availableWorkspaces { + ...AvailableWorkspacesFragment } userVars } ${WorkspaceMemberQueryFragmentFragmentDoc} ${DeletedWorkspaceMemberQueryFragmentFragmentDoc} ${ObjectPermissionFragmentFragmentDoc} -${RoleFragmentFragmentDoc}`; +${WorkspaceUrlsFragmentFragmentDoc} +${RoleFragmentFragmentDoc} +${AvailableWorkspacesFragmentFragmentDoc}`; export const GetTimelineCalendarEventsFromCompanyIdDocument = gql` query GetTimelineCalendarEventsFromCompanyId($companyId: UUID!, $page: Int!, $pageSize: Int!) { getTimelineCalendarEventsFromCompanyId( @@ -3858,12 +3935,12 @@ export const GetLoginTokenFromEmailVerificationTokenDocument = gql` ...AuthTokenFragment } workspaceUrls { - subdomainUrl - customUrl + ...WorkspaceUrlsFragment } } } - ${AuthTokenFragmentFragmentDoc}`; + ${AuthTokenFragmentFragmentDoc} +${WorkspaceUrlsFragmentFragmentDoc}`; export type GetLoginTokenFromEmailVerificationTokenMutationFn = Apollo.MutationFunction; /** @@ -3898,8 +3975,7 @@ export const ImpersonateDocument = gql` impersonate(userId: $userId, workspaceId: $workspaceId) { workspace { workspaceUrls { - subdomainUrl - customUrl + ...WorkspaceUrlsFragment } id } @@ -3908,7 +3984,8 @@ export const ImpersonateDocument = gql` } } } - ${AuthTokenFragmentFragmentDoc}`; + ${WorkspaceUrlsFragmentFragmentDoc} +${AuthTokenFragmentFragmentDoc}`; export type ImpersonateMutationFn = Apollo.MutationFunction; /** @@ -4005,31 +4082,60 @@ export function useResendEmailVerificationTokenMutation(baseOptions?: Apollo.Mut export type ResendEmailVerificationTokenMutationHookResult = ReturnType; export type ResendEmailVerificationTokenMutationResult = Apollo.MutationResult; export type ResendEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions; -export const SignUpDocument = gql` - mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String, $locale: String, $verifyEmailNextPath: String) { - signUp( - email: $email - password: $password - workspaceInviteHash: $workspaceInviteHash - workspacePersonalInviteToken: $workspacePersonalInviteToken - captchaToken: $captchaToken - workspaceId: $workspaceId - locale: $locale - verifyEmailNextPath: $verifyEmailNextPath - ) { - loginToken { - ...AuthTokenFragment +export const SignInDocument = gql` + mutation SignIn($email: String!, $password: String!, $captchaToken: String) { + signIn(email: $email, password: $password, captchaToken: $captchaToken) { + availableWorkspaces { + ...AvailableWorkspacesFragment } - workspace { - id - workspaceUrls { - subdomainUrl - customUrl - } + tokens { + ...AuthTokensFragment } } } - ${AuthTokenFragmentFragmentDoc}`; + ${AvailableWorkspacesFragmentFragmentDoc} +${AuthTokensFragmentFragmentDoc}`; +export type SignInMutationFn = Apollo.MutationFunction; + +/** + * __useSignInMutation__ + * + * To run a mutation, you first call `useSignInMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useSignInMutation` 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 [signInMutation, { data, loading, error }] = useSignInMutation({ + * variables: { + * email: // value for 'email' + * password: // value for 'password' + * captchaToken: // value for 'captchaToken' + * }, + * }); + */ +export function useSignInMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SignInDocument, options); + } +export type SignInMutationHookResult = ReturnType; +export type SignInMutationResult = Apollo.MutationResult; +export type SignInMutationOptions = Apollo.BaseMutationOptions; +export const SignUpDocument = gql` + mutation SignUp($email: String!, $password: String!, $captchaToken: String) { + signUp(email: $email, password: $password, captchaToken: $captchaToken) { + availableWorkspaces { + ...AvailableWorkspacesFragment + } + tokens { + ...AuthTokensFragment + } + } +} + ${AvailableWorkspacesFragmentFragmentDoc} +${AuthTokensFragmentFragmentDoc}`; export type SignUpMutationFn = Apollo.MutationFunction; /** @@ -4047,12 +4153,7 @@ export type SignUpMutationFn = Apollo.MutationFunction; /** @@ -4104,6 +4205,64 @@ export function useSignUpInNewWorkspaceMutation(baseOptions?: Apollo.MutationHoo export type SignUpInNewWorkspaceMutationHookResult = ReturnType; export type SignUpInNewWorkspaceMutationResult = Apollo.MutationResult; export type SignUpInNewWorkspaceMutationOptions = Apollo.BaseMutationOptions; +export const SignUpInWorkspaceDocument = gql` + mutation SignUpInWorkspace($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String, $locale: String, $verifyEmailNextPath: String) { + signUpInWorkspace( + email: $email + password: $password + workspaceInviteHash: $workspaceInviteHash + workspacePersonalInviteToken: $workspacePersonalInviteToken + captchaToken: $captchaToken + workspaceId: $workspaceId + locale: $locale + verifyEmailNextPath: $verifyEmailNextPath + ) { + loginToken { + ...AuthTokenFragment + } + workspace { + id + workspaceUrls { + subdomainUrl + customUrl + } + } + } +} + ${AuthTokenFragmentFragmentDoc}`; +export type SignUpInWorkspaceMutationFn = Apollo.MutationFunction; + +/** + * __useSignUpInWorkspaceMutation__ + * + * To run a mutation, you first call `useSignUpInWorkspaceMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useSignUpInWorkspaceMutation` 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 [signUpInWorkspaceMutation, { data, loading, error }] = useSignUpInWorkspaceMutation({ + * variables: { + * email: // value for 'email' + * password: // value for 'password' + * workspaceInviteHash: // value for 'workspaceInviteHash' + * workspacePersonalInviteToken: // value for 'workspacePersonalInviteToken' + * captchaToken: // value for 'captchaToken' + * workspaceId: // value for 'workspaceId' + * locale: // value for 'locale' + * verifyEmailNextPath: // value for 'verifyEmailNextPath' + * }, + * }); + */ +export function useSignUpInWorkspaceMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SignUpInWorkspaceDocument, options); + } +export type SignUpInWorkspaceMutationHookResult = ReturnType; +export type SignUpInWorkspaceMutationResult = Apollo.MutationResult; +export type SignUpInWorkspaceMutationOptions = Apollo.BaseMutationOptions; export const UpdatePasswordViaResetTokenDocument = gql` mutation UpdatePasswordViaResetToken($token: String!, $newPassword: String!) { updatePasswordViaResetToken( @@ -4144,30 +4303,9 @@ export type UpdatePasswordViaResetTokenMutationOptions = Apollo.BaseMutationOpti export const CheckUserExistsDocument = gql` query CheckUserExists($email: String!, $captchaToken: String) { checkUserExists(email: $email, captchaToken: $captchaToken) { - __typename - ... on UserExists { - exists - availableWorkspaces { - id - displayName - workspaceUrls { - subdomainUrl - customUrl - } - logo - sso { - type - id - issuer - name - status - } - } - isEmailVerified - } - ... on UserNotExists { - exists - } + exists + availableWorkspacesCount + isEmailVerified } } `; @@ -4207,8 +4345,7 @@ export const GetPublicWorkspaceDataByDomainDocument = gql` logo displayName workspaceUrls { - subdomainUrl - customUrl + ...WorkspaceUrlsFragment } authProviders { sso { @@ -4225,7 +4362,7 @@ export const GetPublicWorkspaceDataByDomainDocument = gql` } } } - `; + ${WorkspaceUrlsFragmentFragmentDoc}`; /** * __useGetPublicWorkspaceDataByDomainQuery__ diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index 927a01dfe..cdbc48636 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -12,6 +12,7 @@ import { OnboardingStatus } from '~/generated/graphql'; import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation'; import { UNTESTED_APP_PATHS } from '~/testing/constants/UntestedAppPaths'; import { isMatchingLocation } from '~/utils/isMatchingLocation'; +import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'; jest.mock('@/onboarding/hooks/useOnboardingStatus'); const setupMockOnboardingStatus = ( @@ -50,6 +51,11 @@ jest.mocked(useDefaultHomePagePath).mockReturnValue({ defaultHomePagePath, }); +jest.mock('@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'); +jest.mocked(useIsCurrentLocationOnAWorkspace).mockReturnValue({ + isOnAWorkspace: true, +}); + jest.mock('react-router-dom'); const setupMockUseParams = (objectNamePlural?: string) => { jest diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts index 268fd895a..90193a558 100644 --- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -12,9 +12,11 @@ import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { OnboardingStatus } from '~/generated/graphql'; import { isMatchingLocation } from '~/utils/isMatchingLocation'; +import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'; export const usePageChangeEffectNavigateLocation = () => { const isLoggedIn = useIsLogged(); + const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace(); const onboardingStatus = useOnboardingStatus(); const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo( WorkspaceActivationStatus.SUSPENDED, @@ -41,13 +43,13 @@ export const usePageChangeEffectNavigateLocation = () => { const objectNamePlural = useParams().objectNamePlural ?? ''; const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - const objectMetadataItem = objectMetadataItems.find( + const objectMetadataItem = objectMetadataItems?.find( (objectMetadataItem) => objectMetadataItem.namePlural === objectNamePlural, ); const verifyEmailNextPath = useRecoilValue(verifyEmailNextPathState); if ( - !isLoggedIn && + (!isLoggedIn || (isLoggedIn && !isOnAWorkspace)) && !someMatchingLocationOf([ ...onGoingUserCreationPaths, AppPath.ResetPassword, diff --git a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts index 77cab6049..55c71bffb 100644 --- a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts +++ b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts @@ -8,7 +8,6 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { previousUrlState } from '@/auth/states/previousUrlState'; import { tokenPairState } from '@/auth/states/tokenPairState'; -import { workspacesState } from '@/auth/states/workspaces'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { useUpdateEffect } from '~/hooks/useUpdateEffect'; import { isMatchingLocation } from '~/utils/isMatchingLocation'; @@ -33,8 +32,7 @@ export const useApolloFactory = (options: Partial> = {}) => { const setCurrentUser = useSetRecoilState(currentUserState); const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState); - const setWorkspaces = useSetRecoilState(workspacesState); - const [, setPreviousUrl] = useRecoilState(previousUrlState); + const setPreviousUrl = useSetRecoilState(previousUrlState); const location = useLocation(); const apolloClient = useMemo(() => { @@ -65,7 +63,6 @@ export const useApolloFactory = (options: Partial> = {}) => { setCurrentWorkspaceMember(null); setCurrentWorkspace(null); setCurrentUserWorkspace(null); - setWorkspaces([]); if ( !isMatchingLocation(location, AppPath.Verify) && !isMatchingLocation(location, AppPath.SignInUp) && @@ -89,7 +86,6 @@ export const useApolloFactory = (options: Partial> = {}) => { setCurrentUser, setCurrentWorkspaceMember, setCurrentWorkspace, - setWorkspaces, setPreviousUrl, ]); diff --git a/packages/twenty-front/src/modules/auth/components/Logo.tsx b/packages/twenty-front/src/modules/auth/components/Logo.tsx index bb4ea47ba..4ade282f9 100644 --- a/packages/twenty-front/src/modules/auth/components/Logo.tsx +++ b/packages/twenty-front/src/modules/auth/components/Logo.tsx @@ -11,6 +11,7 @@ type LogoProps = { primaryLogo?: string | null; secondaryLogo?: string | null; placeholder?: string | null; + onClick?: () => void; }; const StyledContainer = styled.div` @@ -53,6 +54,7 @@ export const Logo = ({ primaryLogo, secondaryLogo, placeholder, + onClick, }: LogoProps) => { const { redirectToDefaultDomain } = useRedirectToDefaultDomain(); const defaultPrimaryLogoUrl = `${window.location.origin}/images/icons/android/android-launchericon-192-192.png`; @@ -72,7 +74,7 @@ export const Logo = ({ const isUsingDefaultLogo = !isDefined(primaryLogo); return ( - + onClick?.()}> {isUsingDefaultLogo ? ( ({ + data: { + signUpInWorkspace: results.signUpInWorkspace, + }, + })), + }, +}; diff --git a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx index 0cb9d1e0a..ee6b641cd 100644 --- a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx +++ b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx @@ -2,6 +2,7 @@ import { useAuth } from '@/auth/hooks/useAuth'; import { billingState } from '@/client-config/states/billingState'; import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState'; import { supportChatState } from '@/client-config/states/supportChatState'; +import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { useApolloClient } from '@apollo/client'; import { MockedProvider } from '@apollo/client/testing'; @@ -30,9 +31,13 @@ jest.mock('@/object-metadata/hooks/useRefreshObjectMetadataItem', () => ({ })); const Wrapper = ({ children }: { children: ReactNode }) => ( - + - {children} + + + {children} + + ); @@ -63,7 +68,7 @@ describe('useAuth', () => { ).toStrictEqual(results.getLoginTokenFromCredentials); }); - expect(mocks[0].result).toHaveBeenCalled(); + expect(mocks.getLoginTokenFromCredentials.result).toHaveBeenCalled(); }); it('should verify user', async () => { @@ -73,19 +78,19 @@ describe('useAuth', () => { await result.current.getAuthTokensFromLoginToken(token); }); - expect(mocks[1].result).toHaveBeenCalled(); - expect(mocks[3].result).toHaveBeenCalled(); + expect(mocks.getAuthTokensFromLoginToken.result).toHaveBeenCalled(); + expect(mocks.getCurrentUser.result).toHaveBeenCalled(); }); it('should handle credential sign-in', async () => { const { result } = renderHooks(); await act(async () => { - await result.current.signInWithCredentials(email, password); + await result.current.signInWithCredentialsInWorkspace(email, password); }); - expect(mocks[0].result).toHaveBeenCalled(); - expect(mocks[1].result).toHaveBeenCalled(); + expect(mocks.getLoginTokenFromCredentials.result).toHaveBeenCalled(); + expect(mocks.getAuthTokensFromLoginToken.result).toHaveBeenCalled(); }); it('should handle google sign-in', async () => { @@ -94,6 +99,7 @@ describe('useAuth', () => { await act(async () => { await result.current.signInWithGoogle({ workspaceInviteHash: 'workspaceInviteHash', + action: 'join-workspace', }); }); @@ -163,9 +169,12 @@ describe('useAuth', () => { const { result } = renderHooks(); await act(async () => { - await result.current.signUpWithCredentials({ email, password }); + await result.current.signUpWithCredentialsInWorkspace({ + email, + password, + }); }); - expect(mocks[2].result).toHaveBeenCalled(); + expect(mocks.signUpInWorkspace.result).toHaveBeenCalled(); }); }); diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index d1a4eb315..1d2721b17 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -11,19 +11,21 @@ import { import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { workspacesState } from '@/auth/states/workspaces'; import { billingState } from '@/client-config/states/billingState'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { supportChatState } from '@/client-config/states/supportChatState'; import { ColorScheme } from '@/workspace-member/types/WorkspaceMember'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { + AuthTokenPair, useCheckUserExistsLazyQuery, useGetAuthTokensFromLoginTokenMutation, useGetCurrentUserLazyQuery, useGetLoginTokenFromCredentialsMutation, useGetLoginTokenFromEmailVerificationTokenMutation, useSignUpMutation, + useSignInMutation, + useSignUpInWorkspaceMutation, } from '~/generated/graphql'; import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates'; @@ -68,10 +70,17 @@ import { iconsState } from 'twenty-ui/display'; import { cookieStorage } from '~/utils/cookie-storage'; import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; +import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState'; +import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace'; +import { + countAvailableWorkspaces, + getFirstAvailableWorkspaces, +} from '@/auth/utils/availableWorkspacesUtils'; export const useAuth = () => { const setTokenPair = useSetRecoilState(tokenPairState); const setCurrentUser = useSetRecoilState(currentUserState); + const setAvailableWorkspaces = useSetRecoilState(availableWorkspacesState); const setCurrentWorkspaceMember = useSetRecoilState( currentWorkspaceMemberState, ); @@ -87,16 +96,18 @@ export const useAuth = () => { ); const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems(); + const { createWorkspace } = useSignUpInNewWorkspace(); const setSignInUpStep = useSetRecoilState(signInUpStepState); const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); - const setWorkspaces = useSetRecoilState(workspacesState); const { redirect } = useRedirect(); const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); const [getLoginTokenFromCredentials] = useGetLoginTokenFromCredentialsMutation(); + const [signIn] = useSignInMutation(); const [signUp] = useSignUpMutation(); + const [signUpInWorkspace] = useSignUpInWorkspaceMutation(); const [getAuthTokensFromLoginToken] = useGetAuthTokensFromLoginTokenMutation(); const [getLoginTokenFromEmailVerificationToken] = @@ -280,6 +291,10 @@ export const useAuth = () => { setCurrentWorkspaceMembers(workspaceMembers); } + if (isDefined(user.availableWorkspaces)) { + setAvailableWorkspaces(user.availableWorkspaces); + } + if (isDefined(user.currentUserWorkspace)) { setCurrentUserWorkspace(user.currentUserWorkspace); } @@ -326,17 +341,6 @@ export const useAuth = () => { }); } - if (isDefined(user.workspaces)) { - const validWorkspaces = user.workspaces - .filter( - ({ workspace }) => workspace !== null && workspace !== undefined, - ) - .map((validWorkspace) => validWorkspace.workspace) - .filter(isDefined); - - setWorkspaces(validWorkspaces); - } - return { user, workspaceMember, @@ -352,9 +356,17 @@ export const useAuth = () => { setCurrentWorkspaceMembers, setDateTimeFormat, setLastAuthenticateWorkspaceDomain, - setWorkspaces, + setAvailableWorkspaces, ]); + const handleSetAuthTokens = useCallback( + (tokens: AuthTokenPair) => { + setTokenPair(tokens); + cookieStorage.setItem('tokenPair', JSON.stringify(tokens)); + }, + [setTokenPair], + ); + const handleGetAuthTokensFromLoginToken = useCallback( async (loginToken: string) => { const getAuthTokensResult = await getAuthTokensFromLoginToken({ @@ -372,14 +384,8 @@ export const useAuth = () => { throw new Error('No getAuthTokensFromLoginToken result'); } - setTokenPair( - getAuthTokensResult.data?.getAuthTokensFromLoginToken.tokens, - ); - cookieStorage.setItem( - 'tokenPair', - JSON.stringify( - getAuthTokensResult.data?.getAuthTokensFromLoginToken.tokens, - ), + handleSetAuthTokens( + getAuthTokensResult.data.getAuthTokensFromLoginToken.tokens, ); // TODO: We can't parallelize this yet because when loadCurrentUSer is loaded @@ -390,14 +396,97 @@ export const useAuth = () => { }, [ getAuthTokensFromLoginToken, - setTokenPair, loadCurrentUser, origin, + handleSetAuthTokens, refreshObjectMetadataItems, ], ); const handleCredentialsSignIn = useCallback( + async (email: string, password: string, captchaToken?: string) => { + signIn({ + variables: { email, password, captchaToken }, + onCompleted: async (data) => { + handleSetAuthTokens(data.signIn.tokens); + const { user } = await loadCurrentUser(); + + const availableWorkspacesCount = countAvailableWorkspaces( + user.availableWorkspaces, + ); + + if (availableWorkspacesCount === 0) { + return createWorkspace(); + } + + if (availableWorkspacesCount === 1) { + const targetWorkspace = getFirstAvailableWorkspaces( + user.availableWorkspaces, + ); + return await redirectToWorkspaceDomain( + getWorkspaceUrl(targetWorkspace.workspaceUrls), + targetWorkspace.loginToken ? AppPath.Verify : AppPath.SignInUp, + { + ...(targetWorkspace.loginToken && { + loginToken: targetWorkspace.loginToken, + }), + email: user.email, + }, + ); + } + + setSignInUpStep(SignInUpStep.WorkspaceSelection); + }, + onError: (error) => { + if ( + error instanceof ApolloError && + error.graphQLErrors[0]?.extensions?.subCode === 'EMAIL_NOT_VERIFIED' + ) { + setSearchParams({ email }); + setSignInUpStep(SignInUpStep.EmailVerification); + throw error; + } + throw error; + }, + }); + }, + [ + handleSetAuthTokens, + redirectToWorkspaceDomain, + signIn, + loadCurrentUser, + setSearchParams, + setSignInUpStep, + createWorkspace, + ], + ); + + const handleCredentialsSignUp = useCallback( + async (email: string, password: string, captchaToken?: string) => { + signUp({ + variables: { email, password, captchaToken }, + onCompleted: async (data) => { + handleSetAuthTokens(data.signUp.tokens); + const { user } = await loadCurrentUser(); + + if (countAvailableWorkspaces(user.availableWorkspaces) === 0) { + return createWorkspace(); + } + + setSignInUpStep(SignInUpStep.WorkspaceSelection); + }, + }); + }, + [ + handleSetAuthTokens, + signUp, + loadCurrentUser, + setSignInUpStep, + createWorkspace, + ], + ); + + const handleCredentialsSignInInWorkspace = useCallback( async (email: string, password: string, captchaToken?: string) => { const { loginToken } = await handleGetLoginTokenFromCredentials( email, @@ -413,7 +502,7 @@ export const useAuth = () => { await clearSession(); }, [clearSession]); - const handleCredentialsSignUp = useCallback( + const handleCredentialsSignUpInWorkspace = useCallback( async ({ email, password, @@ -429,7 +518,7 @@ export const useAuth = () => { captchaToken?: string; verifyEmailNextPath?: string; }) => { - const signUpResult = await signUp({ + const signUpInWorkspaceResult = await signUpInWorkspace({ variables: { email, password, @@ -444,11 +533,11 @@ export const useAuth = () => { }, }); - if (isDefined(signUpResult.errors)) { - throw signUpResult.errors; + if (isDefined(signUpInWorkspaceResult.errors)) { + throw signUpInWorkspaceResult.errors; } - if (!signUpResult.data?.signUp) { + if (!signUpInWorkspaceResult.data?.signUpInWorkspace) { throw new Error('No login token'); } @@ -460,11 +549,15 @@ export const useAuth = () => { if (isMultiWorkspaceEnabled) { return await redirectToWorkspaceDomain( - getWorkspaceUrl(signUpResult.data.signUp.workspace.workspaceUrls), + getWorkspaceUrl( + signUpInWorkspaceResult.data.signUpInWorkspace.workspace + .workspaceUrls, + ), isEmailVerificationRequired ? AppPath.SignInUp : AppPath.Verify, { ...(!isEmailVerificationRequired && { - loginToken: signUpResult.data.signUp.loginToken.token, + loginToken: + signUpInWorkspaceResult.data.signUpInWorkspace.loginToken.token, }), email, }, @@ -472,11 +565,11 @@ export const useAuth = () => { } await handleGetAuthTokensFromLoginToken( - signUpResult.data?.signUp.loginToken.token, + signUpInWorkspaceResult.data?.signUpInWorkspace.loginToken.token, ); }, [ - signUp, + signUpInWorkspace, workspacePublicData, isMultiWorkspaceEnabled, handleGetAuthTokensFromLoginToken, @@ -494,6 +587,7 @@ export const useAuth = () => { workspacePersonalInviteToken?: string; workspaceInviteHash?: string; billingCheckoutSession?: BillingCheckoutSession; + action?: string; }, ) => { const url = new URL(`${REACT_APP_SERVER_BASE_URL}${path}`); @@ -513,6 +607,10 @@ export const useAuth = () => { ); } + if (isDefined(params.action)) { + url.searchParams.set('action', params.action); + } + if (isDefined(workspacePublicData)) { url.searchParams.set('workspaceId', workspacePublicData.id); } @@ -527,6 +625,7 @@ export const useAuth = () => { workspacePersonalInviteToken?: string; workspaceInviteHash?: string; billingCheckoutSession?: BillingCheckoutSession; + action: string; }) => { redirect(buildRedirectUrl('/auth/google', params)); }, @@ -538,6 +637,7 @@ export const useAuth = () => { workspacePersonalInviteToken?: string; workspaceInviteHash?: string; billingCheckoutSession?: BillingCheckoutSession; + action: string; }) => { redirect(buildRedirectUrl('/auth/microsoft', params)); }, @@ -556,8 +656,11 @@ export const useAuth = () => { clearSession, signOut: handleSignOut, signUpWithCredentials: handleCredentialsSignUp, + signUpWithCredentialsInWorkspace: handleCredentialsSignUpInWorkspace, + signInWithCredentialsInWorkspace: handleCredentialsSignInInWorkspace, signInWithCredentials: handleCredentialsSignIn, signInWithGoogle: handleGoogleLogin, signInWithMicrosoft: handleMicrosoftLogin, + setAuthTokens: handleSetAuthTokens, }; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index 712dcc5f9..71debd21c 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -2,14 +2,17 @@ import styled from '@emotion/styled'; import { motion } from 'framer-motion'; import { useState } from 'react'; import { FormProvider } from 'react-hook-form'; -import { useLocation } from 'react-router-dom'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import { useTheme } from '@emotion/react'; +import { useLingui } from '@lingui/react/macro'; +import { UndecoratedLink } from 'twenty-ui/navigation'; +import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl'; +import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState'; -import { useAuth } from '@/auth/hooks/useAuth'; -import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField'; -import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField'; -import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoogle'; -import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/SignInUpWithMicrosoft'; +import { SignInUpEmailField } from '@/auth/sign-in-up/components/internal/SignInUpEmailField'; +import { SignInUpPasswordField } from '@/auth/sign-in-up/components/internal/SignInUpPasswordField'; +import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/internal/SignInUpWithGoogle'; +import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/internal/SignInUpWithMicrosoft'; import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { signInUpModeState } from '@/auth/states/signInUpModeState'; @@ -17,19 +20,23 @@ import { SignInUpStep, signInUpStepState, } from '@/auth/states/signInUpStepState'; +import { getAvailableWorkspacePathAndSearchParams } from '@/auth/utils/availableWorkspacesUtils'; import { SignInUpMode } from '@/auth/types/signInUpMode'; -import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; -import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; import { authProvidersState } from '@/client-config/states/authProvidersState'; -import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; -import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { isDefined } from 'twenty-shared/utils'; -import { HorizontalSeparator } from 'twenty-ui/display'; +import { + HorizontalSeparator, + IconChevronRight, + IconPlus, + Avatar, +} from 'twenty-ui/display'; import { Loader } from 'twenty-ui/feedback'; import { MainButton } from 'twenty-ui/input'; import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; +import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; +import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace'; +import { AvailableWorkspace } from '~/generated/graphql'; const StyledContentContainer = styled(motion.div)` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -44,29 +51,101 @@ const StyledForm = styled.form` width: 100%; `; +const StyledWorkspaceContainer = styled.div` + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: ${({ theme }) => theme.border.radius.md}; + display: flex; + flex-direction: column; + margin-bottom: ${({ theme }) => theme.spacing(8)}; + margin-top: ${({ theme }) => theme.spacing(4)}; + overflow: hidden; + width: 100%; +`; + +const StyledWorkspaceItem = styled.div` + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + height: ${({ theme }) => theme.spacing(15)}; + padding: 0; + overflow: hidden; + + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + cursor: pointer; + justify-content: space-between; + + &:hover { + background-color: ${({ theme }) => theme.background.transparent.light}; + } + + &:last-child { + border-bottom: none; + } +`; + +const StyledWorkspaceContent = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; + width: 100%; + padding: 0 ${({ theme }) => theme.spacing(4)}; +`; + +const StyledWorkspaceTextContainer = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; +`; + +const StyledWorkspaceLogo = styled.div` + border-radius: ${({ theme }) => theme.border.radius.sm}; + height: ${({ theme }) => theme.spacing(6)}; + width: ${({ theme }) => theme.spacing(6)}; + background-color: ${({ theme }) => theme.background.transparent.light}; + display: flex; + justify-content: center; + align-items: center; +`; + +const StyledWorkspaceName = styled.div` + color: ${({ theme }) => theme.font.color.primary}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + padding-bottom: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledWorkspaceUrl = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.xs}; +`; + +const StyledChevronIcon = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; +`; + export const SignInUpGlobalScopeForm = () => { const authProviders = useRecoilValue(authProvidersState); const signInUpStep = useRecoilValue(signInUpStepState); + const { buildWorkspaceUrl } = useBuildWorkspaceUrl(); - const { checkUserExists } = useAuth(); - const { readCaptchaToken } = useReadCaptchaToken(); - const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); + const { createWorkspace } = useSignUpInNewWorkspace(); const setSignInUpStep = useSetRecoilState(signInUpStepState); - const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState); + const [signInUpMode] = useRecoilState(signInUpModeState); + const availableWorkspaces = useRecoilValue(availableWorkspacesState); + const theme = useTheme(); + const { t } = useLingui(); const isRequestingCaptchaToken = useRecoilValue( isRequestingCaptchaTokenState, ); - const { enqueueSnackBar } = useSnackBar(); - const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken(); - const [showErrors, setShowErrors] = useState(false); const { form } = useSignInUpForm(); - const { pathname } = useLocation(); - const { submitCredentials } = useSignInUp(form); + const { submitCredentials, continueWithCredentials } = useSignInUp(form); const handleSubmit = async () => { if (isDefined(form?.formState?.errors?.email)) { @@ -79,38 +158,7 @@ export const SignInUpGlobalScopeForm = () => { return; } - const token = await readCaptchaToken(); - await checkUserExists.checkUserExistsQuery({ - variables: { - email: form.getValues('email').toLowerCase().trim(), - captchaToken: token, - }, - onError: (error) => { - enqueueSnackBar(`${error.message}`, { - variant: SnackBarVariant.Error, - }); - }, - onCompleted: async (data) => { - requestFreshCaptchaToken(); - const response = data.checkUserExists; - if (response.__typename === 'UserExists') { - if (response.availableWorkspaces.length >= 1) { - const workspace = response.availableWorkspaces[0]; - return await redirectToWorkspaceDomain( - getWorkspaceUrl(workspace.workspaceUrls), - pathname, - { - email: form.getValues('email'), - }, - ); - } - } - if (response.__typename === 'UserNotExists') { - setSignInUpMode(SignInUpMode.SignUp); - setSignInUpStep(SignInUpStep.Password); - } - }, - }); + continueWithCredentials(); }; const onEmailChange = (email: string) => { @@ -119,42 +167,118 @@ export const SignInUpGlobalScopeForm = () => { } }; + const getAvailableWorkspaceUrl = (availableWorkspace: AvailableWorkspace) => { + const { pathname, searchParams } = getAvailableWorkspacePathAndSearchParams( + availableWorkspace, + { email: form.getValues('email') }, + ); + + return buildWorkspaceUrl( + getWorkspaceUrl(availableWorkspace.workspaceUrls), + pathname, + searchParams, + ); + }; + return ( <> - - {authProviders.google && } - {authProviders.microsoft && } - {(authProviders.google || authProviders.microsoft) && ( - - )} - {/* eslint-disable-next-line react/jsx-props-no-spreading */} - - - - {signInUpStep === SignInUpStep.Password && ( - + {[ + ...availableWorkspaces.availableWorkspacesForSignIn, + ...availableWorkspaces.availableWorkspacesForSignUp, + ].map((availableWorkspace) => ( + + + + + + + {availableWorkspace.displayName || availableWorkspace.id} + + + { + new URL( + getWorkspaceUrl(availableWorkspace.workspaceUrls), + ).hostname + } + + + + + + + + + ))} + createWorkspace()}> + + + + + + {t`Create a workspace`} + + + + + + + + )} + {signInUpStep !== SignInUpStep.WorkspaceSelection && ( + + {authProviders.google && ( + + )} + {authProviders.microsoft && ( + + )} + {(authProviders.google || authProviders.microsoft) && ( + + )} + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + - )} - (form.formState.isSubmitting ? : null)} - fullWidth - /> - - - + {signInUpStep === SignInUpStep.Password && ( + + )} + (form.formState.isSubmitting ? : null)} + fullWidth + /> + + + + )} ); }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx index 411bc6efc..5a812a19e 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx @@ -1,7 +1,7 @@ -import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/SignInUpWithCredentials'; -import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoogle'; -import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/SignInUpWithMicrosoft'; -import { SignInUpWithSSO } from '@/auth/sign-in-up/components/SignInUpWithSSO'; +import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/internal/SignInUpWithCredentials'; +import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/internal/SignInUpWithGoogle'; +import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/internal/SignInUpWithMicrosoft'; +import { SignInUpWithSSO } from '@/auth/sign-in-up/components/internal/SignInUpWithSSO'; import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; @@ -36,9 +36,13 @@ export const SignInUpWorkspaceScopeForm = () => { return ( <> - {workspaceAuthProviders.google && } + {workspaceAuthProviders.google && ( + + )} - {workspaceAuthProviders.microsoft && } + {workspaceAuthProviders.microsoft && ( + + )} {workspaceAuthProviders.sso.length > 0 && } diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpEmailField.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpEmailField.tsx similarity index 100% rename from packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpEmailField.tsx rename to packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpEmailField.tsx diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx new file mode 100644 index 000000000..1b6a6886a --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx @@ -0,0 +1,34 @@ +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { useEffect } from 'react'; +import { useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { useAuth } from '@/auth/hooks/useAuth'; +import { useSearchParams } from 'react-router-dom'; + +export const SignInUpGlobalScopeFormEffect = () => { + const setSignInUpStep = useSetRecoilState(signInUpStepState); + const [searchParams, setSearchParams] = useSearchParams(); + const { setAuthTokens, loadCurrentUser } = useAuth(); + + useEffect(() => { + const tokenPair = searchParams.get('tokenPair'); + if (isDefined(tokenPair)) { + setAuthTokens(JSON.parse(tokenPair)); + searchParams.delete('tokenPair'); + setSearchParams(searchParams); + loadCurrentUser(); + setSignInUpStep(SignInUpStep.WorkspaceSelection); + } + }, [ + searchParams, + setSearchParams, + setSignInUpStep, + loadCurrentUser, + setAuthTokens, + ]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpPasswordField.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpPasswordField.tsx similarity index 100% rename from packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpPasswordField.tsx rename to packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpPasswordField.tsx diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpSSOIdentityProviderSelection.tsx similarity index 100% rename from packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx rename to packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpSSOIdentityProviderSelection.tsx diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithCredentials.tsx similarity index 98% rename from packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx rename to packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithCredentials.tsx index a8317b567..331dff0b5 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithCredentials.tsx @@ -5,8 +5,8 @@ import { signInUpStepState, } from '@/auth/states/signInUpStepState'; -import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField'; -import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField'; +import { SignInUpEmailField } from '@/auth/sign-in-up/components/internal/SignInUpEmailField'; +import { SignInUpPasswordField } from '@/auth/sign-in-up/components/internal/SignInUpPasswordField'; import { SignInUpMode } from '@/auth/types/signInUpMode'; import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; import { captchaState } from '@/client-config/states/captchaState'; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithGoogle.tsx similarity index 80% rename from packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx rename to packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithGoogle.tsx index c1587532d..b4721e6c4 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithGoogle.tsx @@ -9,23 +9,27 @@ import { memo } from 'react'; import { useRecoilValue } from 'recoil'; import { HorizontalSeparator, IconGoogle } from 'twenty-ui/display'; import { MainButton } from 'twenty-ui/input'; +import { SocialSSOSignInUpActionType } from '@/auth/types/socialSSOSignInUp.type'; const GoogleIcon = memo(() => { const theme = useTheme(); return ; }); -export const SignInUpWithGoogle = () => { +export const SignInUpWithGoogle = ({ + action, +}: { + action: SocialSSOSignInUpActionType; +}) => { const { t } = useLingui(); const signInUpStep = useRecoilValue(signInUpStepState); const { signInWithGoogle } = useSignInWithGoogle(); - return ( <> signInWithGoogle({ action })} variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'} fullWidth /> diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithMicrosoft.tsx similarity index 79% rename from packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx rename to packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithMicrosoft.tsx index c491c87c2..b343a130b 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithMicrosoft.tsx @@ -8,8 +8,13 @@ import { useLingui } from '@lingui/react/macro'; import { useRecoilValue } from 'recoil'; import { HorizontalSeparator, IconMicrosoft } from 'twenty-ui/display'; import { MainButton } from 'twenty-ui/input'; +import { SocialSSOSignInUpActionType } from '@/auth/types/socialSSOSignInUp.type'; -export const SignInUpWithMicrosoft = () => { +export const SignInUpWithMicrosoft = ({ + action, +}: { + action: SocialSSOSignInUpActionType; +}) => { const theme = useTheme(); const { t } = useLingui(); @@ -21,7 +26,7 @@ export const SignInUpWithMicrosoft = () => { } title={t`Continue with Microsoft`} - onClick={signInWithMicrosoft} + onClick={() => signInWithMicrosoft({ action })} variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'} fullWidth /> diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithSSO.tsx similarity index 100% rename from packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx rename to packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithSSO.tsx diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceScopeFormEffect.tsx similarity index 100% rename from packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect.tsx rename to packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceScopeFormEffect.tsx diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithGoogle.test.ts index 90dde88b0..faa494074 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithGoogle.test.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithGoogle.test.ts @@ -43,9 +43,12 @@ describe('useSignInWithGoogle', () => { const { result } = renderHook(() => useSignInWithGoogle(), { wrapper: Wrapper, }); - result.current.signInWithGoogle(); + result.current.signInWithGoogle({ + action: 'join-workspace', + }); expect(signInWithGoogleMock).toHaveBeenCalledWith({ + action: 'join-workspace', workspaceInviteHash: 'testHash', workspacePersonalInviteToken: 'testToken', billingCheckoutSession: mockBillingCheckoutSession, @@ -66,9 +69,12 @@ describe('useSignInWithGoogle', () => { const { result } = renderHook(() => useSignInWithGoogle(), { wrapper: Wrapper, }); - result.current.signInWithGoogle(); + result.current.signInWithGoogle({ + action: 'join-workspace', + }); expect(signInWithGoogleMock).toHaveBeenCalledWith({ + action: 'join-workspace', workspaceInviteHash: 'testHash', workspacePersonalInviteToken: undefined, billingCheckoutSession: mockBillingCheckoutSession, diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithMicrosoft.test.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithMicrosoft.test.ts index 577ee7202..ff89f2bcf 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithMicrosoft.test.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useSignInWithMicrosoft.test.ts @@ -43,9 +43,12 @@ describe('useSignInWithMicrosoft', () => { const { result } = renderHook(() => useSignInWithMicrosoft(), { wrapper: Wrapper, }); - result.current.signInWithMicrosoft(); + result.current.signInWithMicrosoft({ + action: 'join-workspace', + }); expect(signInWithMicrosoftMock).toHaveBeenCalledWith({ + action: 'join-workspace', workspaceInviteHash: workspaceInviteHashMock, workspacePersonalInviteToken: inviteTokenMock, billingCheckoutSession: mockBillingCheckoutSession, @@ -67,9 +70,12 @@ describe('useSignInWithMicrosoft', () => { const { result } = renderHook(() => useSignInWithMicrosoft(), { wrapper: Wrapper, }); - result.current.signInWithMicrosoft(); + result.current.signInWithMicrosoft({ + action: 'join-workspace', + }); expect(signInWithMicrosoftMock).toHaveBeenCalledWith({ + action: 'join-workspace', billingCheckoutSession: mockBillingCheckoutSession, workspaceInviteHash: workspaceInviteHashMock, workspacePersonalInviteToken: undefined, diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts index 5344fe297..8ff136e91 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts @@ -19,12 +19,14 @@ import { useRecoilState } from 'recoil'; import { buildAppPathWithQueryParams } from '~/utils/buildAppPathWithQueryParams'; import { isMatchingLocation } from '~/utils/isMatchingLocation'; import { useAuth } from '../../hooks/useAuth'; +import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'; export const useSignInUp = (form: UseFormReturn
) => { const { enqueueSnackBar } = useSnackBar(); const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState); const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState); + const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace(); const location = useLocation(); @@ -38,7 +40,9 @@ export const useSignInUp = (form: UseFormReturn) => { ); const { + signInWithCredentialsInWorkspace, signInWithCredentials, + signUpWithCredentialsInWorkspace, signUpWithCredentials, checkUserExists: { checkUserExistsQuery }, } = useAuth(); @@ -71,11 +75,11 @@ export const useSignInUp = (form: UseFormReturn) => { }, onCompleted: (data) => { requestFreshCaptchaToken(); - if (data?.checkUserExists.exists) { - setSignInUpMode(SignInUpMode.SignIn); - } else { - setSignInUpMode(SignInUpMode.SignUp); - } + setSignInUpMode( + data?.checkUserExists.exists + ? SignInUpMode.SignIn + : SignInUpMode.SignUp, + ); setSignInUpStep(SignInUpStep.Password); }, }); @@ -97,31 +101,60 @@ export const useSignInUp = (form: UseFormReturn) => { throw new Error('Email and password are required'); } - if (signInUpMode === SignInUpMode.SignIn && !isInviteMode) { - await signInWithCredentials( + if ( + !isInviteMode && + signInUpMode === SignInUpMode.SignIn && + isOnAWorkspace + ) { + return await signInWithCredentialsInWorkspace( data.email.toLowerCase().trim(), data.password, token, ); - } else { - const verifyEmailNextPath = buildAppPathWithQueryParams( - AppPath.PlanRequired, - await buildSearchParamsFromUrlSyncedStates(), - ); - - await signUpWithCredentials({ - email: data.email.toLowerCase().trim(), - password: data.password, - workspaceInviteHash, - workspacePersonalInviteToken, - captchaToken: token, - verifyEmailNextPath, - }); } + + if ( + !isInviteMode && + signInUpMode === SignInUpMode.SignIn && + !isOnAWorkspace + ) { + return await signInWithCredentials( + data.email.toLowerCase().trim(), + data.password, + token, + ); + } + + if ( + !isInviteMode && + signInUpMode === SignInUpMode.SignUp && + !isOnAWorkspace + ) { + return await signUpWithCredentials( + data.email.toLowerCase().trim(), + data.password, + token, + ); + } + + const verifyEmailNextPath = buildAppPathWithQueryParams( + AppPath.PlanRequired, + await buildSearchParamsFromUrlSyncedStates(), + ); + + await signUpWithCredentialsInWorkspace({ + email: data.email.toLowerCase().trim(), + password: data.password, + workspaceInviteHash, + workspacePersonalInviteToken, + captchaToken: token, + verifyEmailNextPath, + }); } catch (err: any) { enqueueSnackBar(err?.message, { variant: SnackBarVariant.Error, }); + } finally { requestFreshCaptchaToken(); } }, @@ -129,13 +162,16 @@ export const useSignInUp = (form: UseFormReturn) => { readCaptchaToken, signInUpMode, isInviteMode, + signInWithCredentialsInWorkspace, signInWithCredentials, signUpWithCredentials, + signUpWithCredentialsInWorkspace, workspaceInviteHash, workspacePersonalInviteToken, enqueueSnackBar, requestFreshCaptchaToken, buildSearchParamsFromUrlSyncedStates, + isOnAWorkspace, ], ); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts index 42fe9d7b0..dcef06c2c 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; -import { useLocation, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { z } from 'zod'; @@ -30,7 +30,6 @@ const makeValidationSchema = (signInUpStep: SignInUpStep) => export type Form = z.infer>; export const useSignInUpForm = () => { - const location = useLocation(); const signInUpStep = useRecoilValue(signInUpStepState); const validationSchema = makeValidationSchema(signInUpStep); // Create schema based on the current step @@ -61,11 +60,6 @@ export const useSignInUpForm = () => { form.setValue('email', prefilledEmail ?? 'tim@apple.dev'); form.setValue('password', 'tim@apple.dev'); } - }, [ - form, - isDeveloperDefaultSignInPrefilled, - prefilledEmail, - location.search, - ]); + }, [form, isDeveloperDefaultSignInPrefilled, prefilledEmail]); return { form: form }; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithGoogle.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInWithGoogle.ts index 4d8361d03..edcbf1e63 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 @@ -2,6 +2,7 @@ import { useParams, useSearchParams } from 'react-router-dom'; import { useAuth } from '@/auth/hooks/useAuth'; import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type'; +import { SocialSSOSignInUpActionType } from '@/auth/types/socialSSOSignInUp.type'; export const useSignInWithGoogle = () => { const workspaceInviteHash = useParams().workspaceInviteHash; @@ -15,12 +16,14 @@ export const useSignInWithGoogle = () => { } as BillingCheckoutSession; const { signInWithGoogle } = useAuth(); + return { - signInWithGoogle: () => + signInWithGoogle: ({ action }: { action: SocialSSOSignInUpActionType }) => signInWithGoogle({ workspaceInviteHash, workspacePersonalInviteToken, billingCheckoutSession, + action, }), }; }; 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 513f77ac6..27aaf70ac 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 @@ -3,6 +3,7 @@ import { useParams, useSearchParams } from 'react-router-dom'; import { useAuth } from '@/auth/hooks/useAuth'; import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState'; import { useRecoilValue } from 'recoil'; +import { SocialSSOSignInUpActionType } from '@/auth/types/socialSSOSignInUp.type'; export const useSignInWithMicrosoft = () => { const workspaceInviteHash = useParams().workspaceInviteHash; @@ -13,11 +14,16 @@ export const useSignInWithMicrosoft = () => { const { signInWithMicrosoft } = useAuth(); return { - signInWithMicrosoft: () => + signInWithMicrosoft: ({ + action, + }: { + action: SocialSSOSignInUpActionType; + }) => signInWithMicrosoft({ workspaceInviteHash, workspacePersonalInviteToken, billingCheckoutSession, + action, }), }; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts new file mode 100644 index 000000000..4203df8a2 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts @@ -0,0 +1,37 @@ +import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; +import { AppPath } from '@/types/AppPath'; +import { useSignUpInNewWorkspaceMutation } from '~/generated/graphql'; +import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; + +export const useSignUpInNewWorkspace = () => { + const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); + const { enqueueSnackBar } = useSnackBar(); + + const [signUpInNewWorkspaceMutation] = useSignUpInNewWorkspaceMutation(); + + const createWorkspace = () => { + signUpInNewWorkspaceMutation({ + onCompleted: async (data) => { + return await redirectToWorkspaceDomain( + getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls), + AppPath.Verify, + { + loginToken: data.signUpInNewWorkspace.loginToken.token, + }, + '_blank', + ); + }, + onError: (error: Error) => { + enqueueSnackBar(error.message, { + variant: SnackBarVariant.Error, + }); + }, + }); + }; + + return { + createWorkspace, + }; +}; diff --git a/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts b/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts deleted file mode 100644 index a902a0152..000000000 --- a/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { UserExists } from '~/generated/graphql'; -import { createState } from 'twenty-ui/utilities'; - -export const availableSSOIdentityProvidersForAuthState = createState< - NonNullable[0]['sso'] ->({ - key: 'availableSSOIdentityProvidersForAuth', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/auth/states/availableWorkspacesState.ts b/packages/twenty-front/src/modules/auth/states/availableWorkspacesState.ts new file mode 100644 index 000000000..199bfd821 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/availableWorkspacesState.ts @@ -0,0 +1,10 @@ +import { createState } from 'twenty-ui/utilities'; +import { AvailableWorkspaces } from '~/generated/graphql'; + +export const availableWorkspacesState = createState({ + key: 'availableWorkspacesState', + defaultValue: { + availableWorkspacesForSignIn: [], + availableWorkspacesForSignUp: [], + }, +}); diff --git a/packages/twenty-front/src/modules/auth/states/workspaces.ts b/packages/twenty-front/src/modules/auth/states/workspaces.ts deleted file mode 100644 index 73fe34fe1..000000000 --- a/packages/twenty-front/src/modules/auth/states/workspaces.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Workspace } from '~/generated/graphql'; -import { createState } from 'twenty-ui/utilities'; - -export type Workspaces = Pick< - Workspace, - 'id' | 'logo' | 'displayName' | 'workspaceUrls' ->[]; - -export const workspacesState = createState({ - key: 'workspacesState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/auth/types/socialSSOSignInUp.type.ts b/packages/twenty-front/src/modules/auth/types/socialSSOSignInUp.type.ts new file mode 100644 index 000000000..d23bfda1f --- /dev/null +++ b/packages/twenty-front/src/modules/auth/types/socialSSOSignInUp.type.ts @@ -0,0 +1,4 @@ +export type SocialSSOSignInUpActionType = + | 'create-new-workspace' + | 'list-available-workspaces' + | 'join-workspace'; diff --git a/packages/twenty-front/src/modules/auth/utils/availableWorkspacesUtils.ts b/packages/twenty-front/src/modules/auth/utils/availableWorkspacesUtils.ts new file mode 100644 index 000000000..9f18107d6 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/utils/availableWorkspacesUtils.ts @@ -0,0 +1,70 @@ +import { AvailableWorkspaces, AvailableWorkspace } from '~/generated/graphql'; +import { AppPath } from '@/types/AppPath'; +import { isDefined } from 'twenty-shared/utils'; +import { generatePath } from 'react-router-dom'; + +export const countAvailableWorkspaces = ({ + availableWorkspacesForSignIn, + availableWorkspacesForSignUp, +}: AvailableWorkspaces): number => { + return ( + availableWorkspacesForSignIn.length + availableWorkspacesForSignUp.length + ); +}; + +export const getFirstAvailableWorkspaces = ({ + availableWorkspacesForSignIn, + availableWorkspacesForSignUp, +}: AvailableWorkspaces): AvailableWorkspace => { + return availableWorkspacesForSignIn[0] ?? availableWorkspacesForSignUp[0]; +}; + +const getAvailableWorkspacePathname = ( + availableWorkspace: AvailableWorkspace, +) => { + if (isDefined(availableWorkspace.loginToken)) { + return AppPath.Verify; + } + + if ( + isDefined(availableWorkspace.personalInviteToken) && + isDefined(availableWorkspace.inviteHash) + ) { + return generatePath(AppPath.Invite, { + workspaceInviteHash: availableWorkspace.inviteHash, + }); + } + + return AppPath.SignInUp; +}; + +const getAvailableWorkspaceSearchParams = ( + availableWorkspace: AvailableWorkspace, + defaultSearchParams: Record = {}, +) => { + const searchParams: Record = defaultSearchParams; + + if (isDefined(availableWorkspace.loginToken)) { + searchParams.loginToken = availableWorkspace.loginToken; + return searchParams; + } + + if (isDefined(availableWorkspace.personalInviteToken)) { + searchParams.inviteToken = availableWorkspace.personalInviteToken; + } + + return searchParams; +}; + +export const getAvailableWorkspacePathAndSearchParams = ( + availableWorkspace: AvailableWorkspace, + defaultSearchParams: Record = {}, +): { pathname: string; searchParams: Record } => { + return { + pathname: getAvailableWorkspacePathname(availableWorkspace), + searchParams: getAvailableWorkspaceSearchParams( + availableWorkspace, + defaultSearchParams, + ), + }; +}; diff --git a/packages/twenty-front/src/modules/captcha/hooks/__tests__/useReadCaptchaToken.test.tsx b/packages/twenty-front/src/modules/captcha/hooks/__tests__/useReadCaptchaToken.test.tsx new file mode 100644 index 000000000..3043c81ac --- /dev/null +++ b/packages/twenty-front/src/modules/captcha/hooks/__tests__/useReadCaptchaToken.test.tsx @@ -0,0 +1,43 @@ +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; + +import { captchaTokenState } from '@/captcha/states/captchaTokenState'; +import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; + +describe('useReadCaptchaToken', () => { + it('should return undefined when no token exists', async () => { + const { result } = renderHook(() => useReadCaptchaToken(), { + wrapper: RecoilRoot, + }); + + await act(async () => { + const token = await result.current.readCaptchaToken(); + expect(token).toBeUndefined(); + }); + }); + + it('should return the token when it exists', async () => { + const { result } = renderHook( + () => { + const hook = useReadCaptchaToken(); + return hook; + }, + { + wrapper: ({ children }) => ( + { + set(captchaTokenState, 'test-token'); + }} + > + {children} + + ), + }, + ); + + await act(async () => { + const token = await result.current.readCaptchaToken(); + expect(token).toBe('test-token'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/captcha/hooks/__tests__/useRequestFreshCaptchaToken.test.tsx b/packages/twenty-front/src/modules/captcha/hooks/__tests__/useRequestFreshCaptchaToken.test.tsx new file mode 100644 index 000000000..73b7c97cc --- /dev/null +++ b/packages/twenty-front/src/modules/captcha/hooks/__tests__/useRequestFreshCaptchaToken.test.tsx @@ -0,0 +1,66 @@ +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import * as ReactRouterDom from 'react-router-dom'; + +import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn(), +})); + +describe('useRequestFreshCaptchaToken', () => { + beforeEach(() => { + // Mock useLocation to return a path that requires captcha + (ReactRouterDom.useLocation as jest.Mock).mockReturnValue({ + pathname: '/sign-in', + }); + + // Mock window.grecaptcha + window.grecaptcha = { + execute: jest.fn().mockImplementation(() => { + return Promise.resolve('google-recaptcha-token'); + }), + }; + + // Mock window.turnstile + window.turnstile = { + render: jest.fn().mockReturnValue('turnstile-widget-id'), + execute: jest.fn().mockImplementation((widgetId, options) => { + return options?.callback('turnstile-token'); + }), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + delete window.grecaptcha; + delete window.turnstile; + }); + + it('should not request a token if captcha is not required for the path', async () => { + const { result } = renderHook(() => useRequestFreshCaptchaToken(), { + wrapper: RecoilRoot, + }); + + await act(async () => { + await result.current.requestFreshCaptchaToken(); + }); + + expect(window.grecaptcha.execute).not.toHaveBeenCalled(); + expect(window.turnstile.execute).not.toHaveBeenCalled(); + }); + + it('should not request a token if captcha provider is not defined', async () => { + const { result } = renderHook(() => useRequestFreshCaptchaToken(), { + wrapper: RecoilRoot, + }); + + await act(async () => { + await result.current.requestFreshCaptchaToken(); + }); + + expect(window.grecaptcha.execute).not.toHaveBeenCalled(); + expect(window.turnstile.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useIsCurrentLocationOnAWorkspace.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useIsCurrentLocationOnAWorkspace.ts index 553ba2e60..25590b27f 100644 --- a/packages/twenty-front/src/modules/domain-manager/hooks/useIsCurrentLocationOnAWorkspace.ts +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useIsCurrentLocationOnAWorkspace.ts @@ -18,8 +18,9 @@ export const useIsCurrentLocationOnAWorkspace = () => { throw new Error('frontDomain and defaultSubdomain are required'); } - const isOnAWorkspace = - isMultiWorkspaceEnabled && window.location.hostname !== defaultDomain; + const isOnAWorkspace = !isMultiWorkspaceEnabled + ? true + : window.location.hostname !== defaultDomain; return { isOnAWorkspace, diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts index 20965fbdb..30e7b6524 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts @@ -150,8 +150,7 @@ export const queries = { hasValidEnterpriseKey customDomain workspaceUrls { - subdomainUrl - customUrl + ...WorkspaceUrlsFragment } featureFlags { id @@ -179,8 +178,7 @@ export const queries = { subdomain customDomain workspaceUrls { - subdomainUrl - customUrl + ...WorkspaceUrlsFragment } } } diff --git a/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx b/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx index 51b4c0941..640e9fd58 100644 --- a/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx +++ b/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx @@ -14,6 +14,7 @@ import { currentUserState } from '@/auth/states/currentUserState'; import { billingState } from '@/client-config/states/billingState'; import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState'; import { useSettingsPermissionMap } from '@/settings/roles/hooks/useSettingsPermissionMap'; +import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; const mockCurrentUser = { id: 'fake-user-id', @@ -41,7 +42,11 @@ const initializeState = ({ set }: MutableSnapshot) => { const Wrapper = ({ children }: { children: ReactNode }) => ( - {children} + + + {children} + + ); diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownDefaultComponents.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownDefaultComponents.tsx index 62bbbe0be..fc7cb87ea 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownDefaultComponents.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownDefaultComponents.tsx @@ -2,7 +2,6 @@ import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/consta import { useAuth } from '@/auth/hooks/useAuth'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { Workspaces, workspacesState } from '@/auth/states/workspaces'; import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; import { AppPath } from '@/types/AppPath'; @@ -37,9 +36,14 @@ import { MenuItemSelectAvatar, UndecoratedLink, } from 'twenty-ui/navigation'; -import { useSignUpInNewWorkspaceMutation } from '~/generated/graphql'; +import { + useSignUpInNewWorkspaceMutation, + AvailableWorkspace, +} from '~/generated/graphql'; import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; +import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState'; +import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils'; const StyledDescription = styled.div` color: ${({ theme }) => theme.font.color.light}; @@ -50,7 +54,9 @@ export const MultiWorkspaceDropdownDefaultComponents = () => { const currentWorkspace = useRecoilValue(currentWorkspaceState); const { t } = useLingui(); const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); - const workspaces = useRecoilValue(workspacesState); + const availableWorkspaces = useRecoilValue(availableWorkspacesState); + const availableWorkspacesCount = + countAvailableWorkspaces(availableWorkspaces); const { buildWorkspaceUrl } = useBuildWorkspaceUrl(); const { closeDropdown } = useDropdown(MULTI_WORKSPACE_DROPDOWN_ID); const { signOut } = useAuth(); @@ -63,8 +69,10 @@ export const MultiWorkspaceDropdownDefaultComponents = () => { multiWorkspaceDropdownState, ); - const handleChange = async (workspace: Workspaces[0]) => { - redirectToWorkspaceDomain(getWorkspaceUrl(workspace.workspaceUrls)); + const handleChange = async (availableWorkspace: AvailableWorkspace) => { + redirectToWorkspaceDomain( + getWorkspaceUrl(availableWorkspace.workspaceUrls), + ); }; const createWorkspace = () => { @@ -127,36 +135,41 @@ export const MultiWorkspaceDropdownDefaultComponents = () => { > {currentWorkspace?.displayName} - {workspaces.length > 1 && ( + {availableWorkspacesCount > 1 && ( <> - {workspaces + {[ + ...availableWorkspaces.availableWorkspacesForSignIn, + ...availableWorkspaces.availableWorkspacesForSignUp, + ] .filter(({ id }) => id !== currentWorkspace?.id) .slice(0, 3) - .map((workspace) => ( + .map((availableWorkspace) => ( { event?.preventDefault(); - handleChange(workspace); + handleChange(availableWorkspace); }} > } selected={false} /> ))} - {workspaces.length > 4 && ( + {availableWorkspacesCount > 4 && ( { - const currentWorkspace = useRecoilValue(currentWorkspaceState); - const workspaces = useRecoilValue(workspacesState); - const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); - const { buildWorkspaceUrl } = useBuildWorkspaceUrl(); const { t } = useLingui(); - const handleChange = async (workspace: Workspaces[0]) => { - await redirectToWorkspaceDomain(getWorkspaceUrl(workspace.workspaceUrls)); - }; + const availableWorkspaces = useRecoilValue(availableWorkspacesState); + const setMultiWorkspaceDropdownState = useSetRecoilState( multiWorkspaceDropdownState, ); @@ -52,37 +43,10 @@ export const MultiWorkspaceDropdownWorkspacesListComponents = () => { }} /> - - {workspaces - .filter( - (workspace) => - workspace.id !== currentWorkspace?.id && - workspace.displayName - ?.toLowerCase() - .includes(searchValue.toLowerCase()), - ) - .map((workspace) => ( - { - event?.preventDefault(); - handleChange(workspace); - }} - > - - } - selected={currentWorkspace?.id === workspace.id} - /> - - ))} - + + {availableWorkspaces.availableWorkspacesForSignUp.length > 0 && ( + + )} ); }; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/AvailableWorkspaceItem.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/AvailableWorkspaceItem.tsx new file mode 100644 index 000000000..7cb7ba665 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/AvailableWorkspaceItem.tsx @@ -0,0 +1,58 @@ +import { Avatar } from 'twenty-ui/display'; +import { MenuItemSelectAvatar, UndecoratedLink } from 'twenty-ui/navigation'; +import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; +import { AvailableWorkspace } from '~/generated/graphql'; +import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; +import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; +import { getAvailableWorkspacePathAndSearchParams } from '@/auth/utils/availableWorkspacesUtils'; +import React from 'react'; +import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl'; + +export const AvailableWorkspaceItem = ({ + availableWorkspace, + isSelected, +}: { + availableWorkspace: AvailableWorkspace; + isSelected: boolean; +}) => { + const { buildWorkspaceUrl } = useBuildWorkspaceUrl(); + + const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); + + const { pathname, searchParams } = + getAvailableWorkspacePathAndSearchParams(availableWorkspace); + + const handleChange = async () => { + await redirectToWorkspaceDomain( + getWorkspaceUrl(availableWorkspace.workspaceUrls), + pathname, + searchParams, + ); + }; + + return ( + { + event.preventDefault(); + handleChange(); + }} + > + + } + selected={isSelected} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/WorkspacesForSignIn.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/WorkspacesForSignIn.tsx new file mode 100644 index 000000000..6874debc2 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/WorkspacesForSignIn.tsx @@ -0,0 +1,39 @@ +import { useLingui } from '@lingui/react/macro'; +import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useFilteredAvailableWorkspaces } from '@/ui/navigation/navigation-drawer/hooks/useFilteredAvailableWorkspaces'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useRecoilValue } from 'recoil'; +import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState'; +import { AvailableWorkspaceItem } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/AvailableWorkspaceItem'; + +export const WorkspacesForSignIn = ({ + searchValue, +}: { + searchValue: string; +}) => { + const { t } = useLingui(); + + const availableWorkspaces = useRecoilValue(availableWorkspacesState); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + + const { searchAvailableWorkspaces } = useFilteredAvailableWorkspaces(); + + return ( + <> + {t`Member of`} + + {searchAvailableWorkspaces( + searchValue, + availableWorkspaces.availableWorkspacesForSignIn, + ).map((availableWorkspace) => ( + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/WorkspacesForSignUp.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/WorkspacesForSignUp.tsx new file mode 100644 index 000000000..fbd1ed73b --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/WorkspacesForSignUp.tsx @@ -0,0 +1,39 @@ +import { useLingui } from '@lingui/react/macro'; +import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useFilteredAvailableWorkspaces } from '@/ui/navigation/navigation-drawer/hooks/useFilteredAvailableWorkspaces'; +import { AvailableWorkspaceItem } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/AvailableWorkspaceItem'; +import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useRecoilValue } from 'recoil'; + +export const WorkspacesForSignUp = ({ + searchValue, +}: { + searchValue: string; +}) => { + const { t } = useLingui(); + + const availableWorkspaces = useRecoilValue(availableWorkspacesState); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + + const { searchAvailableWorkspaces } = useFilteredAvailableWorkspaces(); + + return ( + <> + {t`Invitations`} + + {searchAvailableWorkspaces( + searchValue, + availableWorkspaces.availableWorkspacesForSignUp, + ).map((availableWorkspace) => ( + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useFilteredAvailableWorkspaces.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useFilteredAvailableWorkspaces.ts new file mode 100644 index 000000000..d841fadf5 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useFilteredAvailableWorkspaces.ts @@ -0,0 +1,25 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useRecoilValue } from 'recoil'; +import { AvailableWorkspace } from '~/generated/graphql'; + +export const useFilteredAvailableWorkspaces = () => { + const currentWorkspace = useRecoilValue(currentWorkspaceState); + + const searchAvailableWorkspaces = ( + searchValue: string, + availableWorkspaces: Array, + ) => { + return availableWorkspaces.filter( + (availableWorkspace) => + currentWorkspace?.id && + availableWorkspace.id !== currentWorkspace.id && + availableWorkspace.displayName + ?.toLowerCase() + .includes(searchValue.toLowerCase()), + ); + }; + + return { + searchAvailableWorkspaces, + }; +}; diff --git a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx index a6a3f4545..7e472fd61 100644 --- a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx +++ b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx @@ -8,7 +8,6 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState'; -import { workspacesState } from '@/auth/states/workspaces'; import { DateFormat } from '@/localization/constants/DateFormat'; import { TimeFormat } from '@/localization/constants/TimeFormat'; import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState'; @@ -30,6 +29,7 @@ import { useGetCurrentUserQuery } from '~/generated/graphql'; import { dateLocaleState } from '~/localization/states/dateLocaleState'; import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; import { isMatchingLocation } from '~/utils/isMatchingLocation'; +import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState'; export const UserProviderEffect = () => { const location = useLocation(); @@ -40,7 +40,7 @@ export const UserProviderEffect = () => { const setCurrentUser = useSetRecoilState(currentUserState); const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState); - const setWorkspaces = useSetRecoilState(workspacesState); + const setAvailableWorkspaces = useSetRecoilState(availableWorkspacesState); const setDateTimeFormat = useSetRecoilState(dateTimeFormatState); const isLoggedIn = useIsLogged(); @@ -102,7 +102,7 @@ export const UserProviderEffect = () => { workspaceMember, workspaceMembers, deletedWorkspaceMembers, - workspaces: userWorkspaces, + availableWorkspaces, } = queryData.currentUser; const affectDefaultValuesOnEmptyWorkspaceMemberFields = ( @@ -153,12 +153,8 @@ export const UserProviderEffect = () => { setCurrentWorkspaceMembersWithDeleted(deletedWorkspaceMembers); } - if (isDefined(userWorkspaces)) { - const workspaces = userWorkspaces - .map(({ workspace }) => workspace) - .filter(isDefined); - - setWorkspaces(workspaces); + if (isDefined(availableWorkspaces)) { + setAvailableWorkspaces(availableWorkspaces); } }, [ queryLoading, @@ -166,9 +162,9 @@ export const UserProviderEffect = () => { setCurrentUser, setCurrentUserWorkspace, setCurrentWorkspaceMembers, + setAvailableWorkspaces, setCurrentWorkspace, setCurrentWorkspaceMember, - setWorkspaces, setIsCurrentUserLoaded, setDateTimeFormat, setCurrentWorkspaceMembersWithDeleted, diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index a299bff99..7ffbe3167 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -3,6 +3,7 @@ import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment'; import { DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/deletedWorkspaceMemberQueryFragment'; import { WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/workspaceMemberQueryFragment'; import { gql } from '@apollo/client'; +import { AVAILABLE_WORKSPACES_FOR_AUTH_FRAGMENT } from '@/auth/graphql/fragments/authFragments'; export const USER_QUERY_FRAGMENT = gql` ${ROLE_FRAGMENT} @@ -48,8 +49,7 @@ export const USER_QUERY_FRAGMENT = gql` customDomain isCustomDomainEnabled workspaceUrls { - subdomainUrl - customUrl + ...WorkspaceUrlsFragment } featureFlags { key @@ -86,22 +86,13 @@ export const USER_QUERY_FRAGMENT = gql` ...RoleFragment } } - workspaces { - workspace { - id - logo - displayName - subdomain - customDomain - workspaceUrls { - subdomainUrl - customUrl - } - } + availableWorkspaces { + ...AvailableWorkspacesFragment } userVars } + ${AVAILABLE_WORKSPACES_FOR_AUTH_FRAGMENT} ${WORKSPACE_MEMBER_QUERY_FRAGMENT} ${DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT} `; diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/workspaceUrlsFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/workspaceUrlsFragment.ts new file mode 100644 index 000000000..8ee41db34 --- /dev/null +++ b/packages/twenty-front/src/modules/users/graphql/fragments/workspaceUrlsFragment.ts @@ -0,0 +1,8 @@ +import { gql } from '@apollo/client'; + +export const WORKSPACE_URLS_FRAGMENT = gql` + fragment WorkspaceUrlsFragment on WorkspaceUrls { + subdomainUrl + customUrl + } +`; diff --git a/packages/twenty-front/src/pages/auth/PasswordReset.tsx b/packages/twenty-front/src/pages/auth/PasswordReset.tsx index b5dea0d48..140816929 100644 --- a/packages/twenty-front/src/pages/auth/PasswordReset.tsx +++ b/packages/twenty-front/src/pages/auth/PasswordReset.tsx @@ -124,7 +124,7 @@ export const PasswordReset = () => { const [updatePasswordViaToken, { loading: isUpdatingPassword }] = useUpdatePasswordViaResetTokenMutation(); - const { signInWithCredentials } = useAuth(); + const { signInWithCredentialsInWorkspace } = useAuth(); const { readCaptchaToken } = useReadCaptchaToken(); const onSubmit = async (formData: Form) => { @@ -153,7 +153,11 @@ export const PasswordReset = () => { const token = await readCaptchaToken(); - await signInWithCredentials(email || '', formData.newPassword, token); + await signInWithCredentialsInWorkspace( + email || '', + formData.newPassword, + token, + ); navigate(AppPath.Index); } catch (err) { logError(err); diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index afaeed16d..0b9bb651d 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -1,17 +1,20 @@ import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; -import { SignInUpStep } from '@/auth/states/signInUpStepState'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; -import { useRecoilValue } from 'recoil'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { Logo } from '@/auth/components/Logo'; import { Title } from '@/auth/components/Title'; import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent'; import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm'; -import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection'; +import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/internal/SignInUpSSOIdentityProviderSelection'; import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm'; -import { SignInUpWorkspaceScopeFormEffect } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect'; +import { SignInUpWorkspaceScopeFormEffect } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceScopeFormEffect'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { useGetPublicWorkspaceDataByDomain } from '@/domain-manager/hooks/useGetPublicWorkspaceDataByDomain'; import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'; @@ -26,17 +29,20 @@ import { useSearchParams } from 'react-router-dom'; import { isDefined } from 'twenty-shared/utils'; import { AnimatedEaseIn } from 'twenty-ui/utilities'; import { PublicWorkspaceDataOutput } from '~/generated/graphql'; +import { SignInUpGlobalScopeFormEffect } from '@/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect'; const StandardContent = ({ workspacePublicData, signInUpForm, signInUpStep, title, + onClickOnLogo, }: { workspacePublicData: PublicWorkspaceDataOutput | null; signInUpForm: JSX.Element | null; signInUpStep: SignInUpStep; title: string; + onClickOnLogo: () => void; }) => { return ( @@ -44,6 +50,7 @@ const StandardContent = ({ {title} @@ -55,6 +62,7 @@ const StandardContent = ({ export const SignInUp = () => { const { t } = useLingui(); + const setSignInUpStep = useSetRecoilState(signInUpStepState); const { form } = useSignInUpForm(); const { signInUpStep } = useSignInUp(form); @@ -67,10 +75,20 @@ export const SignInUp = () => { useWorkspaceFromInviteHash(); const [searchParams] = useSearchParams(); + + const onClickOnLogo = () => { + setSignInUpStep(SignInUpStep.Init); + }; + const title = useMemo(() => { if (isDefined(workspaceInviteHash)) { return `Join ${workspaceFromInviteHash?.displayName ?? ''} team`; } + + if (signInUpStep === SignInUpStep.WorkspaceSelection) { + return t`Choose a Workspace`; + } + const workspaceName = !isDefined(workspacePublicData?.displayName) ? DEFAULT_WORKSPACE_NAME : workspacePublicData?.displayName === '' @@ -79,31 +97,32 @@ export const SignInUp = () => { return t`Welcome to ${workspaceName}`; }, [ - workspaceFromInviteHash?.displayName, workspaceInviteHash, + signInUpStep, workspacePublicData?.displayName, t, + workspaceFromInviteHash?.displayName, ]); const signInUpForm = useMemo(() => { if (loading) return null; if (isDefaultDomain && isMultiWorkspaceEnabled) { - return ; + return ( + <> + + + + ); } if ( - (!isMultiWorkspaceEnabled || - (isMultiWorkspaceEnabled && isOnAWorkspace)) && + isOnAWorkspace && signInUpStep === SignInUpStep.SSOIdentityProviderSelection ) { return ; } - - if ( - isDefined(workspacePublicData) && - (!isMultiWorkspaceEnabled || isOnAWorkspace) - ) { + if (isDefined(workspacePublicData) && isOnAWorkspace) { return ( <> @@ -112,7 +131,12 @@ export const SignInUp = () => { ); } - return ; + return ( + <> + + + + ); }, [ isDefaultDomain, isMultiWorkspaceEnabled, @@ -136,6 +160,7 @@ export const SignInUp = () => { signInUpForm={signInUpForm} signInUpStep={signInUpStep} title={title} + onClickOnLogo={onClickOnLogo} /> ); }; diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx index d9d1986bf..e62e9ee30 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx @@ -1,19 +1,19 @@ import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPath } from '@/types/SettingsPath'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { TextInput } from '@/ui/input/components/TextInput'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Controller, useForm } from 'react-hook-form'; -import { useNavigateSettings } from '~/hooks/useNavigateSettings'; -import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { Trans, useLingui } from '@lingui/react/macro'; -import { z } from 'zod'; -import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; -import { useCreateApprovedAccessDomainMutation } from '~/generated/graphql'; +import { Controller, useForm } from 'react-hook-form'; import { H2Title } from 'twenty-ui/display'; import { Section } from 'twenty-ui/layout'; -import { TextInput } from '@/ui/input/components/TextInput'; +import { z } from 'zod'; +import { useCreateApprovedAccessDomainMutation } from '~/generated/graphql'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; export const SettingsSecurityApprovedAccessDomain = () => { const navigate = useNavigateSettings(); @@ -62,7 +62,7 @@ export const SettingsSecurityApprovedAccessDomain = () => { }, }, onCompleted: () => { - enqueueSnackBar(t`Domain added successfully.`, { + enqueueSnackBar(t`Please check your email for a verification link.`, { variant: SnackBarVariant.Success, }); navigate(SettingsPath.Security); diff --git a/packages/twenty-front/src/testing/graphqlMocks.ts b/packages/twenty-front/src/testing/graphqlMocks.ts index 0dedab541..5174a1430 100644 --- a/packages/twenty-front/src/testing/graphqlMocks.ts +++ b/packages/twenty-front/src/testing/graphqlMocks.ts @@ -19,6 +19,7 @@ import { mockedRemoteTables } from '~/testing/mock-data/remote-tables'; import { mockedUserData } from '~/testing/mock-data/users'; import { mockedViewsData } from '~/testing/mock-data/views'; import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members'; +import { mockedPublicWorkspaceDataBySubdomain } from '~/testing/mock-data/publicWorkspaceDataBySubdomain'; import { GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataByDomain'; import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery'; @@ -90,22 +91,8 @@ export const graphqlMocks = { () => { return HttpResponse.json({ data: { - getPublicWorkspaceDataByDomain: { - id: 'id', - logo: 'logo', - displayName: 'displayName', - workspaceUrls: { - customUrl: undefined, - subdomainUrl: 'https://twenty.com', - }, - authProviders: { - google: true, - microsoft: false, - password: true, - magicLink: false, - sso: [], - }, - }, + getPublicWorkspaceDataByDomain: + mockedPublicWorkspaceDataBySubdomain, }, }); }, diff --git a/packages/twenty-front/src/testing/mock-data/publicWorkspaceDataBySubdomain.ts b/packages/twenty-front/src/testing/mock-data/publicWorkspaceDataBySubdomain.ts index 1ead7242e..04ae15b96 100644 --- a/packages/twenty-front/src/testing/mock-data/publicWorkspaceDataBySubdomain.ts +++ b/packages/twenty-front/src/testing/mock-data/publicWorkspaceDataBySubdomain.ts @@ -7,6 +7,7 @@ export const mockedPublicWorkspaceDataBySubdomain: GetPublicWorkspaceDataByDomai logo: 'workspace-logo/original/c88deb49-7636-4560-918d-08c3265ffb20.49?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3b3Jrc3BhY2VJZCI6Ijk4NzAzMjNlLTIyYzMtNGQxNC05YjdmLTViZGM4NGY3ZDZlZSIsImlhdCI6MTczNjU0MDU0MywiZXhwIjoxNzM2NjI2OTQzfQ.C8cnHu09VGseRbQAMM4nhiO6z4TLG03ntFTvxm53-xg', displayName: 'Twenty Eng', workspaceUrls: { + __typename: 'WorkspaceUrls', customUrl: 'https://twenty-eng.com', subdomainUrl: 'https://custom.twenty.com', }, diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index 85a618965..3d9ac383d 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -25,6 +25,7 @@ type MockedUser = Pick< | 'supportUserHash' | 'onboardingStatus' | 'userVars' + | 'availableWorkspaces' > & { workspaceMember: WorkspaceMember | null; locale: string; @@ -132,6 +133,10 @@ export const mockedUserData: MockedUser = { workspaces: [{ workspace: mockCurrentWorkspace }], workspaceMembers: [mockedWorkspaceMemberData], onboardingStatus: OnboardingStatus.COMPLETED, + availableWorkspaces: { + availableWorkspacesForSignIn: [], + availableWorkspacesForSignUp: [], + }, userVars: {}, }; diff --git a/packages/twenty-server/.env.test b/packages/twenty-server/.env.test index eeca857db..e75927d7b 100644 --- a/packages/twenty-server/.env.test +++ b/packages/twenty-server/.env.test @@ -22,4 +22,4 @@ MESSAGING_PROVIDER_GMAIL_CALLBACK_URL=http://localhost:3000/auth/google-gmail/ge AUTH_MICROSOFT_CALLBACK_URL=http://localhost:3000/auth/microsoft/redirect AUTH_MICROSOFT_APIS_CALLBACK_URL=http://localhost:3000/auth/microsoft-apis/get-access-token -CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty +CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty \ No newline at end of file diff --git a/packages/twenty-server/@types/express.d.ts b/packages/twenty-server/@types/express.d.ts index bf04f0c42..154ef0f23 100644 --- a/packages/twenty-server/@types/express.d.ts +++ b/packages/twenty-server/@types/express.d.ts @@ -1,15 +1,17 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; declare module 'express-serve-static-core' { interface Request { user?: User | null; apiKey?: ApiKeyWorkspaceEntity | null; - workspace: Workspace; - workspaceId: string; + workspace?: Workspace; + workspaceId?: string; workspaceMetadataVersion?: number; workspaceMemberId?: string; userWorkspaceId?: string; + authProvider?: AuthProviderEnum | null; } } diff --git a/packages/twenty-server/@types/jest.d.ts b/packages/twenty-server/@types/jest.d.ts index 4994338de..370218adf 100644 --- a/packages/twenty-server/@types/jest.d.ts +++ b/packages/twenty-server/@types/jest.d.ts @@ -22,6 +22,7 @@ declare global { const MEMBER_ACCESS_TOKEN: string; const GUEST_ACCESS_TOKEN: string; const API_KEY_ACCESS_TOKEN: string; + const WORKSPACE_AGNOSTIC_TOKEN: string; } export {}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts index b6b885fe3..55f0ce8c5 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts @@ -37,6 +37,7 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; export type GraphqlQueryResolverExecutionArgs = { args: Input; @@ -83,11 +84,15 @@ export abstract class GraphqlQueryBaseResolverService< try { const { authContext, objectMetadataItemWithFieldMaps } = options; + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.validate(args, options); const workspaceDataSource = await this.twentyORMGlobalManager.getDataSourceForWorkspace({ - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, shouldFailIfMetadataNotFound: false, }); @@ -124,7 +129,7 @@ export abstract class GraphqlQueryBaseResolverService< const roleId = await this.userRoleService.getRoleIdForUserWorkspace({ userWorkspaceId: authContext.userWorkspaceId, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }); const executedByApiKey = isDefined(authContext.apiKey); @@ -169,7 +174,7 @@ export abstract class GraphqlQueryBaseResolverService< const resultWithGetters = await this.queryResultGettersFactory.create( results, objectMetadataItemWithFieldMaps, - authContext.workspace.id, + workspace.id, options.objectMetadataMaps, ); @@ -191,6 +196,10 @@ export abstract class GraphqlQueryBaseResolverService< ) { const { authContext, objectMetadataItemWithFieldMaps } = options; + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + if ( Object.keys(OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS).includes( objectMetadataItemWithFieldMaps.nameSingular, @@ -206,7 +215,7 @@ export abstract class GraphqlQueryBaseResolverService< await this.permissionsService.userHasWorkspaceSettingPermission({ userWorkspaceId: authContext.userWorkspaceId, setting: permissionRequired, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, isExecutedByApiKey: isDefined(authContext.apiKey), }); @@ -231,11 +240,15 @@ export abstract class GraphqlQueryBaseResolverService< const requiredPermission = this.getRequiredPermissionForMethod(operationName); + const workspace = options.authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + const userHasPermission = await this.permissionsService.userHasObjectRecordsPermission({ userWorkspaceId: options.authContext.userWorkspaceId, requiredPermission, - workspaceId: options.authContext.workspace.id, + workspaceId: workspace.id, isExecutedByApiKey: isDefined(options.authContext.apiKey), objectMetadataId, }); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service.ts index d16f667d4..931d246f5 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service.ts @@ -7,6 +7,7 @@ import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @Injectable() export class ApiEventEmitterService { @@ -33,7 +34,7 @@ export class ApiEventEmitterService { after: record, }, })), - workspaceId: authContext.workspace.id, + workspaceId: authContext.workspace?.id, }); } @@ -50,6 +51,10 @@ export class ApiEventEmitterService { authContext: AuthContext; objectMetadataItem: ObjectMetadataInterface; }): void { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + const mappedExistingRecords = existingRecords.reduce( (acc, { id, ...record }) => ({ ...acc, @@ -84,7 +89,7 @@ export class ApiEventEmitterService { }, }; }), - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }); } @@ -97,6 +102,10 @@ export class ApiEventEmitterService { authContext: AuthContext; objectMetadataItem: ObjectMetadataInterface; }): void { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + this.workspaceEventEmitter.emitDatabaseBatchEvent({ objectMetadataNameSingular: objectMetadataItem.nameSingular, action: DatabaseEventAction.DELETED, @@ -111,7 +120,7 @@ export class ApiEventEmitterService { }, }; }), - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }); } @@ -124,6 +133,10 @@ export class ApiEventEmitterService { authContext: AuthContext; objectMetadataItem: ObjectMetadataInterface; }): void { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + this.workspaceEventEmitter.emitDatabaseBatchEvent({ objectMetadataNameSingular: objectMetadataItem.nameSingular, action: DatabaseEventAction.RESTORED, @@ -138,7 +151,7 @@ export class ApiEventEmitterService { }, }; }), - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }); } @@ -151,6 +164,10 @@ export class ApiEventEmitterService { authContext: AuthContext; objectMetadataItem: ObjectMetadataInterface; }): void { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + this.workspaceEventEmitter.emitDatabaseBatchEvent({ objectMetadataNameSingular: objectMetadataItem.nameSingular, action: DatabaseEventAction.DESTROYED, @@ -165,7 +182,7 @@ export class ApiEventEmitterService { }, }; }), - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }); } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts index 0420033b0..26bd2d316 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts @@ -23,6 +23,7 @@ import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metada import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map'; import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; type ArgPositionBackfillInput = { argIndex?: number; @@ -171,7 +172,10 @@ export class QueryRunnerArgsFactory { return Promise.resolve({}); } - const workspaceId = options.authContext.workspace.id; + const workspace = options.authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + let isFieldPositionPresent = false; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -192,7 +196,7 @@ export class QueryRunnerArgsFactory { const newValue = await this.recordPositionService.buildRecordPosition( { value, - workspaceId, + workspaceId: workspace.id, objectMetadata: { isCustom: options.objectMetadataItemWithFieldMaps.isCustom, nameSingular: @@ -234,7 +238,7 @@ export class QueryRunnerArgsFactory { 'position', await this.recordPositionService.buildRecordPosition({ value: 'first', - workspaceId, + workspaceId: workspace.id, objectMetadata: { isCustom: options.objectMetadataItemWithFieldMaps.isCustom, nameSingular: diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.explorer.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.explorer.ts index e56a17403..f3f6ad0fc 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.explorer.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.explorer.ts @@ -22,6 +22,7 @@ import { WorkspaceQueryHookKey } from 'src/engine/api/graphql/workspace-query-ru import { WorkspaceQueryHookStorage } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/storage/workspace-query-hook.storage'; import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type'; import { WorkspaceQueryHookMetadataAccessor } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook-metadata.accessor'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @Injectable() export class WorkspaceQueryHookExplorer implements OnModuleInit { @@ -89,6 +90,10 @@ export class WorkspaceQueryHookExplorer implements OnModuleInit { ): Promise> { const methodName = 'execute'; + const workspace = executeParams?.[0].workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + if (isRequestScoped) { const contextId = createContextId(); @@ -96,7 +101,7 @@ export class WorkspaceQueryHookExplorer implements OnModuleInit { this.moduleRef.registerRequestByContextId( { req: { - workspaceId: executeParams?.[0].workspace.id, + workspaceId: workspace.id, }, }, contextId, @@ -152,6 +157,10 @@ export class WorkspaceQueryHookExplorer implements OnModuleInit { ): Promise> { const methodName = 'execute'; + const workspace = executeParams?.[0].workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + const transformedPayload = this.transformPayload(executeParams[2]); if (isRequestScoped) { @@ -161,7 +170,7 @@ export class WorkspaceQueryHookExplorer implements OnModuleInit { this.moduleRef.registerRequestByContextId( { req: { - workspaceId: executeParams?.[0].workspace.id, + workspaceId: workspace.id, userWorkspaceId: executeParams?.[0].userWorkspaceId, apiKey: executeParams?.[0].apiKey, workspaceMemberId: executeParams?.[0].workspaceMemberId, 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 2103627f9..b5dec7f43 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,6 +18,7 @@ import { getObjectMetadataMapItemByNamePlural } from 'src/engine/metadata-module import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @Injectable() export class CoreQueryBuilderFactory { @@ -42,6 +43,8 @@ export class CoreQueryBuilderFactory { const { workspace } = await this.accessTokenService.validateTokenByRequest(request); + workspaceValidator.assertIsDefinedOrThrow(workspace); + const currentCacheVersion = await this.workspaceCacheStorageService.getMetadataVersion(workspace.id); diff --git a/packages/twenty-server/src/engine/core-modules/actor/services/created-by-from-auth-context.service.ts b/packages/twenty-server/src/engine/core-modules/actor/services/created-by-from-auth-context.service.ts index 2abbae161..b9652c07b 100644 --- a/packages/twenty-server/src/engine/core-modules/actor/services/created-by-from-auth-context.service.ts +++ b/packages/twenty-server/src/engine/core-modules/actor/services/created-by-from-auth-context.service.ts @@ -11,6 +11,7 @@ import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/compos import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type CreateInput = Record; @@ -30,6 +31,10 @@ export class CreatedByFromAuthContextService { objectMetadataNameSingular: string, authContext: AuthContext, ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + // TODO: Once all objects have it, we can remove this check const createdByFieldMetadata = await this.fieldMetadataRepository.findOne({ where: { @@ -37,7 +42,7 @@ export class CreatedByFromAuthContextService { nameSingular: objectMetadataNameSingular, }, name: 'createdBy', - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }, }); @@ -78,6 +83,8 @@ export class CreatedByFromAuthContextService { ): Promise { const { workspace, workspaceMemberId, user, apiKey } = authContext; + workspaceValidator.assertIsDefinedOrThrow(workspace); + // TODO: remove that code once we have the workspace member id in all tokens if (isDefined(workspaceMemberId) && isDefined(user)) { return buildCreatedByFromFullNameMetadata({ 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 875cd3187..3bf5590aa 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 @@ -26,7 +26,6 @@ export enum AppTokenType { AuthorizationCode = 'AUTHORIZATION_CODE', PasswordResetToken = 'PASSWORD_RESET_TOKEN', InvitationToken = 'INVITATION_TOKEN', - OIDCCodeVerifier = 'OIDC_CODE_VERIFIER', EmailVerificationToken = 'EMAIL_VERIFICATION_TOKEN', } diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts index b9b57f43f..995faa6c7 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts @@ -203,4 +203,20 @@ export class ApprovedAccessDomainService { }, }); } + + async findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain( + domain: string, + ) { + return await this.approvedAccessDomainRepository.find({ + relations: [ + 'workspace', + 'workspace.workspaceSSOIdentityProviders', + 'workspace.approvedAccessDomains', + ], + where: { + domain, + isValidated: true, + }, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts index 777259d48..055b9df38 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts @@ -26,4 +26,5 @@ export enum AuthExceptionCode { GOOGLE_API_AUTH_DISABLED = 'GOOGLE_API_AUTH_DISABLED', MICROSOFT_API_AUTH_DISABLED = 'MICROSOFT_API_AUTH_DISABLED', MISSING_ENVIRONMENT_VARIABLE = 'MISSING_ENVIRONMENT_VARIABLE', + INVALID_JWT_TOKEN_TYPE = 'INVALID_JWT_TOKEN_TYPE', } 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 668499a05..48078c494 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 @@ -13,6 +13,8 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm import { SSOService } from 'src/engine/core-modules/sso/services/sso.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 { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { AuthResolver } from './auth.resolver'; @@ -45,6 +47,10 @@ describe('AuthResolver', () => { provide: AuthService, useValue: {}, }, + { + provide: RefreshTokenService, + useValue: {}, + }, { provide: UserService, useValue: {}, @@ -81,6 +87,10 @@ describe('AuthResolver', () => { provide: LoginTokenService, useValue: {}, }, + { + provide: WorkspaceAgnosticTokenService, + useValue: {}, + }, { provide: TransientTokenService, useValue: {}, 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 6c6f91a10..5bf3fa96d 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 @@ -28,7 +28,6 @@ import { import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input'; import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output'; import { GetLoginTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input'; -import { GetLoginTokenFromEmailVerificationTokenOutput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output'; import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; @@ -55,14 +54,21 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; +import { GetLoginTokenFromEmailVerificationTokenOutput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output'; +import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { AvailableWorkspacesAndAccessTokensOutput } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.output'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { AuthProvider } from 'src/engine/decorators/auth/auth-provider.decorator'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input'; -import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input'; +import { UserCredentialsInput } from './dto/user-credentials.input'; import { LoginToken } from './dto/login-token.entity'; import { SignUpInput } from './dto/sign-up.input'; import { ApiKeyToken, AuthTokens } from './dto/token.entity'; -import { UserExistsOutput } from './dto/user-exists.entity'; -import { CheckUserExistsInput } from './dto/user-exists.input'; +import { CheckUserExistOutput } from './dto/user-exists.entity'; +import { EmailAndCaptchaInput } from './dto/user-exists.input'; import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity'; import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input'; import { AuthService } from './services/auth.service'; @@ -85,6 +91,8 @@ export class AuthResolver { private apiKeyService: ApiKeyService, private resetPasswordService: ResetPasswordService, private loginTokenService: LoginTokenService, + private workspaceAgnosticTokenService: WorkspaceAgnosticTokenService, + private refreshTokenService: RefreshTokenService, private signInUpService: SignInUpService, private transientTokenService: TransientTokenService, private emailVerificationService: EmailVerificationService, @@ -96,10 +104,10 @@ export class AuthResolver { ) {} @UseGuards(CaptchaGuard, PublicEndpointGuard) - @Query(() => UserExistsOutput) + @Query(() => CheckUserExistOutput) async checkUserExists( - @Args() checkUserExistsInput: CheckUserExistsInput, - ): Promise { + @Args() checkUserExistsInput: EmailAndCaptchaInput, + ): Promise { return await this.authService.checkUserExists( checkUserExistsInput.email.toLowerCase(), ); @@ -136,11 +144,11 @@ export class AuthResolver { ); } - @UseGuards(CaptchaGuard, PublicEndpointGuard) @Mutation(() => LoginToken) + @UseGuards(CaptchaGuard, PublicEndpointGuard) async getLoginTokenFromCredentials( @Args() - getLoginTokenFromCredentialsInput: GetLoginTokenFromCredentialsInput, + getLoginTokenFromCredentialsInput: UserCredentialsInput, @Args('origin') origin: string, ): Promise { const workspace = @@ -156,7 +164,7 @@ export class AuthResolver { ), ); - const user = await this.authService.getLoginTokenFromCredentials( + const user = await this.authService.validateLoginWithPassword( getLoginTokenFromCredentialsInput, workspace, ); @@ -164,17 +172,58 @@ export class AuthResolver { const loginToken = await this.loginTokenService.generateLoginToken( user.email, workspace.id, + // email validation is active only for password flow + AuthProviderEnum.Password, ); return { loginToken }; } + @Mutation(() => AvailableWorkspacesAndAccessTokensOutput) + @UseGuards(CaptchaGuard, PublicEndpointGuard) + async signIn( + @Args() + userCredentials: UserCredentialsInput, + ): Promise { + const user = + await this.authService.validateLoginWithPassword(userCredentials); + + const availableWorkspaces = + await this.userWorkspaceService.findAvailableWorkspacesByEmail( + user.email, + ); + + return { + availableWorkspaces: + await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch( + availableWorkspaces, + user, + AuthProviderEnum.Password, + ), + tokens: { + accessToken: + await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken( + { + userId: user.id, + authProvider: AuthProviderEnum.Password, + }, + ), + refreshToken: await this.refreshTokenService.generateRefreshToken({ + userId: user.id, + authProvider: AuthProviderEnum.Password, + targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, + }), + }, + }; + } + @Mutation(() => GetLoginTokenFromEmailVerificationTokenOutput) @UseGuards(PublicEndpointGuard) async getLoginTokenFromEmailVerificationToken( @Args() getLoginTokenFromEmailVerificationTokenInput: GetLoginTokenFromEmailVerificationTokenInput, @Args('origin') origin: string, + @AuthProvider() authProvider: AuthProviderEnum, ) { const appToken = await this.emailVerificationTokenService.validateEmailVerificationTokenOrThrow( @@ -195,6 +244,7 @@ export class AuthResolver { const loginToken = await this.loginTokenService.generateLoginToken( appToken.user.email, workspace.id, + authProvider, ); const workspaceUrls = this.domainManagerService.getWorkspaceUrls(workspace); @@ -202,12 +252,59 @@ export class AuthResolver { return { loginToken, workspaceUrls }; } + @Mutation(() => AvailableWorkspacesAndAccessTokensOutput) @UseGuards(CaptchaGuard, PublicEndpointGuard) + async signUp( + @Args() signUpInput: UserCredentialsInput, + ): Promise { + const user = await this.signInUpService.signUpWithoutWorkspace( + { + email: signUpInput.email, + }, + { + provider: AuthProviderEnum.Password, + password: signUpInput.password, + }, + ); + + const availableWorkspaces = + await this.userWorkspaceService.findAvailableWorkspacesByEmail( + user.email, + ); + + return { + availableWorkspaces: + await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch( + availableWorkspaces, + user, + AuthProviderEnum.Password, + ), + tokens: { + accessToken: + await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken( + { + userId: user.id, + authProvider: AuthProviderEnum.Password, + }, + ), + refreshToken: await this.refreshTokenService.generateRefreshToken({ + userId: user.id, + authProvider: AuthProviderEnum.Password, + targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, + }), + }, + }; + } + @Mutation(() => SignUpOutput) - async signUp(@Args() signUpInput: SignUpInput): Promise { + @UseGuards(CaptchaGuard, PublicEndpointGuard) + async signUpInWorkspace( + @Args() signUpInput: SignUpInput, + @AuthProvider() authProvider: AuthProviderEnum, + ): Promise { const currentWorkspace = await this.authService.findWorkspaceForSignInUp({ workspaceInviteHash: signUpInput.workspaceInviteHash, - authProvider: 'password', + authProvider: AuthProviderEnum.Password, workspaceId: signUpInput.workspaceId, }); @@ -246,7 +343,7 @@ export class AuthResolver { workspace: currentWorkspace, invitation, authParams: { - provider: 'password', + provider: AuthProviderEnum.Password, password: signUpInput.password, }, }); @@ -262,6 +359,7 @@ export class AuthResolver { const loginToken = await this.loginTokenService.generateLoginToken( user.email, workspace.id, + authProvider, ); return { @@ -277,6 +375,7 @@ export class AuthResolver { @UseGuards(UserAuthGuard) async signUpInNewWorkspace( @AuthUser() currentUser: User, + @AuthProvider() authProvider: AuthProviderEnum, ): Promise { const { user, workspace } = await this.signInUpService.signUpOnNewWorkspace( { type: 'existingUser', existingUser: currentUser }, @@ -285,6 +384,7 @@ export class AuthResolver { const loginToken = await this.loginTokenService.generateLoginToken( user.email, workspace.id, + authProvider, ); return { @@ -320,11 +420,11 @@ export class AuthResolver { return; } const transientToken = - await this.transientTokenService.generateTransientToken( - workspaceMember.id, - user.id, - workspace.id, - ); + await this.transientTokenService.generateTransientToken({ + workspaceId: workspace.id, + userId: user.id, + workspaceMemberId: workspaceMember.id, + }); return { transientToken }; } @@ -335,6 +435,14 @@ export class AuthResolver { @Args() getAuthTokensFromLoginTokenInput: GetAuthTokensFromLoginTokenInput, @Args('origin') origin: string, ): Promise { + const { + sub: email, + workspaceId, + authProvider, + } = await this.loginTokenService.verifyLoginToken( + getAuthTokensFromLoginTokenInput.loginToken, + ); + const workspace = await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace( origin, @@ -342,11 +450,6 @@ export class AuthResolver { workspaceValidator.assertIsDefinedOrThrow(workspace); - const { sub: email, workspaceId } = - await this.loginTokenService.verifyLoginToken( - getAuthTokensFromLoginTokenInput.loginToken, - ); - if (workspaceId !== workspace.id) { throw new AuthException( 'Token is not valid for this workspace', @@ -354,7 +457,7 @@ export class AuthResolver { ); } - return await this.authService.verify(email, workspace.id); + return await this.authService.verify(email, workspace.id, authProvider); } @Mutation(() => AuthorizeApp) 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 461004260..9b1115197 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 @@ -6,10 +6,8 @@ import { UseFilters, UseGuards, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { Response } from 'express'; -import { Repository } from 'typeorm'; import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter'; import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; @@ -17,23 +15,13 @@ import { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oau 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 { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; -import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; -import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; -import { User } from 'src/engine/core-modules/user/user.entity'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; @Controller('auth/google') @UseFilters(AuthRestApiExceptionFilter) export class GoogleAuthController { - constructor( - private readonly loginTokenService: LoginTokenService, - private readonly authService: AuthService, - private readonly guardRedirectService: GuardRedirectService, - private readonly domainManagerService: DomainManagerService, - @InjectRepository(User, 'core') - private readonly userRepository: Repository, - ) {} + constructor(private readonly authService: AuthService) {} @Get() @UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard, PublicEndpointGuard) @@ -46,90 +34,11 @@ export class GoogleAuthController { @UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard, PublicEndpointGuard) @UseFilters(AuthOAuthExceptionFilter) async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) { - const { - firstName, - lastName, - email: rawEmail, - picture, - workspaceInviteHash, - workspaceId, - billingCheckoutSessionState, - locale, - } = req.user; - - const email = rawEmail.toLowerCase(); - - const currentWorkspace = await this.authService.findWorkspaceForSignInUp({ - workspaceId, - workspaceInviteHash, - email, - authProvider: 'google', - }); - - try { - const invitation = - currentWorkspace && email - ? await this.authService.findInvitationForSignInUp({ - currentWorkspace, - email, - }) - : undefined; - - const existingUser = await this.userRepository.findOne({ - where: { email }, - }); - - const { userData } = this.authService.formatUserDataPayload( - { - firstName, - lastName, - email, - picture, - locale, - }, - existingUser, - ); - - await this.authService.checkAccessForSignIn({ - userData, - invitation, - workspaceInviteHash, - workspace: currentWorkspace, - }); - - const { user, workspace } = await this.authService.signInUp({ - invitation, - workspace: currentWorkspace, - userData, - authParams: { - provider: 'google', - }, - billingCheckoutSessionState, - }); - - const loginToken = await this.loginTokenService.generateLoginToken( - user.email, - workspace.id, - ); - - return res.redirect( - this.authService.computeRedirectURI({ - loginToken: loginToken.token, - workspace, - billingCheckoutSessionState, - }), - ); - } catch (error) { - return res.redirect( - this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({ - error, - workspace: - this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( - currentWorkspace, - ), - pathname: '/verify', - }), - ); - } + return res.redirect( + await this.authService.signInUpWithSocialSSO( + req.user, + AuthProviderEnum.Google, + ), + ); } } 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 964861e24..b663823f5 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 @@ -6,33 +6,21 @@ import { UseFilters, UseGuards, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { Response } from 'express'; -import { Repository } from 'typeorm'; import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; 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 { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; -import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; -import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; -import { User } from 'src/engine/core-modules/user/user.entity'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; @Controller('auth/microsoft') @UseFilters(AuthRestApiExceptionFilter) export class MicrosoftAuthController { - constructor( - private readonly loginTokenService: LoginTokenService, - private readonly authService: AuthService, - private readonly guardRedirectService: GuardRedirectService, - @InjectRepository(User, 'core') - private readonly userRepository: Repository, - private readonly domainManagerService: DomainManagerService, - ) {} + constructor(private readonly authService: AuthService) {} @Get() @UseGuards( @@ -55,90 +43,11 @@ export class MicrosoftAuthController { @Req() req: MicrosoftRequest, @Res() res: Response, ) { - const { - firstName, - lastName, - email: rawEmail, - picture, - workspaceInviteHash, - workspaceId, - billingCheckoutSessionState, - locale, - } = req.user; - - const email = rawEmail.toLowerCase(); - - const currentWorkspace = await this.authService.findWorkspaceForSignInUp({ - workspaceId, - workspaceInviteHash, - email, - authProvider: 'microsoft', - }); - - try { - const invitation = - currentWorkspace && email - ? await this.authService.findInvitationForSignInUp({ - currentWorkspace, - email, - }) - : undefined; - - const existingUser = await this.userRepository.findOne({ - where: { email }, - }); - - const { userData } = this.authService.formatUserDataPayload( - { - firstName, - lastName, - email, - picture, - locale, - }, - existingUser, - ); - - await this.authService.checkAccessForSignIn({ - userData, - invitation, - workspaceInviteHash, - workspace: currentWorkspace, - }); - - const { user, workspace } = await this.authService.signInUp({ - invitation, - workspace: currentWorkspace, - userData, - authParams: { - provider: 'microsoft', - }, - billingCheckoutSessionState, - }); - - const loginToken = await this.loginTokenService.generateLoginToken( - user.email, - workspace.id, - ); - - return res.redirect( - this.authService.computeRedirectURI({ - loginToken: loginToken.token, - workspace, - billingCheckoutSessionState, - }), - ); - } catch (error) { - return res.redirect( - this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({ - error, - workspace: - this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( - currentWorkspace, - ), - pathname: '/verify', - }), - ); - } + return res.redirect( + await this.authService.signInUpWithSocialSSO( + req.user, + AuthProviderEnum.Microsoft, + ), + ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index d220c1ff7..06804ca11 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -39,6 +39,7 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; @Controller('auth') export class SSOAuthController { @@ -136,7 +137,7 @@ export class SSOAuthController { workspaceId: workspaceIdentityProvider.workspaceId, workspaceInviteHash: req.user.workspaceInviteHash, email: req.user.email, - authProvider: 'sso', + authProvider: AuthProviderEnum.SSO, }); workspaceValidator.assertIsDefinedOrThrow( @@ -206,7 +207,7 @@ export class SSOAuthController { workspace: currentWorkspace, invitation, authParams: { - provider: 'sso', + provider: AuthProviderEnum.SSO, }, }); @@ -215,6 +216,7 @@ export class SSOAuthController { loginToken: await this.loginTokenService.generateLoginToken( user.email, workspace.id, + AuthProviderEnum.SSO, ), }; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.output.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.output.ts new file mode 100644 index 000000000..b8c6aa854 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.output.ts @@ -0,0 +1,14 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; + +import { AuthTokenPair } from './token.entity'; + +@ObjectType() +export class AvailableWorkspacesAndAccessTokensOutput { + @Field(() => AuthTokenPair) + tokens: AuthTokenPair; + + @Field(() => AvailableWorkspaces) + availableWorkspaces: AvailableWorkspaces; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts index 361955467..238f5960d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts @@ -28,13 +28,22 @@ class SSOConnection { } @ObjectType() -export class AvailableWorkspaceOutput { +export class AvailableWorkspace { @Field(() => String) id: string; @Field(() => String, { nullable: true }) displayName?: string; + @Field(() => String, { nullable: true }) + loginToken?: string; + + @Field(() => String, { nullable: true }) + personalInviteToken?: string; + + @Field(() => String, { nullable: true }) + inviteHash?: string; + @Field(() => WorkspaceUrls) workspaceUrls: WorkspaceUrls; @@ -44,3 +53,12 @@ export class AvailableWorkspaceOutput { @Field(() => [SSOConnection]) sso: SSOConnection[]; } + +@ObjectType() +export class AvailableWorkspaces { + @Field(() => [AvailableWorkspace]) + availableWorkspacesForSignIn: Array; + + @Field(() => [AvailableWorkspace]) + availableWorkspacesForSignUp: Array; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/create-user-and-workspace.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/create-user-and-workspace.input.ts new file mode 100644 index 000000000..8c64dbafd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/create-user-and-workspace.input.ts @@ -0,0 +1,37 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { APP_LOCALES } from 'twenty-shared/translations'; + +@ArgsType() +export class CreateUserAndWorkspaceInput { + @Field(() => String) + @IsNotEmpty() + @IsEmail() + email: string; + + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + firstName?: string; + + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + lastName?: string; + + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + picture?: string; + + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + locale?: keyof typeof APP_LOCALES; + + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + captchaToken?: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/token.entity.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/token.entity.ts index 997124675..ca3a5d4ac 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/token.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/token.entity.ts @@ -41,3 +41,9 @@ export class PasswordResetToken { @Field(() => String) workspaceId: string; } + +@ObjectType() +export class WorkspaceAgnosticToken { + @Field(() => AuthToken) + token: AuthToken; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/get-login-token-from-credentials.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/user-credentials.input.ts similarity index 88% rename from packages/twenty-server/src/engine/core-modules/auth/dto/get-login-token-from-credentials.input.ts rename to packages/twenty-server/src/engine/core-modules/auth/dto/user-credentials.input.ts index 03fe24b63..dcda6dcb9 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/get-login-token-from-credentials.input.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/user-credentials.input.ts @@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql'; import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; @ArgsType() -export class GetLoginTokenFromCredentialsInput { +export class UserCredentialsInput { @Field(() => String) @IsNotEmpty() @IsEmail() diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts index 4b914a8a4..a6ff467b7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts @@ -1,33 +1,13 @@ -import { Field, ObjectType, createUnionType } from '@nestjs/graphql'; - -import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; +import { Field, ObjectType } from '@nestjs/graphql'; @ObjectType() -export class UserExists { +export class CheckUserExistOutput { @Field(() => Boolean) - exists: true; + exists: boolean; - @Field(() => [AvailableWorkspaceOutput]) - availableWorkspaces: Array; + @Field(() => Number) + availableWorkspacesCount: number; @Field(() => Boolean) isEmailVerified: boolean; } - -@ObjectType() -export class UserNotExists { - @Field(() => Boolean) - exists: false; -} - -export const UserExistsOutput = createUnionType({ - name: 'UserExistsOutput', - types: () => [UserExists, UserNotExists] as const, - resolveType(value) { - if (value.exists === true) { - return UserExists; - } - - return UserNotExists; - }, -}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.input.ts index b580371f6..e429c5182 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.input.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.input.ts @@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql'; import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; @ArgsType() -export class CheckUserExistsInput { +export class EmailAndCaptchaInput { @Field(() => String) @IsString() @IsNotEmpty() diff --git a/packages/twenty-server/src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter.ts index b4af06383..a32d48307 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter.ts @@ -28,6 +28,7 @@ export class AuthGraphqlApiExceptionFilter implements ExceptionFilter { case AuthExceptionCode.GOOGLE_API_AUTH_DISABLED: case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED: case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE: + case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE: throw new ForbiddenError(exception.message); case AuthExceptionCode.EMAIL_NOT_VERIFIED: case AuthExceptionCode.INVALID_DATA: diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts index 85c51dcac..66903d1ce 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts @@ -2,14 +2,11 @@ import { Injectable } from '@nestjs/common'; import { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; @Injectable() export class ApiKeyService { - constructor( - private readonly jwtWrapperService: JwtWrapperService, - private readonly twentyConfigService: TwentyConfigService, - ) {} + constructor(private readonly jwtWrapperService: JwtWrapperService) {} async generateApiKeyToken( workspaceId: string, @@ -19,13 +16,8 @@ export class ApiKeyService { if (!apiKeyId) { return; } - const jwtPayload = { - sub: workspaceId, - type: 'API_KEY', - workspaceId, - }; const secret = this.jwtWrapperService.generateAppSecret( - 'ACCESS', + JwtTokenTypeEnum.ACCESS, workspaceId, ); let expiresIn: string | number; @@ -37,11 +29,18 @@ export class ApiKeyService { } else { expiresIn = '100y'; } - const token = this.jwtWrapperService.sign(jwtPayload, { - secret, - expiresIn, - jwtid: apiKeyId, - }); + const token = this.jwtWrapperService.sign( + { + sub: workspaceId, + type: JwtTokenTypeEnum.API_KEY, + workspaceId, + }, + { + secret, + expiresIn, + jwtid: apiKeyId, + }, + ); return { token }; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.service.ts index a779b8c58..d686b412e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.service.ts @@ -4,10 +4,11 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository export class AuthSsoService { constructor( @InjectRepository(Workspace, 'core') @@ -15,18 +16,16 @@ export class AuthSsoService { private readonly twentyConfigService: TwentyConfigService, ) {} - private getAuthProviderColumnNameByProvider( - authProvider: WorkspaceAuthProvider, - ) { - if (authProvider === 'google') { + private getAuthProviderColumnNameByProvider(authProvider: AuthProviderEnum) { + if (authProvider === AuthProviderEnum.Google) { return 'isGoogleAuthEnabled'; } - if (authProvider === 'microsoft') { + if (authProvider === AuthProviderEnum.Microsoft) { return 'isMicrosoftAuthEnabled'; } - if (authProvider === 'password') { + if (authProvider === AuthProviderEnum.Password) { return 'isPasswordAuthEnabled'; } @@ -34,10 +33,7 @@ export class AuthSsoService { } async findWorkspaceFromWorkspaceIdOrAuthProvider( - { - authProvider, - email, - }: { authProvider: WorkspaceAuthProvider; email: string }, + { authProvider, email }: { authProvider: AuthProviderEnum; email: string }, workspaceId?: string, ) { if ( diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts index 1fbba06ad..d20d31e9b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts @@ -6,6 +6,7 @@ import { Repository } from 'typeorm'; import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; describe('AuthSsoService', () => { let authSsoService: AuthSsoService; @@ -49,7 +50,7 @@ describe('AuthSsoService', () => { const result = await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( - { authProvider: 'google', email: 'test@example.com' }, + { authProvider: AuthProviderEnum.Google, email: 'test@example.com' }, workspaceId, ); @@ -63,7 +64,7 @@ describe('AuthSsoService', () => { }); it('should return a workspace from authProvider and email when multi-workspace mode is enabled', async () => { - const authProvider = 'google'; + const authProvider = AuthProviderEnum.Google; const email = 'test@example.com'; const mockWorkspace = { id: 'workspace-id-456' } as Workspace; @@ -102,7 +103,7 @@ describe('AuthSsoService', () => { const result = await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ - authProvider: 'google', + authProvider: AuthProviderEnum.Google, email: 'notfound@example.com', }); 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 3dc84386d..de952dbcb 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 @@ -14,7 +14,6 @@ import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-u import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { ExistingUserOrNewUser } from 'src/engine/core-modules/auth/types/signInUp.type'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; @@ -22,26 +21,26 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service' import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { AuthService } from './auth.service'; jest.mock('bcrypt'); -const UserFindOneMock = jest.fn(); -const UserWorkspacefindOneMock = jest.fn(); - -const userWorkspaceServiceCheckUserWorkspaceExistsMock = jest.fn(); -const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn(); -const workspaceInvitationValidatePersonalInvitationMock = jest.fn(); -const userWorkspaceAddUserToWorkspaceMock = jest.fn(); - const twentyConfigServiceGetMock = jest.fn(); describe('AuthService', () => { let service: AuthService; let userService: UserService; let workspaceRepository: Repository; + let userRepository: Repository; let authSsoService: AuthSsoService; + let userWorkspaceService: UserWorkspaceService; + let workspaceInvitationService: WorkspaceInvitationService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -56,7 +55,7 @@ describe('AuthService', () => { { provide: getRepositoryToken(User, 'core'), useValue: { - findOne: UserFindOneMock, + findOne: jest.fn(), }, }, { @@ -70,6 +69,22 @@ describe('AuthService', () => { }), }, }, + { + provide: LoginTokenService, + useValue: {}, + }, + { + provide: DomainManagerService, + useValue: {}, + }, + { + provide: WorkspaceAgnosticTokenService, + useValue: {}, + }, + { + provide: GuardRedirectService, + useValue: {}, + }, { provide: SignInUpService, useValue: {}, @@ -80,10 +95,6 @@ describe('AuthService', () => { get: twentyConfigServiceGetMock, }, }, - { - provide: DomainManagerService, - useValue: {}, - }, { provide: EmailService, useValue: {}, @@ -99,10 +110,9 @@ describe('AuthService', () => { { provide: UserWorkspaceService, useValue: { - checkUserWorkspaceExists: - userWorkspaceServiceCheckUserWorkspaceExistsMock, - addUserToWorkspaceIfUserNotInWorkspace: - userWorkspaceAddUserToWorkspaceMock, + checkUserWorkspaceExists: jest.fn(), + addUserToWorkspaceIfUserNotInWorkspace: jest.fn(), + findAvailableWorkspacesByEmail: jest.fn(), }, }, { @@ -114,10 +124,8 @@ describe('AuthService', () => { { provide: WorkspaceInvitationService, useValue: { - getOneWorkspaceInvitation: - workspaceInvitationGetOneWorkspaceInvitationMock, - validatePersonalInvitation: - workspaceInvitationValidatePersonalInvitationMock, + getOneWorkspaceInvitation: jest.fn(), + validatePersonalInvitation: jest.fn(), }, }, { @@ -131,10 +139,18 @@ describe('AuthService', () => { service = module.get(AuthService); userService = module.get(UserService); + workspaceInvitationService = module.get( + WorkspaceInvitationService, + ); authSsoService = module.get(AuthSsoService); + userWorkspaceService = + module.get(UserWorkspaceService); workspaceRepository = module.get>( getRepositoryToken(Workspace, 'core'), ); + userRepository = module.get>( + getRepositoryToken(User, 'core'), + ); }); beforeEach(() => { @@ -155,17 +171,17 @@ describe('AuthService', () => { (bcrypt.compare as jest.Mock).mockReturnValueOnce(true); - UserFindOneMock.mockReturnValueOnce({ + jest.spyOn(userRepository, 'findOne').mockReturnValueOnce({ email: user.email, passwordHash: 'passwordHash', captchaToken: user.captchaToken, - }); + } as unknown as Promise); - UserWorkspacefindOneMock.mockReturnValueOnce({}); + jest + .spyOn(userWorkspaceService, 'checkUserWorkspaceExists') + .mockReturnValueOnce({} as any); - userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce({}); - - const response = await service.getLoginTokenFromCredentials( + const response = await service.validateLoginWithPassword( { email: 'email', password: 'password', @@ -188,20 +204,32 @@ describe('AuthService', () => { captchaToken: 'captchaToken', }; - UserFindOneMock.mockReturnValueOnce({ - email: user.email, - passwordHash: 'passwordHash', - captchaToken: user.captchaToken, - }); + const UserFindOneSpy = jest + .spyOn(userRepository, 'findOne') + .mockReturnValueOnce({ + email: user.email, + passwordHash: 'passwordHash', + captchaToken: user.captchaToken, + } as unknown as Promise); (bcrypt.compare as jest.Mock).mockReturnValueOnce(true); - userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce(false); + jest + .spyOn(userWorkspaceService, 'checkUserWorkspaceExists') + .mockReturnValueOnce(null as any); - workspaceInvitationGetOneWorkspaceInvitationMock.mockReturnValueOnce({}); - workspaceInvitationValidatePersonalInvitationMock.mockReturnValueOnce({}); - userWorkspaceAddUserToWorkspaceMock.mockReturnValueOnce({}); + const getOneWorkspaceInvitationSpy = jest + .spyOn(workspaceInvitationService, 'getOneWorkspaceInvitation') + .mockReturnValueOnce({} as any); - const response = await service.getLoginTokenFromCredentials( + const workspaceInvitationValidatePersonalInvitationSpy = jest + .spyOn(workspaceInvitationService, 'validatePersonalInvitation') + .mockReturnValueOnce({} as any); + + const addUserToWorkspaceIfUserNotInWorkspaceSpy = jest + .spyOn(userWorkspaceService, 'addUserToWorkspaceIfUserNotInWorkspace') + .mockReturnValueOnce({} as any); + + const response = await service.validateLoginWithPassword( { email: 'email', password: 'password', @@ -218,14 +246,12 @@ describe('AuthService', () => { captchaToken: user.captchaToken, }); + expect(getOneWorkspaceInvitationSpy).toHaveBeenCalledTimes(1); expect( - workspaceInvitationGetOneWorkspaceInvitationMock, + workspaceInvitationValidatePersonalInvitationSpy, ).toHaveBeenCalledTimes(1); - expect( - workspaceInvitationValidatePersonalInvitationMock, - ).toHaveBeenCalledTimes(1); - expect(userWorkspaceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1); - expect(UserFindOneMock).toHaveBeenCalledTimes(1); + expect(addUserToWorkspaceIfUserNotInWorkspaceSpy).toHaveBeenCalledTimes(1); + expect(UserFindOneSpy).toHaveBeenCalledTimes(1); }); describe('checkAccessForSignIn', () => { @@ -418,7 +444,7 @@ describe('AuthService', () => { ); const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', + authProvider: AuthProviderEnum.Password, workspaceId: 'workspaceId', }); @@ -438,7 +464,7 @@ describe('AuthService', () => { ); const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', + authProvider: AuthProviderEnum.Password, workspaceId: 'workspaceId', workspaceInviteHash: 'workspaceInviteHash', }); @@ -459,7 +485,7 @@ describe('AuthService', () => { ); const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', + authProvider: AuthProviderEnum.Password, workspaceId: 'workspaceId', workspaceInviteHash: 'workspaceInviteHash', }); @@ -476,7 +502,7 @@ describe('AuthService', () => { .mockResolvedValue({} as Workspace); const result = await service.findWorkspaceForSignInUp({ - authProvider: 'google', + authProvider: AuthProviderEnum.Google, workspaceId: 'workspaceId', email: 'email', }); @@ -493,7 +519,7 @@ describe('AuthService', () => { .mockResolvedValue({} as Workspace); const result = await service.findWorkspaceForSignInUp({ - authProvider: 'sso', + authProvider: AuthProviderEnum.SSO, workspaceId: 'workspaceId', email: 'email', }); 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 24658eaa2..d65f35f74 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 @@ -30,13 +30,9 @@ import { } from 'src/engine/core-modules/auth/auth.util'; import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity'; import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input'; -import { GetLoginTokenFromCredentialsInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-credentials.input'; +import { UserCredentialsInput } from 'src/engine/core-modules/auth/dto/user-credentials.input'; import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity'; import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity'; -import { - UserExists, - UserNotExists, -} from 'src/engine/core-modules/auth/dto/user-exists.entity'; import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity'; import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; @@ -57,17 +53,27 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service' import { User } from 'src/engine/core-modules/user/user.entity'; import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; -import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { CheckUserExistOutput } from 'src/engine/core-modules/auth/dto/user-exists.entity'; +import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; +import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; +import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class AuthService { constructor( private readonly accessTokenService: AccessTokenService, + private readonly workspaceAgnosticTokenService: WorkspaceAgnosticTokenService, private readonly domainManagerService: DomainManagerService, private readonly refreshTokenService: RefreshTokenService, + private readonly loginTokenService: LoginTokenService, + private readonly guardRedirectService: GuardRedirectService, private readonly userWorkspaceService: UserWorkspaceService, private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly authSsoService: AuthSsoService, @@ -121,11 +127,11 @@ export class AuthService { ); } - async getLoginTokenFromCredentials( - input: GetLoginTokenFromCredentialsInput, - targetWorkspace: Workspace, + async validateLoginWithPassword( + input: UserCredentialsInput, + targetWorkspace?: Workspace, ) { - if (!targetWorkspace.isPasswordAuthEnabled) { + if (targetWorkspace && !targetWorkspace.isPasswordAuthEnabled) { throw new AuthException( 'Email/Password auth is not enabled for this workspace', AuthExceptionCode.FORBIDDEN_EXCEPTION, @@ -146,7 +152,9 @@ export class AuthService { ); } - await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user); + if (targetWorkspace) { + await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user); + } if (!user.passwordHash) { throw new AuthException( @@ -182,7 +190,7 @@ export class AuthService { userData: ExistingUserOrNewUser['userData'], authParams: Extract< AuthProviderWithPasswordType['authParams'], - { provider: 'password' } + { provider: AuthProviderEnum.Password } >, ) { if (userData.type === 'newUser') { @@ -203,7 +211,7 @@ export class AuthService { authParams: AuthProviderWithPasswordType['authParams'], workspace: Workspace | undefined | null, ) { - if (authParams.provider === 'password') { + if (authParams.provider === AuthProviderEnum.Password) { await this.validatePassword(userData, authParams); } @@ -248,7 +256,11 @@ export class AuthService { }); } - async verify(email: string, workspaceId: string): Promise { + async verify( + email: string, + workspaceId: string, + authProvider: AuthProviderEnum, + ): Promise { if (!email) { throw new AuthException( 'Email is required', @@ -268,14 +280,17 @@ export class AuthService { // passwordHash is hidden for security reasons user.passwordHash = ''; - const accessToken = await this.accessTokenService.generateAccessToken( - user.id, + const accessToken = await this.accessTokenService.generateAccessToken({ + userId: user.id, workspaceId, - ); - const refreshToken = await this.refreshTokenService.generateRefreshToken( - user.id, + authProvider, + }); + const refreshToken = await this.refreshTokenService.generateRefreshToken({ + userId: user.id, workspaceId, - ); + authProvider, + targetedTokenType: JwtTokenTypeEnum.ACCESS, + }); return { tokens: { @@ -285,21 +300,25 @@ export class AuthService { }; } - async checkUserExists(email: string): Promise { + async countAvailableWorkspacesByEmail(email: string): Promise { + return Object.values( + await this.userWorkspaceService.findAvailableWorkspacesByEmail(email), + ).flat(2).length; + } + + async checkUserExists(email: string): Promise { const user = await this.userRepository.findOneBy({ email, }); - if (userValidator.isDefined(user)) { - return { - exists: true, - availableWorkspaces: - await this.userWorkspaceService.findAvailableWorkspacesByEmail(email), - isEmailVerified: user.isEmailVerified, - }; - } + const isUserExist = userValidator.isDefined(user); - return { exists: false }; + return { + exists: isUserExist, + availableWorkspacesCount: + await this.countAvailableWorkspacesByEmail(email), + isEmailVerified: isUserExist ? user.isEmailVerified : false, + }; } async checkWorkspaceInviteHashIsValid( @@ -533,10 +552,10 @@ export class AuthService { workspaceInviteHash?: string; } & ( | { - authProvider: Exclude; + authProvider: Exclude; email: string; } - | { authProvider: Extract } + | { authProvider: Extract } ), ) { if (params.workspaceInviteHash) { @@ -550,7 +569,7 @@ export class AuthService { ); } - if (params.authProvider !== 'password') { + if (params.authProvider !== AuthProviderEnum.Password) { return ( (await this.authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( { @@ -649,4 +668,142 @@ export class AuthService { ); } } + + async signInUpWithSocialSSO( + { + firstName, + lastName, + email: rawEmail, + picture, + workspaceInviteHash, + workspaceId, + billingCheckoutSessionState, + action, + locale, + }: MicrosoftRequest['user'] | GoogleRequest['user'], + authProvider: AuthProviderEnum.Google | AuthProviderEnum.Microsoft, + ): Promise { + const email = rawEmail.toLowerCase(); + + const availableWorkspacesCount = + action === 'list-available-workspaces' + ? await this.countAvailableWorkspacesByEmail(email) + : 0; + + const existingUser = await this.userRepository.findOne({ + where: { email }, + }); + + if ( + !workspaceId && + !workspaceInviteHash && + action === 'list-available-workspaces' && + availableWorkspacesCount !== 0 + ) { + const user = + existingUser ?? + (await this.signInUpService.signUpWithoutWorkspace( + { + firstName, + lastName, + email, + picture, + }, + { + provider: authProvider, + }, + )); + + const url = this.domainManagerService.buildBaseUrl({ + pathname: '/welcome', + searchParams: { + tokenPair: JSON.stringify({ + accessToken: + await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken( + { + userId: user.id, + authProvider, + }, + ), + refreshToken: await this.refreshTokenService.generateRefreshToken({ + userId: user.id, + authProvider, + targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, + }), + }), + }, + }); + + return url.toString(); + } + + const currentWorkspace = + action === 'create-new-workspace' + ? undefined + : await this.findWorkspaceForSignInUp({ + workspaceId, + workspaceInviteHash, + email, + authProvider, + }); + + try { + const invitation = + currentWorkspace && email + ? await this.findInvitationForSignInUp({ + currentWorkspace, + email, + }) + : undefined; + + const { userData } = this.formatUserDataPayload( + { + firstName, + lastName, + email, + picture, + locale, + }, + existingUser, + ); + + await this.checkAccessForSignIn({ + userData, + invitation, + workspaceInviteHash, + workspace: currentWorkspace, + }); + + const { user, workspace } = await this.signInUp({ + invitation, + workspace: currentWorkspace, + userData, + authParams: { + provider: AuthProviderEnum.Google, + }, + billingCheckoutSessionState, + }); + + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + workspace.id, + authProvider, + ); + + return this.computeRedirectURI({ + loginToken: loginToken.token, + workspace, + billingCheckoutSessionState, + }); + } catch (error) { + return this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({ + error, + workspace: + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( + currentWorkspace, + ), + pathname: '/verify', + }); + } + } } 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 c312b8a9e..80191e44c 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 @@ -5,6 +5,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { Repository } from 'typeorm'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AuthException, @@ -28,6 +29,7 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; jest.mock('src/utils/image', () => { return { @@ -96,6 +98,10 @@ describe('SignInUpService', () => { provide: HttpService, useValue: {}, }, + { + provide: LoginTokenService, + useValue: {}, + }, { provide: TwentyConfigService, useValue: { @@ -155,7 +161,10 @@ describe('SignInUpService', () => { id: 'workspaceId', activationStatus: WorkspaceActivationStatus.ACTIVE, } as Workspace, - authParams: { provider: 'password', password: 'validPassword' }, + authParams: { + provider: AuthProviderEnum.Password, + password: 'validPassword', + }, userData: { type: 'existingUser', existingUser: { email: 'test@example.com' } as User, @@ -206,7 +215,10 @@ describe('SignInUpService', () => { id: 'workspaceId', activationStatus: WorkspaceActivationStatus.ACTIVE, } as Workspace, - authParams: { provider: 'password', password: 'validPassword' }, + authParams: { + provider: AuthProviderEnum.Password, + password: 'validPassword', + }, userData: { type: 'existingUser', existingUser: { email: 'test@example.com' } as User, @@ -230,7 +242,10 @@ describe('SignInUpService', () => { const params: SignInUpBaseParams & ExistingUserOrPartialUserWithPicture & AuthProviderWithPasswordType = { - authParams: { provider: 'password', password: 'validPassword' }, + authParams: { + provider: AuthProviderEnum.Password, + password: 'validPassword', + }, userData: { type: 'newUserWithPicture', newUserWithPicture: { @@ -283,7 +298,10 @@ describe('SignInUpService', () => { id: 'workspaceId', activationStatus: WorkspaceActivationStatus.PENDING_CREATION, } as Workspace, - authParams: { provider: 'password', password: 'validPassword' }, + authParams: { + provider: AuthProviderEnum.Password, + password: 'validPassword', + }, userData: { type: 'existingUser', existingUser: { email: 'test@example.com' } as User, @@ -315,7 +333,10 @@ describe('SignInUpService', () => { id: 'workspaceId', activationStatus: WorkspaceActivationStatus.PENDING_CREATION, } as Workspace, - authParams: { provider: 'password', password: 'validPassword' }, + authParams: { + provider: AuthProviderEnum.Password, + password: 'validPassword', + }, userData: { type: 'existingUser', existingUser: { email: 'test@example.com' } as User, @@ -340,7 +361,10 @@ describe('SignInUpService', () => { ExistingUserOrPartialUserWithPicture & AuthProviderWithPasswordType = { workspace: null, - authParams: { provider: 'password', password: 'validPassword' }, + authParams: { + provider: AuthProviderEnum.Password, + password: 'validPassword', + }, userData: { type: 'existingUser', existingUser: { email: 'existinguser@example.com' } as User, 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 4becf5a20..80c8c7a4d 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 @@ -25,8 +25,6 @@ import { SignInUpNewUserPayload, } from 'src/engine/core-modules/auth/types/signInUp.type'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; -import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; @@ -34,9 +32,10 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service' import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; import { isWorkEmail } from 'src/utils/is-work-email'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -46,16 +45,14 @@ export class SignInUpService { private readonly userRepository: Repository, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, - private readonly fileUploadService: FileUploadService, private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly userWorkspaceService: UserWorkspaceService, private readonly onboardingService: OnboardingService, + private readonly loginTokenService: LoginTokenService, private readonly httpService: HttpService, private readonly twentyConfigService: TwentyConfigService, private readonly domainManagerService: DomainManagerService, private readonly userService: UserService, - private readonly userRoleService: UserRoleService, - private readonly featureFlagService: FeatureFlagService, ) {} async computeParamsForNewUser( @@ -72,7 +69,7 @@ export class SignInUpService { ); } - if (authParams.provider === 'password') { + if (authParams.provider === AuthProviderEnum.Password) { newUserParams.passwordHash = await this.generateHash(authParams.password); } @@ -293,11 +290,27 @@ export class SignInUpService { return await this.userRepository.save(userCreated); } + private async setDefaultImpersonateAndAccessFullAdminPanel() { + if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) { + const workspacesCount = await this.workspaceRepository.count(); + + // let the creation of the first workspace + if (workspacesCount > 0) { + throw new AuthException( + 'New workspace setup is disabled', + AuthExceptionCode.SIGNUP_DISABLED, + ); + } + + return { canImpersonate: true, canAccessFullAdminPanel: true }; + } + + return { canImpersonate: false, canAccessFullAdminPanel: false }; + } + async signUpOnNewWorkspace( userData: ExistingUserOrPartialUserWithPicture['userData'], ) { - let canImpersonate = false; - let canAccessFullAdminPanel = false; const email = userData.type === 'newUserWithPicture' ? userData.newUserWithPicture.email @@ -310,21 +323,8 @@ export class SignInUpService { ); } - if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) { - const workspacesCount = await this.workspaceRepository.count(); - - // if the workspace doesn't exist it means it's the first user of the workspace - canImpersonate = true; - canAccessFullAdminPanel = true; - - // let the creation of the first workspace - if (workspacesCount > 0) { - throw new AuthException( - 'New workspace setup is disabled', - AuthExceptionCode.SIGNUP_DISABLED, - ); - } - } + const { canImpersonate, canAccessFullAdminPanel } = + await this.setDefaultImpersonateAndAccessFullAdminPanel(); const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`; const isLogoUrlValid = async () => { @@ -380,4 +380,14 @@ export class SignInUpService { return { user, workspace }; } + + async signUpWithoutWorkspace( + newUserParams: SignInUpNewUserPayload, + authParams: AuthProviderWithPasswordType['authParams'], + ) { + return this.saveNewUser( + await this.computeParamsForNewUser(newUserParams, authParams), + await this.setDefaultImpersonateAndAccessFullAdminPanel(), + ); + } } 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 66d881d75..e5b4ba528 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 @@ -6,6 +6,7 @@ import { Strategy, VerifyCallback } from 'passport-google-oauth20'; import { APP_LOCALES } from 'twenty-shared/translations'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { SocialSSOSignInUpActionType } from 'src/engine/core-modules/auth/types/signInUp.type'; export type GoogleRequest = Omit< Request, @@ -19,6 +20,7 @@ export type GoogleRequest = Omit< locale?: keyof typeof APP_LOCALES | null; workspaceInviteHash?: string; workspacePersonalInviteToken?: string; + action: SocialSSOSignInUpActionType; workspaceId?: string; billingCheckoutSessionState?: string; }; @@ -45,6 +47,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { workspaceId: req.params.workspaceId, billingCheckoutSessionState: req.query.billingCheckoutSessionState, workspacePersonalInviteToken: req.query.workspacePersonalInviteToken, + action: req.query.action, }), }; @@ -53,8 +56,8 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { async validate( request: GoogleRequest, - accessToken: string, - refreshToken: string, + _accessToken: string, + _refreshToken: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any profile: any, done: VerifyCallback, @@ -74,6 +77,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { workspacePersonalInviteToken: state.workspacePersonalInviteToken, workspaceId: state.workspaceId, billingCheckoutSessionState: state.billingCheckoutSessionState, + action: state.action, locale: state.locale, }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts index e771641d9..ea680a2f5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -155,12 +155,12 @@ describe('JwtAuthStrategy', () => { ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( - new AuthException('User not found', expect.any(String)), + new AuthException('UserWorkspace not found', expect.any(String)), ); try { await strategy.validate(payload as JwtPayload); } catch (e) { - expect(e.code).toBe(AuthExceptionCode.USER_NOT_FOUND); + expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND); } }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index d62162fd8..564167215 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -10,8 +10,12 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { + AccessTokenJwtPayload, + ApiKeyTokenJwtPayload, AuthContext, + FileTokenJwtPayload, JwtPayload, + WorkspaceAgnosticTokenJwtPayload, } from 'src/engine/core-modules/auth/types/auth-context.type'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; @@ -19,6 +23,9 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; +import { userWorkspaceValidator } from 'src/engine/core-modules/user-workspace/user-workspace.validate'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { userValidator } from 'src/engine/core-modules/user/user.validate'; @Injectable() export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { @@ -36,13 +43,20 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { // @ts-expect-error legacy noImplicitAny const secretOrKeyProviderFunction = async (_request, rawJwtToken, done) => { try { - const decodedToken = jwtWrapperService.decode( - rawJwtToken, - ) as JwtPayload; - const workspaceId = decodedToken.workspaceId; + const decodedToken = jwtWrapperService.decode< + | FileTokenJwtPayload + | AccessTokenJwtPayload + | WorkspaceAgnosticTokenJwtPayload + >(rawJwtToken); + + const appSecretBody = + decodedToken.type === 'WORKSPACE_AGNOSTIC' + ? decodedToken.userId + : decodedToken.workspaceId; + const secret = jwtWrapperService.generateAppSecret( - 'ACCESS', - workspaceId, + decodedToken.type, + appSecretBody, ); done(null, secret); @@ -58,19 +72,20 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { }); } - private async validateAPIKey(payload: JwtPayload): Promise { - let apiKey: ApiKeyWorkspaceEntity | null = null; - + private async validateAPIKey( + payload: ApiKeyTokenJwtPayload, + ): Promise { const workspace = await this.workspaceRepository.findOneBy({ - id: payload['sub'], + id: payload.sub, }); - if (!workspace) { - throw new AuthException( + workspaceValidator.assertIsDefinedOrThrow( + workspace, + new AuthException( 'Workspace not found', AuthExceptionCode.WORKSPACE_NOT_FOUND, - ); - } + ), + ); const apiKeyRepository = await this.twentyORMGlobalManager.getRepositoryForWorkspace( @@ -78,7 +93,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { 'apiKey', ); - apiKey = await apiKeyRepository.findOne({ + const apiKey = await apiKeyRepository.findOne({ where: { id: payload.jti, }, @@ -91,13 +106,15 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { ); } - return { apiKey, workspace }; + return { apiKey, workspace, workspaceMemberId: payload.workspaceMemberId }; } - private async validateAccessToken(payload: JwtPayload): Promise { + private async validateAccessToken( + payload: AccessTokenJwtPayload, + ): Promise { let user: User | null = null; const workspace = await this.workspaceRepository.findOneBy({ - id: payload['workspaceId'], + id: payload.workspaceId, }); if (!workspace) { @@ -107,16 +124,19 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { ); } - user = await this.userRepository.findOne({ - where: { id: payload.sub }, - }); - if (!user) { + const userId = payload.sub ?? payload.userId; + + if (!userId) { throw new AuthException( 'User not found', AuthExceptionCode.USER_NOT_FOUND, ); } + user = await this.userRepository.findOne({ + where: { id: userId }, + }); + if (!payload.userWorkspaceId) { throw new AuthException( 'UserWorkspace not found', @@ -130,27 +150,62 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { }, }); - if (!userWorkspace) { - throw new AuthException( + userWorkspaceValidator.assertIsDefinedOrThrow( + userWorkspace, + new AuthException( 'UserWorkspace not found', AuthExceptionCode.USER_WORKSPACE_NOT_FOUND, - ); - } + ), + ); - return { user, workspace, userWorkspaceId: userWorkspace.id }; + return { + user, + workspace, + authProvider: payload.authProvider, + userWorkspaceId: userWorkspace.id, + workspaceMemberId: payload.workspaceMemberId, + }; + } + + private async validateWorkspaceAgnosticToken( + payload: WorkspaceAgnosticTokenJwtPayload, + ) { + const user = await this.userRepository.findOne({ + where: { id: payload.sub }, + }); + + userValidator.assertIsDefinedOrThrow( + user, + new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + ); + + return { user, authProvider: payload.authProvider }; + } + + private isLegacyApiKeyPayload( + payload: JwtPayload, + ): payload is ApiKeyTokenJwtPayload { + return !payload.type && !('workspaceId' in payload); } async validate(payload: JwtPayload): Promise { - const workspaceMemberId = payload.workspaceMemberId; - - if (!payload.type && !payload.workspaceId) { - return { ...(await this.validateAPIKey(payload)), workspaceMemberId }; + // Support legacy api keys + if (payload.type === 'API_KEY' || this.isLegacyApiKeyPayload(payload)) { + return await this.validateAPIKey(payload); } - if (payload.type === 'API_KEY') { - return { ...(await this.validateAPIKey(payload)), workspaceMemberId }; + if (payload.type === 'WORKSPACE_AGNOSTIC') { + return await this.validateWorkspaceAgnosticToken(payload); } - return { ...(await this.validateAccessToken(payload)), workspaceMemberId }; + // `!payload.type` is here to support legacy token + if (payload.type === 'ACCESS' || !payload.type) { + return await this.validateAccessToken(payload); + } + + throw new AuthException( + 'Invalid token', + AuthExceptionCode.INVALID_JWT_TOKEN_TYPE, + ); } } 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 4dddd7153..3561d91f5 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 @@ -10,6 +10,7 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { SocialSSOSignInUpActionType } from 'src/engine/core-modules/auth/types/signInUp.type'; export type MicrosoftRequest = Omit< Request, @@ -25,6 +26,7 @@ export type MicrosoftRequest = Omit< workspacePersonalInviteToken?: string; workspaceId?: string; billingCheckoutSessionState?: string; + action: SocialSSOSignInUpActionType; }; }; @@ -50,6 +52,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { locale: req.query.locale, billingCheckoutSessionState: req.query.billingCheckoutSessionState, workspacePersonalInviteToken: req.query.workspacePersonalInviteToken, + action: req.query.action, }), }; @@ -58,8 +61,8 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { async validate( request: MicrosoftRequest, - accessToken: string, - refreshToken: string, + _accessToken: string, + _refreshToken: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any profile: any, done: VerifyCallback, @@ -90,6 +93,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { workspaceId: state.workspaceId, billingCheckoutSessionState: state.billingCheckoutSessionState, locale: state.locale, + action: state.action, }; done(null, user); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts index 2a46f63db..0494de098 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts @@ -36,7 +36,7 @@ describe('AccessTokenService', () => { provide: JwtWrapperService, useValue: { sign: jest.fn(), - verifyWorkspaceToken: jest.fn(), + verifyJwtToken: jest.fn(), decode: jest.fn(), generateAppSecret: jest.fn(), extractJwtFromRequest: jest.fn(), @@ -138,7 +138,7 @@ describe('AccessTokenService', () => { } as any); jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); - const result = await service.generateAccessToken(userId, workspaceId); + const result = await service.generateAccessToken({ userId, workspaceId }); expect(result).toEqual({ token: mockToken, @@ -159,7 +159,10 @@ describe('AccessTokenService', () => { jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); await expect( - service.generateAccessToken('non-existent-user', 'workspace-id'), + service.generateAccessToken({ + userId: 'non-existent-user', + workspaceId: 'workspace-id', + }), ).rejects.toThrow(AuthException); }); }); @@ -184,7 +187,7 @@ describe('AccessTokenService', () => { .spyOn(jwtWrapperService, 'extractJwtFromRequest') .mockReturnValue(() => mockToken); jest - .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .spyOn(jwtWrapperService, 'verifyJwtToken') .mockResolvedValue(undefined); jest .spyOn(jwtWrapperService, 'decode') @@ -196,7 +199,7 @@ describe('AccessTokenService', () => { const result = await service.validateTokenByRequest(mockRequest); expect(result).toEqual(mockAuthContext); - expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( + expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith( mockToken, 'ACCESS', ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts index 0095cd776..0e1c7c3ad 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts @@ -14,8 +14,9 @@ import { import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; import { + AccessTokenJwtPayload, AuthContext, - JwtPayload, + JwtTokenTypeEnum, } from 'src/engine/core-modules/auth/types/auth-context.type'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -26,6 +27,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { userWorkspaceValidator } from 'src/engine/core-modules/user-workspace/user-workspace.validate'; @Injectable() export class AccessTokenService { @@ -42,10 +44,14 @@ export class AccessTokenService { private readonly userWorkspaceRepository: Repository, ) {} - async generateAccessToken( - userId: string, - workspaceId: string, - ): Promise { + async generateAccessToken({ + userId, + workspaceId, + authProvider, + }: Omit< + AccessTokenJwtPayload, + 'type' | 'workspaceMemberId' | 'userWorkspaceId' | 'sub' + >): Promise { const expiresIn = this.twentyConfigService.get('ACCESS_TOKEN_EXPIRES_IN'); const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); @@ -99,16 +105,24 @@ export class AccessTokenService { }, }); - const jwtPayload: JwtPayload = { + userWorkspaceValidator.assertIsDefinedOrThrow(userWorkspace); + + const jwtPayload: AccessTokenJwtPayload = { sub: user.id, + userId: user.id, workspaceId, workspaceMemberId: tokenWorkspaceMemberId, - userWorkspaceId: userWorkspace?.id, + userWorkspaceId: userWorkspace.id, + type: JwtTokenTypeEnum.ACCESS, + authProvider, }; return { token: this.jwtWrapperService.sign(jwtPayload, { - secret: this.jwtWrapperService.generateAppSecret('ACCESS', workspaceId), + secret: this.jwtWrapperService.generateAppSecret( + JwtTokenTypeEnum.ACCESS, + workspaceId, + ), expiresIn, }), expiresAt, @@ -116,14 +130,27 @@ export class AccessTokenService { } async validateToken(token: string): Promise { - await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS'); + await this.jwtWrapperService.verifyJwtToken(token, JwtTokenTypeEnum.ACCESS); - const decoded = await this.jwtWrapperService.decode(token); + const decoded = this.jwtWrapperService.decode(token); - const { user, apiKey, workspace, workspaceMemberId, userWorkspaceId } = - await this.jwtStrategy.validate(decoded as JwtPayload); + const { + user, + apiKey, + workspace, + workspaceMemberId, + userWorkspaceId, + authProvider, + } = await this.jwtStrategy.validate(decoded); - return { user, apiKey, workspace, workspaceMemberId, userWorkspaceId }; + return { + user, + apiKey, + workspace, + workspaceMemberId, + userWorkspaceId, + authProvider, + }; } async validateTokenByRequest(request: Request): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts index 823431950..c2f41ce32 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts @@ -19,7 +19,7 @@ describe('LoginTokenService', () => { useValue: { generateAppSecret: jest.fn(), sign: jest.fn(), - verifyWorkspaceToken: jest.fn(), + verifyJwtToken: jest.fn(), decode: jest.fn(), }, }, @@ -69,7 +69,7 @@ describe('LoginTokenService', () => { 'LOGIN_TOKEN_EXPIRES_IN', ); expect(jwtWrapperService.sign).toHaveBeenCalledWith( - { sub: email, workspaceId }, + { sub: email, workspaceId, type: 'LOGIN' }, { secret: mockSecret, expiresIn: mockExpiresIn }, ); }); @@ -81,7 +81,7 @@ describe('LoginTokenService', () => { const mockEmail = 'test@example.com'; jest - .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .spyOn(jwtWrapperService, 'verifyJwtToken') .mockResolvedValue(undefined); jest .spyOn(jwtWrapperService, 'decode') @@ -90,7 +90,7 @@ describe('LoginTokenService', () => { const result = await service.verifyLoginToken(mockToken); expect(result).toEqual({ sub: mockEmail }); - expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( + expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith( mockToken, 'LOGIN', ); @@ -103,7 +103,7 @@ describe('LoginTokenService', () => { const mockToken = 'invalid-token'; jest - .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .spyOn(jwtWrapperService, 'verifyJwtToken') .mockRejectedValue(new Error('Invalid token')); await expect(service.verifyLoginToken(mockToken)).rejects.toThrow(); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts index b125393c4..562d25243 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts @@ -6,6 +6,11 @@ import ms from 'ms'; import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { + LoginTokenJwtPayload, + JwtTokenTypeEnum, +} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; @Injectable() export class LoginTokenService { @@ -17,19 +22,23 @@ export class LoginTokenService { async generateLoginToken( email: string, workspaceId: string, + authProvider?: AuthProviderEnum, ): Promise { + const jwtPayload: LoginTokenJwtPayload = { + type: JwtTokenTypeEnum.LOGIN, + sub: email, + workspaceId, + authProvider, + }; + const secret = this.jwtWrapperService.generateAppSecret( - 'LOGIN', + jwtPayload.type, workspaceId, ); const expiresIn = this.twentyConfigService.get('LOGIN_TOKEN_EXPIRES_IN'); const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - const jwtPayload = { - sub: email, - workspaceId, - }; return { token: this.jwtWrapperService.sign(jwtPayload, { @@ -40,10 +49,15 @@ export class LoginTokenService { }; } - async verifyLoginToken( - loginToken: string, - ): Promise<{ sub: string; workspaceId: string }> { - await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN'); + async verifyLoginToken(loginToken: string): Promise<{ + sub: string; + workspaceId: string; + authProvider: AuthProviderEnum; + }> { + await this.jwtWrapperService.verifyJwtToken( + loginToken, + JwtTokenTypeEnum.LOGIN, + ); return this.jwtWrapperService.decode(loginToken, { json: true, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts index 6262d08f8..f6cc852ca 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts @@ -8,6 +8,7 @@ import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; import { RefreshTokenService } from './refresh-token.service'; @@ -25,7 +26,7 @@ describe('RefreshTokenService', () => { { provide: JwtWrapperService, useValue: { - verifyWorkspaceToken: jest.fn(), + verifyJwtToken: jest.fn(), decode: jest.fn(), sign: jest.fn(), generateAppSecret: jest.fn(), @@ -84,7 +85,7 @@ describe('RefreshTokenService', () => { }; jest - .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .spyOn(jwtWrapperService, 'verifyJwtToken') .mockResolvedValue(undefined); jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockJwtPayload); jest @@ -96,7 +97,7 @@ describe('RefreshTokenService', () => { const result = await service.verifyRefreshToken(mockToken); expect(result).toEqual({ user: mockUser, token: mockAppToken }); - expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( + expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith( mockToken, 'REFRESH', ); @@ -106,7 +107,7 @@ describe('RefreshTokenService', () => { const mockToken = 'invalid-token'; jest - .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .spyOn(jwtWrapperService, 'verifyJwtToken') .mockResolvedValue(undefined); jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({}); @@ -135,7 +136,11 @@ describe('RefreshTokenService', () => { .spyOn(appTokenRepository, 'save') .mockResolvedValue({ id: 'new-token-id' } as AppToken); - const result = await service.generateRefreshToken(userId, workspaceId); + const result = await service.generateRefreshToken({ + userId, + workspaceId, + targetedTokenType: JwtTokenTypeEnum.ACCESS, + }); expect(result).toEqual({ token: mockToken, @@ -143,7 +148,13 @@ describe('RefreshTokenService', () => { }); expect(appTokenRepository.save).toHaveBeenCalled(); expect(jwtWrapperService.sign).toHaveBeenCalledWith( - { sub: userId, workspaceId }, + { + sub: userId, + workspaceId, + type: 'REFRESH', + userId: 'user-id', + targetedTokenType: 'ACCESS', + }, expect.objectContaining({ secret: 'mock-secret', expiresIn: mockExpiresIn, @@ -156,7 +167,11 @@ describe('RefreshTokenService', () => { jest.spyOn(twentyConfigService, 'get').mockReturnValue(undefined); await expect( - service.generateRefreshToken('user-id', 'workspace-id'), + service.generateRefreshToken({ + userId: 'user-id', + workspaceId: 'workspace-id', + targetedTokenType: JwtTokenTypeEnum.ACCESS, + }), ).rejects.toThrow(AuthException); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts index 40172c2e3..61bf9caff 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts @@ -17,6 +17,10 @@ import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { + RefreshTokenJwtPayload, + JwtTokenTypeEnum, +} from 'src/engine/core-modules/auth/types/auth-context.type'; @Injectable() export class RefreshTokenService { @@ -32,8 +36,12 @@ export class RefreshTokenService { async verifyRefreshToken(refreshToken: string) { const coolDown = this.twentyConfigService.get('REFRESH_TOKEN_COOL_DOWN'); - await this.jwtWrapperService.verifyWorkspaceToken(refreshToken, 'REFRESH'); - const jwtPayload = await this.jwtWrapperService.decode(refreshToken); + await this.jwtWrapperService.verifyJwtToken( + refreshToken, + JwtTokenTypeEnum.REFRESH, + ); + const jwtPayload = + this.jwtWrapperService.decode(refreshToken); if (!(jwtPayload.jti && jwtPayload.sub)) { throw new AuthException( @@ -90,24 +98,20 @@ export class RefreshTokenService { ); } - // TODO: Delete this useless condition and error after March 31st 2025 - if (!token.workspaceId) { - throw new AuthException( - 'This refresh token is malformed', - AuthExceptionCode.INVALID_INPUT, - ); - } - - return { user, token }; + return { + user, + token, + authProvider: jwtPayload.authProvider, + targetedTokenType: jwtPayload.targetedTokenType, + }; } async generateRefreshToken( - userId: string, - workspaceId: string, + payload: Omit, ): Promise { const secret = this.jwtWrapperService.generateAppSecret( - 'REFRESH', - workspaceId, + JwtTokenTypeEnum.REFRESH, + payload.workspaceId ?? payload.userId, ); const expiresIn = this.twentyConfigService.get('REFRESH_TOKEN_EXPIRES_IN'); @@ -120,28 +124,27 @@ export class RefreshTokenService { const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - const refreshTokenPayload = { - userId, + const refreshToken = this.appTokenRepository.create({ + ...payload, expiresAt, - workspaceId, type: AppTokenType.RefreshToken, - }; - const jwtPayload = { - sub: userId, - workspaceId, - }; - - const refreshToken = this.appTokenRepository.create(refreshTokenPayload); + }); await this.appTokenRepository.save(refreshToken); return { - token: this.jwtWrapperService.sign(jwtPayload, { - secret, - expiresIn, - // Jwtid will be used to link RefreshToken entity to this token - jwtid: refreshToken.id, - }), + token: this.jwtWrapperService.sign( + { + ...payload, + sub: payload.userId, + type: JwtTokenTypeEnum.REFRESH, + }, + { + secret, + expiresIn, + jwtid: refreshToken.id, + }, + ), expiresAt, }; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts index d44fb5473..04371f244 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts @@ -3,11 +3,13 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; import { RenewTokenService } from './renew-token.service'; @@ -31,6 +33,12 @@ describe('RenewTokenService', () => { generateAccessToken: jest.fn(), }, }, + { + provide: WorkspaceAgnosticTokenService, + useValue: { + generateWorkspaceAgnosticToken: jest.fn(), + }, + }, { provide: RefreshTokenService, useValue: { @@ -66,6 +74,7 @@ describe('RenewTokenService', () => { const mockNewRefreshToken = { token: 'new-refresh-token', expiresAt: new Date(), + targetedTokenType: JwtTokenTypeEnum.ACCESS, }; const mockAppToken: Partial = { id: mockTokenId, @@ -77,6 +86,8 @@ describe('RenewTokenService', () => { jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({ user: mockUser, token: mockAppToken as AppToken, + authProvider: undefined, + targetedTokenType: JwtTokenTypeEnum.ACCESS, }); jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any); jest @@ -100,14 +111,16 @@ describe('RenewTokenService', () => { { id: mockTokenId }, { revokedAt: expect.any(Date) }, ); - expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith( - mockUser.id, - mockWorkspaceId, - ); - expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith( - mockUser.id, - mockWorkspaceId, - ); + expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith({ + userId: mockUser.id, + workspaceId: mockWorkspaceId, + }); + expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith({ + authProvider: undefined, + targetedTokenType: JwtTokenTypeEnum.ACCESS, + userId: mockUser.id, + workspaceId: mockWorkspaceId, + }); }); it('should throw an error if refresh token is not provided', async () => { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts index a06e4790f..be6f3cad2 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { isDefined } from 'twenty-shared/utils'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { @@ -10,7 +11,9 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; @Injectable() export class RenewTokenService { @@ -18,6 +21,7 @@ export class RenewTokenService { @InjectRepository(AppToken, 'core') private readonly appTokenRepository: Repository, private readonly accessTokenService: AccessTokenService, + private readonly workspaceAgnosticTokenService: WorkspaceAgnosticTokenService, private readonly refreshTokenService: RefreshTokenService, ) {} @@ -35,6 +39,8 @@ export class RenewTokenService { const { user, token: { id, workspaceId }, + authProvider, + targetedTokenType: targetedTokenTypeFromPayload, } = await this.refreshTokenService.verifyRefreshToken(token); // Revoke old refresh token @@ -47,14 +53,31 @@ export class RenewTokenService { }, ); - const accessToken = await this.accessTokenService.generateAccessToken( - user.id, + // Support legacy token when targetedTokenType is undefined. + const targetedTokenType = + targetedTokenTypeFromPayload ?? JwtTokenTypeEnum.ACCESS; + + const accessToken = + isDefined(authProvider) && + targetedTokenType === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC + ? await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken( + { + userId: user.id, + authProvider, + }, + ) + : await this.accessTokenService.generateAccessToken({ + userId: user.id, + workspaceId, + authProvider, + }); + + const refreshToken = await this.refreshTokenService.generateRefreshToken({ + userId: user.id, workspaceId, - ); - const refreshToken = await this.refreshTokenService.generateRefreshToken( - user.id, - workspaceId, - ); + authProvider, + targetedTokenType, + }); return { accessToken, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts index 4e774a279..81dddfd02 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts @@ -18,7 +18,7 @@ describe('TransientTokenService', () => { provide: JwtWrapperService, useValue: { sign: jest.fn(), - verifyWorkspaceToken: jest.fn(), + verifyJwtToken: jest.fn(), decode: jest.fn(), generateAppSecret: jest.fn().mockReturnValue('mocked-secret'), }, @@ -56,11 +56,11 @@ describe('TransientTokenService', () => { }); jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); - const result = await service.generateTransientToken( + const result = await service.generateTransientToken({ workspaceMemberId, userId, workspaceId, - ); + }); expect(result).toEqual({ token: mockToken, @@ -72,8 +72,10 @@ describe('TransientTokenService', () => { expect(jwtWrapperService.sign).toHaveBeenCalledWith( { sub: workspaceMemberId, + type: 'LOGIN', userId, workspaceId, + workspaceMemberId, }, expect.objectContaining({ secret: 'mocked-secret', @@ -90,21 +92,23 @@ describe('TransientTokenService', () => { sub: 'workspace-member-id', userId: 'user-id', workspaceId: 'workspace-id', + workspaceMemberId: 'workspace-member-id', }; jest - .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .spyOn(jwtWrapperService, 'verifyJwtToken') .mockResolvedValue(undefined); jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload); const result = await service.verifyTransientToken(mockToken); expect(result).toEqual({ - workspaceMemberId: mockPayload.sub, + workspaceMemberId: mockPayload.workspaceMemberId, + sub: mockPayload.sub, userId: mockPayload.userId, workspaceId: mockPayload.workspaceId, }); - expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( + expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith( mockToken, 'LOGIN', ); @@ -115,7 +119,7 @@ describe('TransientTokenService', () => { const mockToken = 'invalid-token'; jest - .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .spyOn(jwtWrapperService, 'verifyJwtToken') .mockRejectedValue(new Error('Invalid token')); await expect(service.verifyTransientToken(mockToken)).rejects.toThrow(); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts index 075ab8416..1b0ef3dae 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts @@ -6,6 +6,10 @@ import ms from 'ms'; import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { + TransientTokenJwtPayload, + JwtTokenTypeEnum, +} from 'src/engine/core-modules/auth/types/auth-context.type'; @Injectable() export class TransientTokenService { @@ -14,13 +18,21 @@ export class TransientTokenService { private readonly twentyConfigService: TwentyConfigService, ) {} - async generateTransientToken( - workspaceMemberId: string, - userId: string, - workspaceId: string, - ): Promise { + async generateTransientToken({ + workspaceMemberId, + workspaceId, + userId, + }: Omit): Promise { + const jwtPayload: TransientTokenJwtPayload = { + sub: workspaceMemberId, + userId: userId, + workspaceId: workspaceId, + workspaceMemberId: workspaceMemberId, + type: JwtTokenTypeEnum.LOGIN, + }; + const secret = this.jwtWrapperService.generateAppSecret( - 'LOGIN', + jwtPayload.type, workspaceId, ); const expiresIn = this.twentyConfigService.get( @@ -28,11 +40,6 @@ export class TransientTokenService { ); const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - const jwtPayload = { - sub: workspaceMemberId, - userId, - workspaceId, - }; return { token: this.jwtWrapperService.sign(jwtPayload, { @@ -43,19 +50,17 @@ export class TransientTokenService { }; } - async verifyTransientToken(transientToken: string): Promise<{ - workspaceMemberId: string; - userId: string; - workspaceId: string; - }> { - await this.jwtWrapperService.verifyWorkspaceToken(transientToken, 'LOGIN'); + async verifyTransientToken( + transientToken: string, + ): Promise> { + await this.jwtWrapperService.verifyJwtToken( + transientToken, + JwtTokenTypeEnum.LOGIN, + ); - const payload = await this.jwtWrapperService.decode(transientToken); + const { type: _type, ...payload } = + this.jwtWrapperService.decode(transientToken); - return { - workspaceMemberId: payload.sub, - userId: payload.userId, - workspaceId: payload.workspaceId, - }; + return payload; } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts new file mode 100644 index 000000000..e0f8f4f11 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts @@ -0,0 +1,188 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; + +describe('WorkspaceAgnosticToken', () => { + let service: WorkspaceAgnosticTokenService; + let jwtWrapperService: JwtWrapperService; + let twentyConfigService: TwentyConfigService; + let userRepository: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceAgnosticTokenService, + { + provide: JwtWrapperService, + useValue: { + sign: jest.fn(), + verify: jest.fn(), + decode: jest.fn(), + generateAppSecret: jest.fn().mockReturnValue('mocked-secret'), + }, + }, + { + provide: TwentyConfigService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: getRepositoryToken(User, 'core'), + useValue: { + findOne: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get( + WorkspaceAgnosticTokenService, + ); + jwtWrapperService = module.get(JwtWrapperService); + twentyConfigService = module.get(TwentyConfigService); + userRepository = module.get>( + getRepositoryToken(User, 'core'), + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateWorkspaceAgnosticToken', () => { + it('should generate a workspace agnostic token successfully', async () => { + const userId = 'user-id'; + const mockExpiresIn = '15m'; + const mockToken = 'mock-token'; + const mockUser = { id: userId }; + + jest.spyOn(twentyConfigService, 'get').mockImplementation((key) => { + if (key === 'WORKSPACE_AGNOSTIC_TOKEN_EXPIRES_IN') return mockExpiresIn; + + return undefined; + }); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User); + + const result = await service.generateWorkspaceAgnosticToken({ + userId, + authProvider: AuthProviderEnum.Password, + }); + + expect(result).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(twentyConfigService.get).toHaveBeenCalledWith( + 'WORKSPACE_AGNOSTIC_TOKEN_EXPIRES_IN', + ); + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: userId }, + }); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + { + authProvider: AuthProviderEnum.Password, + sub: userId, + userId: userId, + type: 'WORKSPACE_AGNOSTIC', + }, + expect.objectContaining({ + secret: 'mocked-secret', + expiresIn: mockExpiresIn, + }), + ); + }); + + it('should throw an error if user is not found', async () => { + const userId = 'non-existent-user-id'; + const mockExpiresIn = '15m'; + + jest.spyOn(twentyConfigService, 'get').mockImplementation((key) => { + if (key === 'WORKSPACE_AGNOSTIC_TOKEN_EXPIRES_IN') return mockExpiresIn; + + return undefined; + }); + + jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.generateWorkspaceAgnosticToken({ + userId, + authProvider: AuthProviderEnum.Password, + }), + ).rejects.toThrow(AuthException); + }); + }); + + describe('validateToken', () => { + it('should validate a token successfully', async () => { + const mockToken = 'valid-token'; + const userId = 'user-id'; + const mockPayload = { + sub: userId, + userId: userId, + type: 'WORKSPACE_AGNOSTIC', + }; + const mockUser = { id: userId }; + + jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload); + jest.spyOn(jwtWrapperService, 'verify').mockReturnValue({}); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User); + + const result = await service.validateToken(mockToken); + + expect(result).toEqual({ + user: mockUser, + }); + expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken); + expect(jwtWrapperService.verify).toHaveBeenCalledWith( + mockToken, + expect.objectContaining({ + secret: 'mocked-secret', + }), + ); + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: userId }, + }); + }); + + it('should throw an error if token verification fails', async () => { + const mockToken = 'invalid-token'; + + jest.spyOn(jwtWrapperService, 'verify').mockImplementation(() => { + throw new Error('Invalid token'); + }); + + await expect(service.validateToken(mockToken)).rejects.toThrow( + AuthException, + ); + }); + + it('should throw an error if user is not found', async () => { + const mockToken = 'valid-token'; + const userId = 'user-id'; + const mockPayload = { + sub: userId, + userId: userId, + type: 'WORKSPACE_AGNOSTIC', + }; + + jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload); + jest.spyOn(jwtWrapperService, 'verify').mockReturnValue({}); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); + + await expect(service.validateToken(mockToken)).rejects.toThrow( + AuthException, + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts new file mode 100644 index 000000000..bf38e9fd5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { addMilliseconds } from 'date-fns'; +import ms from 'ms'; +import { Repository } from 'typeorm'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { + AuthContext, + JwtTokenTypeEnum, + WorkspaceAgnosticTokenJwtPayload, +} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { userValidator } from 'src/engine/core-modules/user/user.validate'; + +@Injectable() +export class WorkspaceAgnosticTokenService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly twentyConfigService: TwentyConfigService, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + ) {} + + async generateWorkspaceAgnosticToken({ + userId, + authProvider, + }: { + userId: string; + authProvider: WorkspaceAgnosticTokenJwtPayload['authProvider']; + }): Promise { + const expiresIn = this.twentyConfigService.get( + 'WORKSPACE_AGNOSTIC_TOKEN_EXPIRES_IN', + ); + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + userValidator.assertIsDefinedOrThrow( + user, + new AuthException('User is not found', AuthExceptionCode.INVALID_INPUT), + ); + + const jwtPayload: WorkspaceAgnosticTokenJwtPayload = { + sub: user.id, + userId: user.id, + authProvider, + type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, + }; + + return { + token: this.jwtWrapperService.sign(jwtPayload, { + secret: this.jwtWrapperService.generateAppSecret( + JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, + user.id, + ), + expiresIn, + }), + expiresAt, + }; + } + + async validateToken(token: string): Promise { + try { + const decoded = + this.jwtWrapperService.decode(token); + + this.jwtWrapperService.verify(token, { + secret: this.jwtWrapperService.generateAppSecret( + JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, + decoded.userId, + ), + }); + + const user = await this.userRepository.findOne({ + where: { id: decoded.sub }, + }); + + userValidator.assertIsDefinedOrThrow(user); + + return { user }; + } catch (error) { + if (error instanceof AuthException) { + throw error; + } + + throw new AuthException( + 'Invalid token', + AuthExceptionCode.UNAUTHENTICATED, + ); + } + } +} 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 index 79a9ccce5..0ae60002f 100644 --- 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 @@ -9,11 +9,10 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service'; -import { EmailModule } from 'src/engine/core-modules/email/email.module'; +import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.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'; @@ -27,9 +26,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s ), TypeORMModule, DataSourceModule, - EmailModule, WorkspaceSSOModule, - UserWorkspaceModule, ], providers: [ RenewTokenService, @@ -37,12 +34,14 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s AccessTokenService, LoginTokenService, RefreshTokenService, + WorkspaceAgnosticTokenService, ], exports: [ RenewTokenService, AccessTokenService, LoginTokenService, RefreshTokenService, + WorkspaceAgnosticTokenService, ], }) export class TokenModule {} diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts index 9174f7cf0..d16b5c096 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts @@ -1,21 +1,101 @@ -import { WorkspaceTokenType } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; export type AuthContext = { user?: User | null | undefined; apiKey?: ApiKeyWorkspaceEntity | null | undefined; workspaceMemberId?: string; - workspace: Workspace; + workspace?: Workspace; userWorkspaceId?: string; + authProvider?: AuthProviderEnum; }; -export type JwtPayload = { +export enum JwtTokenTypeEnum { + ACCESS = 'ACCESS', + REFRESH = 'REFRESH', + WORKSPACE_AGNOSTIC = 'WORKSPACE_AGNOSTIC', + LOGIN = 'LOGIN', + FILE = 'FILE', + API_KEY = 'API_KEY', + POSTGRES_PROXY = 'POSTGRES_PROXY', + REMOTE_SERVER = 'REMOTE_SERVER', +} + +type CommonPropertiesJwtPayload = { sub: string; +}; + +export type FileTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.FILE; + workspaceId: string; + filename: string; + workspaceMemberId?: string; + noteBlockId?: string; + attachmentId?: string; + personId?: string; +}; + +export type LoginTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.LOGIN; + workspaceId: string; + authProvider?: AuthProviderEnum; +}; + +export type TransientTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.LOGIN; + workspaceId: string; + userId: string; + workspaceMemberId: string; +}; + +export type RefreshTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.REFRESH; + workspaceId?: string; + userId: string; + jti?: string; + authProvider?: AuthProviderEnum; + targetedTokenType: JwtTokenTypeEnum; +}; + +export type WorkspaceAgnosticTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC; + userId: string; + authProvider: AuthProviderEnum; +}; + +export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.API_KEY; workspaceId: string; workspaceMemberId?: string; jti?: string; - type?: WorkspaceTokenType; - userWorkspaceId?: string; }; + +export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.ACCESS; + workspaceId: string; + userId: string; + workspaceMemberId?: string; + userWorkspaceId: string; + authProvider?: AuthProviderEnum; +}; + +export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.POSTGRES_PROXY; +}; + +export type RemoteServerTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.REMOTE_SERVER; +}; + +export type JwtPayload = + | AccessTokenJwtPayload + | ApiKeyTokenJwtPayload + | WorkspaceAgnosticTokenJwtPayload + | LoginTokenJwtPayload + | TransientTokenJwtPayload + | RefreshTokenJwtPayload + | FileTokenJwtPayload + | PostgresProxyTokenJwtPayload + | RemoteServerTokenJwtPayload; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/signInUp.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/signInUp.type.ts index b43f092af..96950bf70 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/signInUp.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/signInUp.type.ts @@ -2,9 +2,14 @@ import { APP_LOCALES } from 'twenty-shared/translations'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +export type SocialSSOSignInUpActionType = + | 'create-new-workspace' + | 'list-available-workspaces' + | 'join-workspace'; + export type SignInUpBaseParams = { invitation?: AppToken; workspace?: Workspace | null; @@ -45,10 +50,10 @@ export type ExistingUserOrPartialUserWithPicture = { export type AuthProviderWithPasswordType = { authParams: | { - provider: Extract; + provider: Extract; password: string; } | { - provider: Exclude; + provider: Exclude; }; }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts index f04824277..ace8f74ec 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts @@ -19,6 +19,7 @@ export const getAuthExceptionRestStatus = (exception: AuthException) => { case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED: case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE: case AuthExceptionCode.EMAIL_NOT_VERIFIED: + case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE: return 403; case AuthExceptionCode.INVALID_DATA: case AuthExceptionCode.UNAUTHENTICATED: diff --git a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts index a22a9e873..471c55a2b 100644 --- a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts +++ b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts @@ -1,8 +1,11 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { FilePayloadToEncode } from 'src/engine/core-modules/file/services/file.service'; import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils'; +import { + FileTokenJwtPayload, + JwtTokenTypeEnum, +} from 'src/engine/core-modules/auth/types/auth-context.type'; @Injectable() export class FilePathGuard implements CanActivate { @@ -19,11 +22,11 @@ export class FilePathGuard implements CanActivate { } try { - const payload = (await this.jwtWrapperService.verifyWorkspaceToken( + const payload = await this.jwtWrapperService.verifyJwtToken( fileSignature, - 'FILE', + JwtTokenTypeEnum.FILE, ignoreExpirationToken ? { ignoreExpiration: true } : {}, - )) as FilePayloadToEncode; + ); if ( !payload.workspaceId || @@ -36,9 +39,12 @@ export class FilePathGuard implements CanActivate { return false; } - const decodedPayload = (await this.jwtWrapperService.decode(fileSignature, { - json: true, - })) as FilePayloadToEncode; + const decodedPayload = this.jwtWrapperService.decode( + fileSignature, + { + json: true, + }, + ); request.workspaceId = decodedPayload.workspaceId; diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index 6597048dd..ed2338580 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -11,11 +11,10 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { extractFilenameFromPath } from 'src/engine/core-modules/file/utils/extract-file-id-from-path.utils'; - -export type FilePayloadToEncode = { - workspaceId: string; - filename: string; -}; +import { + FileTokenJwtPayload, + JwtTokenTypeEnum, +} from 'src/engine/core-modules/auth/types/auth-context.type'; @Injectable() export class FileService { @@ -52,26 +51,26 @@ export class FileService { }); } - encodeFileToken(payloadToEncode: FilePayloadToEncode) { + encodeFileToken(payloadToEncode: Omit) { const fileTokenExpiresIn = this.twentyConfigService.get( 'FILE_TOKEN_EXPIRES_IN', ); + + const payload: FileTokenJwtPayload = { + ...payloadToEncode, + sub: payloadToEncode.workspaceId, + type: JwtTokenTypeEnum.FILE, + }; + const secret = this.jwtWrapperService.generateAppSecret( - 'FILE', + payload.type, payloadToEncode.workspaceId, ); - const signedPayload = this.jwtWrapperService.sign( - { - ...payloadToEncode, - }, - { - secret, - expiresIn: fileTokenExpiresIn, - }, - ); - - return signedPayload; + return this.jwtWrapperService.sign(payload, { + secret, + expiresIn: fileTokenExpiresIn, + }); } async deleteFile({ diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts index 044f23663..f36f7e81c 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts @@ -13,15 +13,15 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; - -export type WorkspaceTokenType = - | 'ACCESS' - | 'LOGIN' - | 'REFRESH' - | 'FILE' - | 'POSTGRES_PROXY' - | 'REMOTE_SERVER' - | 'API_KEY'; +import { + JwtPayload, + JwtTokenTypeEnum, + TransientTokenJwtPayload, + RefreshTokenJwtPayload, + WorkspaceAgnosticTokenJwtPayload, + AccessTokenJwtPayload, + FileTokenJwtPayload, +} from 'src/engine/core-modules/auth/types/auth-context.type'; @Injectable() export class JwtWrapperService { @@ -30,17 +30,16 @@ export class JwtWrapperService { private readonly twentyConfigService: TwentyConfigService, ) {} - sign(payload: string | object, options?: JwtSignOptions): string { + sign(payload: JwtPayload, options?: JwtSignOptions): string { // Typescript does not handle well the overloads of the sign method, helping it a little bit - if (typeof payload === 'object') { - return this.jwtService.sign(payload, options); - } - return this.jwtService.sign(payload, options); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - verify(token: string, options?: JwtVerifyOptions): T { + verify( + token: string, + options?: { secret: string }, + ): T { return this.jwtService.verify(token, options); } @@ -49,12 +48,18 @@ export class JwtWrapperService { return this.jwtService.decode(payload, options); } - verifyWorkspaceToken( + verifyJwtToken( token: string, - type: WorkspaceTokenType, + type: JwtTokenTypeEnum, options?: JwtVerifyOptions, ) { - const payload = this.decode(token, { + const payload = this.decode< + | TransientTokenJwtPayload + | RefreshTokenJwtPayload + | WorkspaceAgnosticTokenJwtPayload + | AccessTokenJwtPayload + | FileTokenJwtPayload + >(token, { json: true, }); @@ -62,6 +67,12 @@ export class JwtWrapperService { throw new AuthException('No payload', AuthExceptionCode.UNAUTHENTICATED); } + // @TODO: Migrate to use type from payload instead of parameter + type = + payload.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC + ? JwtTokenTypeEnum.WORKSPACE_AGNOSTIC + : type; + // TODO: check if this is really needed if (type !== 'FILE' && !payload.sub) { throw new AuthException( @@ -72,16 +83,26 @@ export class JwtWrapperService { try { // TODO: Deprecate this once old API KEY tokens are no longer in use - if (!payload.type && !payload.workspaceId && type === 'ACCESS') { + if (!payload.type && !('workspaceId' in payload) && type === 'ACCESS') { return this.jwtService.verify(token, { ...options, secret: this.generateAppSecretLegacy(), }); } + const appSecretBody = + 'workspaceId' in payload ? payload.workspaceId : payload.userId; + + if (!isDefined(appSecretBody)) { + throw new AuthException( + 'Invalid token type', + AuthExceptionCode.INVALID_JWT_TOKEN_TYPE, + ); + } + return this.jwtService.verify(token, { ...options, - secret: this.generateAppSecret(type, payload.workspaceId), + secret: this.generateAppSecret(type, appSecretBody), }); } catch (error) { if (error instanceof jwt.TokenExpiredError) { @@ -103,7 +124,7 @@ export class JwtWrapperService { } } - generateAppSecret(type: WorkspaceTokenType, workspaceId?: string): string { + generateAppSecret(type: JwtTokenTypeEnum, appSecretBody: string): string { const appSecret = this.twentyConfigService.get('APP_SECRET'); if (!appSecret) { @@ -111,7 +132,7 @@ export class JwtWrapperService { } return createHash('sha256') - .update(`${appSecret}${workspaceId}${type}`) + .update(`${appSecret}${appSecretBody}${type}`) .digest('hex'); } 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 09ae17446..fcd8153a4 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 @@ -38,6 +38,7 @@ import { import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { getServerUrl } from 'src/utils/get-server-url'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @Injectable() export class OpenApiService { @@ -61,6 +62,8 @@ export class OpenApiService { const { workspace } = await this.accessTokenService.validateTokenByRequest(request); + workspaceValidator.assertIsDefinedOrThrow(workspace); + objectMetadataItems = await this.objectMetadataService.findManyWithinWorkspace(workspace.id, { order: { diff --git a/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts b/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts index 6ae8d5a3d..8e702a7ba 100644 --- a/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts +++ b/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts @@ -13,6 +13,7 @@ import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-err import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { PostgresCredentialsDTO } from 'src/engine/core-modules/postgres-credentials/dtos/postgres-credentials.dto'; import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; export class PostgresCredentialsService { constructor( @@ -28,7 +29,7 @@ export class PostgresCredentialsService { const password = randomBytes(16).toString('hex'); const key = this.jwtWrapperService.generateAppSecret( - 'POSTGRES_PROXY', + JwtTokenTypeEnum.POSTGRES_PROXY, workspaceId, ); const passwordHash = encryptText(password, key); @@ -85,7 +86,7 @@ export class PostgresCredentialsService { }); const key = this.jwtWrapperService.generateAppSecret( - 'POSTGRES_PROXY', + JwtTokenTypeEnum.POSTGRES_PROXY, workspaceId, ); @@ -112,7 +113,7 @@ export class PostgresCredentialsService { } const key = this.jwtWrapperService.generateAppSecret( - 'POSTGRES_PROXY', + JwtTokenTypeEnum.POSTGRES_PROXY, workspaceId, ); diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 5f9c84b53..7e46956d8 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -223,6 +223,15 @@ export class ConfigVariables { @IsOptional() ACCESS_TOKEN_EXPIRES_IN = '30m'; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.TokensDuration, + description: 'Duration for which the workspace agnostic token is valid', + type: ConfigVariableType.STRING, + }) + @IsDuration() + @IsOptional() + WORKSPACE_AGNOSTIC_TOKEN_EXPIRES_IN = '30m'; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.TokensDuration, description: 'Duration for which the refresh token is valid', diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.exception.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.exception.ts new file mode 100644 index 000000000..116bb089f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.exception.ts @@ -0,0 +1,12 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class UserWorkspaceException extends CustomException { + declare code: UserWorkspaceExceptionCode; + constructor(message: string, code: UserWorkspaceExceptionCode) { + super(message, code); + } +} + +export enum UserWorkspaceExceptionCode { + USER_WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', +} 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 26c84a7dc..701af5485 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 @@ -19,6 +19,8 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; +import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module'; +import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; @Module({ imports: [ @@ -32,12 +34,14 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works TypeORMModule, DataSourceModule, WorkspaceDataSourceModule, + ApprovedAccessDomainModule, WorkspaceInvitationModule, DomainManagerModule, TwentyORMModule, UserRoleModule, FileUploadModule, FileModule, + TokenModule, ], services: [UserWorkspaceService], }), diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts index 921cff25c..4daba123f 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts @@ -28,6 +28,9 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; 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 { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; describe('UserWorkspaceService', () => { let service: UserWorkspaceService; @@ -37,7 +40,7 @@ describe('UserWorkspaceService', () => { let typeORMService: TypeORMService; let workspaceInvitationService: WorkspaceInvitationService; let workspaceEventEmitter: WorkspaceEventEmitter; - let domainManagerService: DomainManagerService; + let approvedAccessDomainService: ApprovedAccessDomainService; let twentyORMGlobalManager: TwentyORMGlobalManager; let userRoleService: UserRoleService; let fileService: FileService; @@ -87,6 +90,7 @@ describe('UserWorkspaceService', () => { provide: WorkspaceInvitationService, useValue: { invalidateWorkspaceInvitation: jest.fn(), + findInvitationsByEmail: jest.fn(), }, }, { @@ -102,6 +106,13 @@ describe('UserWorkspaceService', () => { getWorkspaceUrls: jest.fn(), }, }, + { + provide: ApprovedAccessDomainService, + useValue: { + findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain: + jest.fn().mockResolvedValue([]), + }, + }, { provide: TwentyORMGlobalManager, useValue: { @@ -120,6 +131,10 @@ describe('UserWorkspaceService', () => { copy: jest.fn(), }, }, + { + provide: LoginTokenService, + useValue: {}, + }, { provide: FileUploadService, useValue: { @@ -151,8 +166,9 @@ describe('UserWorkspaceService', () => { workspaceEventEmitter = module.get( WorkspaceEventEmitter, ); - domainManagerService = - module.get(DomainManagerService); + approvedAccessDomainService = module.get( + ApprovedAccessDomainService, + ); twentyORMGlobalManager = module.get( TwentyORMGlobalManager, ); @@ -657,15 +673,15 @@ describe('UserWorkspaceService', () => { jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); jest - .spyOn(domainManagerService, 'getWorkspaceUrls') - .mockReturnValueOnce({ - customUrl: 'https://crm.custom1.com', - subdomainUrl: 'https://workspace1.twenty.com', - }) - .mockReturnValueOnce({ - customUrl: 'https://crm.custom2.com', - subdomainUrl: 'https://workspace2.twenty.com', - }); + .spyOn( + approvedAccessDomainService, + 'findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain', + ) + .mockResolvedValue([]); + + jest + .spyOn(workspaceInvitationService, 'findInvitationsByEmail') + .mockResolvedValue([]); const result = await service.findAvailableWorkspacesByEmail(email); @@ -677,18 +693,26 @@ describe('UserWorkspaceService', () => { 'workspaces', 'workspaces.workspace', 'workspaces.workspace.workspaceSSOIdentityProviders', + 'workspaces.workspace.approvedAccessDomains', ], }); - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ - id: workspace1.id, - displayName: workspace1.displayName, - workspaceUrls: { - customUrl: 'https://crm.custom1.com', - subdomainUrl: 'https://workspace1.twenty.com', - }, - logo: workspace1.logo, - sso: [ + + expect(result).toEqual({ + availableWorkspacesForSignIn: [ + { workspace: workspace1 }, + { workspace: workspace2 }, + ], + availableWorkspacesForSignUp: [], + }); + }); + + it('should find available workspaces including approved domain workspace for an email', async () => { + const email = 'test@example.com'; + const workspace1 = { + id: 'workspace-id-1', + displayName: 'Workspace 1', + logo: 'logo1.png', + workspaceSSOIdentityProviders: [ { id: 'sso-id-1', name: 'SSO Provider 1', @@ -696,28 +720,106 @@ describe('UserWorkspaceService', () => { type: 'type1', status: 'Active', }, + { + id: 'sso-id-2', + name: 'SSO Provider 2', + issuer: 'issuer2', + type: 'type2', + status: 'Inactive', + }, + ], + } as unknown as Workspace; + const workspace2 = { + id: 'workspace-id-2', + displayName: 'Workspace 2', + logo: 'logo2.png', + workspaceSSOIdentityProviders: [], + } as unknown as Workspace; + + const user = { + email, + workspaces: [ + { + workspaceId: workspace1.id, + workspace: workspace1, + }, + ], + } as User; + + jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); + jest + .spyOn( + approvedAccessDomainService, + 'findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain', + ) + .mockResolvedValueOnce([ + { + id: 'domain-id-2', + workspaceId: workspace2.id, + workspace: workspace2, + isValidated: true, + } as unknown as ApprovedAccessDomain, + ]); + + jest + .spyOn(workspaceInvitationService, 'findInvitationsByEmail') + .mockResolvedValue([]); + + const result = await service.findAvailableWorkspacesByEmail(email); + + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { + email, + }, + relations: [ + 'workspaces', + 'workspaces.workspace', + 'workspaces.workspace.workspaceSSOIdentityProviders', + 'workspaces.workspace.approvedAccessDomains', ], }); - expect(result[1]).toEqual({ - id: workspace2.id, - displayName: workspace2.displayName, - workspaceUrls: { - customUrl: 'https://crm.custom2.com', - subdomainUrl: 'https://workspace2.twenty.com', - }, - logo: workspace2.logo, - sso: [], + + expect(result).toEqual({ + availableWorkspacesForSignIn: [{ workspace: workspace1 }], + availableWorkspacesForSignUp: [{ workspace: workspace2 }], }); }); - it('should throw an exception if user is not found', async () => { + it('should return workspace with approved access domain if user is not found', async () => { const email = 'nonexistent@example.com'; + const workspace1 = { + id: 'workspace-id-1', + displayName: 'Workspace 1', + logo: 'logo1.png', + workspaceSSOIdentityProviders: [], + } as unknown as Workspace; jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); - await expect( - service.findAvailableWorkspacesByEmail(email), - ).rejects.toThrow(AuthException); + jest + .spyOn( + approvedAccessDomainService, + 'findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain', + ) + .mockResolvedValueOnce([ + { + id: 'domain-id-1', + workspaceId: workspace1.id, + workspace: workspace1, + isValidated: true, + } as unknown as ApprovedAccessDomain, + ]); + + jest + .spyOn(workspaceInvitationService, 'findInvitationsByEmail') + .mockResolvedValue([]); + + const result = await service.findAvailableWorkspacesByEmail(email); + + expect(result).toEqual({ + availableWorkspacesForSignIn: [], + availableWorkspacesForSignUp: [{ workspace: workspace1 }], + }); }); }); 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 d00220dd8..3733e938a 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 @@ -14,13 +14,11 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @@ -35,6 +33,12 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global. 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 { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service'; +import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; +import { AvailableWorkspace } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; export class UserWorkspaceService extends TypeOrmQueryService { constructor( @@ -44,10 +48,11 @@ export class UserWorkspaceService extends TypeOrmQueryService { private readonly userRepository: Repository, @InjectRepository(ObjectMetadataEntity, 'core') private readonly objectMetadataRepository: Repository, - private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly workspaceEventEmitter: WorkspaceEventEmitter, private readonly domainManagerService: DomainManagerService, + private readonly loginTokenService: LoginTokenService, + private readonly approvedAccessDomainService: ApprovedAccessDomainService, private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly userRoleService: UserRoleService, private readonly fileUploadService: FileUploadService, @@ -255,39 +260,53 @@ export class UserWorkspaceService extends TypeOrmQueryService { 'workspaces', 'workspaces.workspace', 'workspaces.workspace.workspaceSSOIdentityProviders', + 'workspaces.workspace.approvedAccessDomains', ], }); - userValidator.assertIsDefinedOrThrow( - user, - new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + const alreadyMemberWorkspaces = user + ? user.workspaces.map(({ workspace }) => ({ workspace })) + : []; + + const alreadyMemberWorkspacesIds = alreadyMemberWorkspaces.map( + ({ workspace }) => workspace.id, ); - return user.workspaces.map((userWorkspace) => ({ - id: userWorkspace.workspaceId, - displayName: userWorkspace.workspace.displayName, - workspaceUrls: this.domainManagerService.getWorkspaceUrls( - userWorkspace.workspace, - ), - logo: userWorkspace.workspace.logo, - sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce( - (acc, identityProvider) => - acc.concat( - identityProvider.status === 'Inactive' - ? [] - : [ - { - id: identityProvider.id, - name: identityProvider.name, - issuer: identityProvider.issuer, - type: identityProvider.type, - status: identityProvider.status, - }, - ], - ), - [] as AvailableWorkspaceOutput['sso'], - ), - })); + const workspacesFromApprovedAccessDomain = ( + await this.approvedAccessDomainService.findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain( + getDomainNameByEmail(email), + ) + ) + .filter( + ({ workspace }) => !alreadyMemberWorkspacesIds.includes(workspace.id), + ) + .map(({ workspace }) => ({ workspace })); + + const workspacesFromApprovedAccessDomainIds = + workspacesFromApprovedAccessDomain.map(({ workspace }) => workspace.id); + + const workspacesFromInvitations = ( + await this.workspaceInvitationService.findInvitationsByEmail(email) + ) + .filter( + ({ workspaceId }) => + ![ + ...alreadyMemberWorkspacesIds, + ...workspacesFromApprovedAccessDomainIds, + ].includes(workspaceId), + ) + .map((appToken) => ({ + workspace: appToken.workspace, + appToken, + })); + + return { + availableWorkspacesForSignIn: alreadyMemberWorkspaces, + availableWorkspacesForSignUp: [ + ...workspacesFromApprovedAccessDomain, + ...workspacesFromInvitations, + ], + }; } async getUserWorkspaceForUserOrThrow({ @@ -380,4 +399,78 @@ export class UserWorkspaceService extends TypeOrmQueryService { return files[0].path; } + + castWorkspaceToAvailableWorkspace(workspace: Workspace) { + return { + id: workspace.id, + displayName: workspace.displayName, + workspaceUrls: this.domainManagerService.getWorkspaceUrls(workspace), + logo: workspace.logo, + sso: workspace.workspaceSSOIdentityProviders.reduce( + (acc, identityProvider) => + acc.concat( + identityProvider.status === 'Inactive' + ? [] + : [ + { + id: identityProvider.id, + name: identityProvider.name, + issuer: identityProvider.issuer, + type: identityProvider.type, + status: identityProvider.status, + }, + ], + ), + [] as AvailableWorkspace['sso'], + ), + }; + } + + async setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch( + availableWorkspaces: { + availableWorkspacesForSignUp: Array<{ + workspace: Workspace; + appToken?: AppToken; + }>; + availableWorkspacesForSignIn: Array<{ + workspace: Workspace; + appToken?: AppToken; + }>; + }, + user: User, + authProvider: AuthProviderEnum, + ) { + return { + availableWorkspacesForSignUp: + availableWorkspaces.availableWorkspacesForSignUp.map( + ({ workspace, appToken }) => { + return { + ...this.castWorkspaceToAvailableWorkspace(workspace), + ...(appToken ? { personalInviteToken: appToken.value } : {}), + }; + }, + ), + availableWorkspacesForSignIn: await Promise.all( + availableWorkspaces.availableWorkspacesForSignIn.map( + async ({ workspace }) => { + return { + ...this.castWorkspaceToAvailableWorkspace(workspace), + loginToken: workspaceValidator.isAuthEnabled( + authProvider, + workspace, + ) + ? ( + await this.loginTokenService.generateLoginToken( + user.email, + workspace.id, + AuthProviderEnum.Password, + ) + ).token + : undefined, + }; + }, + ), + ), + }; + } } diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.validate.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.validate.ts new file mode 100644 index 000000000..c989ce736 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.validate.ts @@ -0,0 +1,24 @@ +import { CustomException } from 'src/utils/custom-exception'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { + UserWorkspaceException, + UserWorkspaceExceptionCode, +} from 'src/engine/core-modules/user-workspace/user-workspace.exception'; + +const assertIsDefinedOrThrow = ( + userWorkspace: UserWorkspace | undefined | null, + exceptionToThrow: CustomException = new UserWorkspaceException( + 'User Workspace not found', + UserWorkspaceExceptionCode.USER_WORKSPACE_NOT_FOUND, + ), +): asserts userWorkspace is UserWorkspace => { + if (!userWorkspace) { + throw exceptionToThrow; + } +}; + +export const userWorkspaceValidator: { + assertIsDefinedOrThrow: typeof assertIsDefinedOrThrow; +} = { + assertIsDefinedOrThrow: assertIsDefinedOrThrow, +}; diff --git a/packages/twenty-server/src/engine/core-modules/user/user.entity.ts b/packages/twenty-server/src/engine/core-modules/user/user.entity.ts index ea513286f..1a0f57d9e 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.entity.ts @@ -118,7 +118,7 @@ export class User { onboardingStatus: OnboardingStatus; @Field(() => Workspace, { nullable: true }) - currentWorkspace: Relation; + currentWorkspace?: Relation; @Field(() => UserWorkspace, { nullable: true }) currentUserWorkspace?: Relation; diff --git a/packages/twenty-server/src/engine/core-modules/user/user.module.ts b/packages/twenty-server/src/engine/core-modules/user/user.module.ts index d2f475e44..741750fe6 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.module.ts @@ -8,7 +8,6 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; -import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; @@ -47,8 +46,8 @@ import { UserService } from './services/user.service'; OnboardingModule, TypeOrmModule.forFeature([KeyValuePair, UserWorkspace], 'core'), UserVarsModule, + UserWorkspaceModule, AuditModule, - DomainManagerModule, UserRoleModule, FeatureFlagModule, PermissionsModule, diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts index 3ce9df828..75b507b5b 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts @@ -57,6 +57,10 @@ import { fromUserWorkspacePermissionsToUserWorkspacePermissionsDto } from 'src/e import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { AccountsToReconnectKeys } from 'src/modules/connected-account/types/accounts-to-reconnect-key-value.type'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; +import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; +import { AuthProvider } from 'src/engine/decorators/auth/auth-provider.decorator'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; const getHMACKey = (email?: string, key?: string | null) => { if (!email || !key) return null; @@ -66,7 +70,6 @@ const getHMACKey = (email?: string, key?: string | null) => { return hmac.update(email).digest('hex'); }; -@UseGuards(WorkspaceAuthGuard) @Resolver(() => User) @UseFilters(PermissionsGraphqlApiExceptionFilter) export class UserResolver { @@ -123,15 +126,16 @@ export class UserResolver { } @Query(() => User) + @UseGuards(UserAuthGuard) async currentUser( @AuthUser() { id: userId }: User, - @AuthWorkspace() workspace: Workspace, + @AuthWorkspace({ allowUndefined: true }) workspace: Workspace, ): Promise { const user = await this.userRepository.findOne({ where: { id: userId, }, - relations: ['workspaces', 'workspaces.workspace'], + relations: ['workspaces'], }); userValidator.assertIsDefinedOrThrow( @@ -139,8 +143,12 @@ export class UserResolver { new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), ); + if (!workspace) { + return user; + } + const currentUserWorkspace = user.workspaces.find( - (userWorkspace) => userWorkspace.workspace.id === workspace.id, + (userWorkspace) => userWorkspace.workspaceId === workspace.id, ); if (!isDefined(currentUserWorkspace)) { @@ -165,11 +173,14 @@ export class UserResolver { }; } - @ResolveField(() => GraphQLJSONObject) + @ResolveField(() => GraphQLJSONObject, { + nullable: true, + }) async userVars( @Parent() user: User, - @AuthWorkspace() workspace: Workspace, + @AuthWorkspace({ allowUndefined: true }) workspace: Workspace | undefined, ): Promise> { + if (!workspace) return {}; const userVars = await this.userVarService.getAll({ userId: user.id, workspaceId: workspace.id, @@ -193,8 +204,10 @@ export class UserResolver { }) async workspaceMember( @Parent() user: User, - @AuthWorkspace() workspace: Workspace, + @AuthWorkspace({ allowUndefined: true }) workspace: Workspace | undefined, ): Promise { + if (!workspace) return null; + const workspaceMemberEntity = await this.userService.loadWorkspaceMember( user, workspace, @@ -235,8 +248,10 @@ export class UserResolver { }) async workspaceMembers( @Parent() _user: User, - @AuthWorkspace() workspace: Workspace, + @AuthWorkspace({ allowUndefined: true }) workspace: Workspace | undefined, ): Promise { + if (!workspace) return []; + const workspaceMemberEntities = await this.userService.loadWorkspaceMembers( workspace, false, @@ -301,8 +316,10 @@ export class UserResolver { }) async deletedWorkspaceMembers( @Parent() _user: User, - @AuthWorkspace() workspace: Workspace, + @AuthWorkspace({ allowUndefined: true }) workspace: Workspace | undefined, ): Promise { + if (!workspace) return []; + const workspaceMemberEntities = await this.userService.loadDeletedWorkspaceMembersOnly(workspace); @@ -327,9 +344,10 @@ export class UserResolver { } @Mutation(() => SignedFileDTO) + @UseGuards(WorkspaceAuthGuard) async uploadProfilePicture( @AuthUser() { id }: User, - @AuthWorkspace() { id: workspaceId }: Workspace, + @AuthWorkspace({ allowUndefined: true }) { id: workspaceId }: Workspace, @Args({ name: 'file', type: () => GraphQLUpload }) { createReadStream, filename, mimetype }: FileUpload, ): Promise { @@ -357,20 +375,43 @@ export class UserResolver { } @Mutation(() => User) + @UseGuards(UserAuthGuard) async deleteUser(@AuthUser() { id: userId }: User) { return this.userService.deleteUser(userId); } - @ResolveField(() => OnboardingStatus) + @ResolveField(() => OnboardingStatus, { + nullable: true, + }) async onboardingStatus( @Parent() user: User, - @AuthWorkspace() workspace: Workspace, - ): Promise { + @AuthWorkspace({ allowUndefined: true }) workspace: Workspace | undefined, + ): Promise { + if (!workspace) return null; + return this.onboardingService.getOnboardingStatus(user, workspace); } - @ResolveField(() => Workspace) - async currentWorkspace(@AuthWorkspace() workspace: Workspace) { + @ResolveField(() => Workspace, { + nullable: true, + }) + async currentWorkspace( + @AuthWorkspace({ allowUndefined: true }) workspace: Workspace | undefined, + ) { return workspace; } + + @ResolveField(() => AvailableWorkspaces) + async availableWorkspaces( + @AuthUser() user: User, + @AuthProvider() authProvider: AuthProviderEnum, + ): Promise { + return this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch( + await this.userWorkspaceService.findAvailableWorkspacesByEmail( + user.email, + ), + user, + authProvider, + ); + } } 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 c361bd473..2215e03da 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 @@ -102,6 +102,21 @@ export class WorkspaceInvitationService { return await this.getOneWorkspaceInvitation(workspace.id, email); } + async findInvitationsByEmail(email: string) { + return await this.appTokenRepository + .createQueryBuilder('appToken') + .where('"appToken".type = :type', { + type: AppTokenType.InvitationToken, + }) + .andWhere('"appToken".context->>\'email\' = :email', { email }) + .andWhere('appToken.deletedAt IS NULL') + .andWhere('appToken.expiresAt > :now', { + now: new Date(), + }) + .leftJoinAndSelect('appToken.workspace', 'workspace') + .getMany(); + } + async getOneWorkspaceInvitation(workspaceId: string, email: string) { return await this.appTokenRepository .createQueryBuilder('appToken') diff --git a/packages/twenty-server/src/engine/core-modules/workspace/types/workspace.type.ts b/packages/twenty-server/src/engine/core-modules/workspace/types/workspace.type.ts index c502be449..6f8da8dde 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/types/workspace.type.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/types/workspace.type.ts @@ -1 +1,6 @@ -export type WorkspaceAuthProvider = 'google' | 'microsoft' | 'password' | 'sso'; +export enum AuthProviderEnum { + Google = 'google', + Microsoft = 'microsoft', + Password = 'password', + SSO = 'sso', +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.validate.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.validate.ts index f943f0654..efd2c47d2 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.validate.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.validate.ts @@ -2,7 +2,7 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceException, @@ -23,25 +23,47 @@ const assertIsDefinedOrThrow = ( }; const isAuthEnabledOrThrow = ( - provider: WorkspaceAuthProvider, + provider: AuthProviderEnum, workspace: Workspace, exceptionToThrowCustom: AuthException = new AuthException( `${provider} auth is not enabled for this workspace`, AuthExceptionCode.OAUTH_ACCESS_DENIED, ), ) => { - if (provider === 'google' && workspace.isGoogleAuthEnabled) return true; - if (provider === 'microsoft' && workspace.isMicrosoftAuthEnabled) return true; - if (provider === 'password' && workspace.isPasswordAuthEnabled) return true; - if (provider === 'sso') return true; + if (provider === AuthProviderEnum.Google && workspace.isGoogleAuthEnabled) + return true; + if ( + provider === AuthProviderEnum.Microsoft && + workspace.isMicrosoftAuthEnabled + ) + return true; + if (provider === AuthProviderEnum.Password && workspace.isPasswordAuthEnabled) + return true; + if (provider === AuthProviderEnum.SSO) return true; throw exceptionToThrowCustom; }; +const isAuthEnabled = (provider: AuthProviderEnum, workspace: Workspace) => { + if (provider === AuthProviderEnum.Google && workspace.isGoogleAuthEnabled) + return true; + if ( + provider === AuthProviderEnum.Microsoft && + workspace.isMicrosoftAuthEnabled + ) + return true; + if (provider === AuthProviderEnum.Password && workspace.isPasswordAuthEnabled) + return true; + + return false; +}; + export const workspaceValidator: { assertIsDefinedOrThrow: typeof assertIsDefinedOrThrow; isAuthEnabledOrThrow: typeof isAuthEnabledOrThrow; + isAuthEnabled: typeof isAuthEnabled; } = { assertIsDefinedOrThrow: assertIsDefinedOrThrow, isAuthEnabledOrThrow: isAuthEnabledOrThrow, + isAuthEnabled: isAuthEnabled, }; diff --git a/packages/twenty-server/src/engine/decorators/auth/auth-provider.decorator.ts b/packages/twenty-server/src/engine/decorators/auth/auth-provider.decorator.ts new file mode 100644 index 000000000..b9278eb6f --- /dev/null +++ b/packages/twenty-server/src/engine/decorators/auth/auth-provider.decorator.ts @@ -0,0 +1,11 @@ +import { ExecutionContext, createParamDecorator } from '@nestjs/common'; + +import { getRequest } from 'src/utils/extract-request'; + +export const AuthProvider = createParamDecorator( + (_: unknown, ctx: ExecutionContext) => { + const request = getRequest(ctx); + + return request.authProvider; + }, +); 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 fb451e3d5..126b6141a 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -16,15 +16,16 @@ export class JwtAuthGuard implements CanActivate { try { const data = await this.accessTokenService.validateTokenByRequest(request); - const metadataVersion = - await this.workspaceStorageCacheService.getMetadataVersion( - data.workspace.id, - ); + const metadataVersion = data.workspace + ? await this.workspaceStorageCacheService.getMetadataVersion( + data.workspace.id, + ) + : undefined; request.user = data.user; request.apiKey = data.apiKey; request.workspace = data.workspace; - request.workspaceId = data.workspace.id; + request.workspaceId = data.workspace?.id; request.workspaceMetadataVersion = metadataVersion; request.workspaceMemberId = data.workspaceMemberId; request.userWorkspaceId = data.userWorkspaceId; diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts index a63b9e143..6874eb135 100644 --- a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts @@ -28,6 +28,7 @@ import { import { validateRemoteServerType } from 'src/engine/metadata-modules/remote-server/utils/validate-remote-server-type.util'; import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; @Injectable() export class RemoteServerService { @@ -257,7 +258,7 @@ export class RemoteServerService { private encryptPassword(password: string, workspaceId: string) { const key = this.jwtWrapperService.generateAppSecret( - 'REMOTE_SERVER', + JwtTokenTypeEnum.REMOTE_SERVER, workspaceId, ); diff --git a/packages/twenty-server/src/engine/middlewares/constants/excluded-middleware-operations.constant.ts b/packages/twenty-server/src/engine/middlewares/constants/excluded-middleware-operations.constant.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/twenty-server/src/engine/middlewares/middleware.service.ts b/packages/twenty-server/src/engine/middlewares/middleware.service.ts index 3ba346356..7949b6283 100644 --- a/packages/twenty-server/src/engine/middlewares/middleware.service.ts +++ b/packages/twenty-server/src/engine/middlewares/middleware.service.ts @@ -99,22 +99,24 @@ export class MiddlewareService { public async hydrateRestRequest(request: Request) { const data = await this.accessTokenService.validateTokenByRequest(request); - const metadataVersion = - await this.workspaceStorageCacheService.getMetadataVersion( - data.workspace.id, - ); + const metadataVersion = data.workspace + ? await this.workspaceStorageCacheService.getMetadataVersion( + data.workspace.id, + ) + : undefined; - if (metadataVersion === undefined) { + if (metadataVersion === undefined && isDefined(data.workspace)) { await this.workspaceMetadataCacheService.recomputeMetadataCache({ workspaceId: data.workspace.id, }); throw new Error('Metadata cache version not found'); } - const dataSourcesMetadata = - await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId( - data.workspace.id, - ); + const dataSourcesMetadata = data.workspace + ? await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId( + data.workspace.id, + ) + : undefined; if (!dataSourcesMetadata || dataSourcesMetadata.length === 0) { throw new Error('No data sources found'); @@ -129,10 +131,11 @@ export class MiddlewareService { } const data = await this.accessTokenService.validateTokenByRequest(request); - const metadataVersion = - await this.workspaceStorageCacheService.getMetadataVersion( - data.workspace.id, - ); + const metadataVersion = data.workspace + ? await this.workspaceStorageCacheService.getMetadataVersion( + data.workspace.id, + ) + : undefined; this.bindDataToRequestObject(data, request, metadataVersion); } @@ -149,10 +152,11 @@ export class MiddlewareService { request.user = data.user; request.apiKey = data.apiKey; request.workspace = data.workspace; - request.workspaceId = data.workspace.id; + request.workspaceId = data.workspace?.id; request.workspaceMetadataVersion = metadataVersion; request.workspaceMemberId = data.workspaceMemberId; request.userWorkspaceId = data.userWorkspaceId; + request.authProvider = data.authProvider; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/twenty-server/src/engine/strategies/aggregate-by-workspace-context-id.strategy.ts b/packages/twenty-server/src/engine/strategies/aggregate-by-workspace-context-id.strategy.ts deleted file mode 100644 index 29087d30a..000000000 --- a/packages/twenty-server/src/engine/strategies/aggregate-by-workspace-context-id.strategy.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - ContextId, - ContextIdFactory, - ContextIdStrategy, - HostComponentInfo, -} from '@nestjs/core'; - -import { Request } from 'express'; -import { jwtDecode } from 'jwt-decode'; -import { isDefined } from 'twenty-shared/utils'; - -import { JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; - -const workspaces = new Map(); - -export class AggregateByWorkspaceContextIdStrategy - implements ContextIdStrategy -{ - attach(contextId: ContextId, request: Request) { - const token = request.header('Authorization')?.replace('Bearer ', ''); - const jwtPayload = token ? jwtDecode(token) : null; - let workspaceSubTreeId: ContextId; - - if (!jwtPayload) { - return () => contextId; - } - - const subTreeId = workspaces.get(jwtPayload.workspaceId); - - if (isDefined(subTreeId)) { - workspaceSubTreeId = subTreeId; - } else { - workspaceSubTreeId = ContextIdFactory.create(); - workspaces.set(jwtPayload.workspaceId, workspaceSubTreeId); - } - - // If tree is not durable, return the original "contextId" object - return (info: HostComponentInfo) => - info.isTreeDurable ? workspaceSubTreeId : contextId; - } -} diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.ts b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.ts index f8d83794c..3dc9ea0da 100644 --- a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.ts +++ b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.ts @@ -30,7 +30,7 @@ export class WorkspaceEventEmitter { objectMetadataNameSingular: string; action: A; events: ActionEventMap[A][]; - workspaceId: string; + workspaceId: string | undefined; }) { if (!events.length) { return; @@ -48,7 +48,7 @@ export class WorkspaceEventEmitter { public emitCustomBatchEvent( eventName: CustomEventName, events: T[], - workspaceId: string, + workspaceId: string | undefined, ) { if (!events.length) { return; diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index ced56dd87..3cb2fa907 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -42,10 +42,6 @@ const bootstrap = async () => { app.use(session(getSessionStorageOptions(twentyConfigService))); - // TODO: Double check this as it's not working for now, it's going to be helpful for durable trees in twenty "orm" - // // Apply context id strategy for durable trees - // ContextIdFactory.apply(new AggregateByWorkspaceContextIdStrategy()); - // Apply class-validator container so that we can use injection in validators useContainer(app.select(AppModule), { fallbackOnErrors: true }); diff --git a/packages/twenty-server/src/modules/blocklist/query-hooks/blocklist-create-many.pre-query.hook.ts b/packages/twenty-server/src/modules/blocklist/query-hooks/blocklist-create-many.pre-query.hook.ts index 1e231a9e3..0b3a4e387 100644 --- a/packages/twenty-server/src/modules/blocklist/query-hooks/blocklist-create-many.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/blocklist/query-hooks/blocklist-create-many.pre-query.hook.ts @@ -9,6 +9,7 @@ import { BlocklistItem, BlocklistValidationService, } from 'src/modules/blocklist/blocklist-validation-manager/services/blocklist-validation.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`blocklist.createMany`) export class BlocklistCreateManyPreQueryHook @@ -27,10 +28,14 @@ export class BlocklistCreateManyPreQueryHook throw new BadRequestException('User id is required'); } + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.blocklistValidationService.validateBlocklistForCreateMany( payload, authContext.user?.id, - authContext.workspace.id, + workspace.id, ); return payload; diff --git a/packages/twenty-server/src/modules/blocklist/query-hooks/blocklist-update-one.pre-query.hook.ts b/packages/twenty-server/src/modules/blocklist/query-hooks/blocklist-update-one.pre-query.hook.ts index 439c4b709..6c92060ef 100644 --- a/packages/twenty-server/src/modules/blocklist/query-hooks/blocklist-update-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/blocklist/query-hooks/blocklist-update-one.pre-query.hook.ts @@ -9,6 +9,7 @@ import { BlocklistItem, BlocklistValidationService, } from 'src/modules/blocklist/blocklist-validation-manager/services/blocklist-validation.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`blocklist.updateOne`) export class BlocklistUpdateOnePreQueryHook @@ -27,10 +28,14 @@ export class BlocklistUpdateOnePreQueryHook throw new BadRequestException('User id is required'); } + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.blocklistValidationService.validateBlocklistForUpdateOne( payload, authContext.user?.id, - authContext.workspace.id, + workspace.id, ); return payload; diff --git a/packages/twenty-server/src/modules/connected-account/query-hooks/connected-account-delete-one.pre-query.hook.ts b/packages/twenty-server/src/modules/connected-account/query-hooks/connected-account-delete-one.pre-query.hook.ts index 4bda2daa5..0dd550c5b 100644 --- a/packages/twenty-server/src/modules/connected-account/query-hooks/connected-account-delete-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/connected-account/query-hooks/connected-account-delete-one.pre-query.hook.ts @@ -12,6 +12,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`connectedAccount.destroyOne`) export class ConnectedAccountDeleteOnePreQueryHook @@ -31,6 +32,10 @@ export class ConnectedAccountDeleteOnePreQueryHook ): Promise { const connectedAccountId = payload.id; + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + const messageChannelRepository = await this.twentyORMManager.getRepository( 'messageChannel', @@ -43,7 +48,7 @@ export class ConnectedAccountDeleteOnePreQueryHook const objectMetadata = await this.objectMetadataRepository.findOneOrFail({ where: { nameSingular: 'messageChannel', - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }, }); @@ -57,7 +62,7 @@ export class ConnectedAccountDeleteOnePreQueryHook before: messageChannel, }, })), - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }); return payload; diff --git a/packages/twenty-server/src/modules/view/pre-hooks/view-delete-one.pre-query.hook.ts b/packages/twenty-server/src/modules/view/pre-hooks/view-delete-one.pre-query.hook.ts index ef9e3d86f..536c79751 100644 --- a/packages/twenty-server/src/modules/view/pre-hooks/view-delete-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/view/pre-hooks/view-delete-one.pre-query.hook.ts @@ -8,6 +8,7 @@ import { import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`view.deleteOne`) export class ViewDeleteOnePreQueryHook implements WorkspacePreQueryHookInstance @@ -21,10 +22,14 @@ export class ViewDeleteOnePreQueryHook _objectName: string, payload: DeleteOneResolverArgs, ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + const targettedViewId = payload.id; const viewRepository = await this.twentyORMGlobalManager.getRepositoryForWorkspace( - authContext.workspace.id, + workspace.id, 'view', ); diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-create-many.post-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-create-many.post-query.hook.ts index 3bdbdca30..87e02ad15 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-create-many.post-query.hook.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-create-many.post-query.hook.ts @@ -17,6 +17,7 @@ import { WorkflowVersionWorkspaceEntity, } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook({ key: `workflow.createMany`, @@ -38,6 +39,10 @@ export class WorkflowCreateManyPostQueryHook _objectName: string, payload: WorkflowWorkspaceEntity[], ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + const workflowVersionRepository = await this.twentyORMManager.getRepository( 'workflowVersion', @@ -49,7 +54,7 @@ export class WorkflowCreateManyPostQueryHook isCustom: false, nameSingular: 'workflowVersion', }, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }); const workflowVersionsToCreate = payload.map((workflow) => { @@ -70,7 +75,7 @@ export class WorkflowCreateManyPostQueryHook const objectMetadata = await this.objectMetadataRepository.findOneOrFail({ where: { nameSingular: 'workflowVersion', - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }, }); @@ -87,7 +92,7 @@ export class WorkflowCreateManyPostQueryHook }, }; }), - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }); } } diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-create-one.post-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-create-one.post-query.hook.ts index bd38441db..ee6b7b490 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-create-one.post-query.hook.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-create-one.post-query.hook.ts @@ -17,6 +17,7 @@ import { WorkflowVersionWorkspaceEntity, } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook({ key: `workflow.createOne`, @@ -38,6 +39,10 @@ export class WorkflowCreateOnePostQueryHook _objectName: string, payload: WorkflowWorkspaceEntity[], ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + const workflow = payload[0]; const workflowVersionRepository = @@ -51,7 +56,7 @@ export class WorkflowCreateOnePostQueryHook isCustom: false, nameSingular: 'workflowVersion', }, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }); const workflowVersionToCreate = await workflowVersionRepository.create({ @@ -66,7 +71,7 @@ export class WorkflowCreateOnePostQueryHook const objectMetadata = await this.objectMetadataRepository.findOneOrFail({ where: { nameSingular: 'workflowVersion', - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }, }); @@ -83,7 +88,7 @@ export class WorkflowCreateOnePostQueryHook }, }, ], - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, }); } } diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-delete-many.post-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-delete-many.post-query.hook.ts index b1c7cb2d1..a2365dd63 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-delete-many.post-query.hook.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-delete-many.post-query.hook.ts @@ -5,6 +5,7 @@ import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-r import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook({ key: `workflow.deleteMany`, @@ -22,9 +23,13 @@ export class WorkflowDeleteManyPostQueryHook _objectName: string, payload: WorkflowWorkspaceEntity[], ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workflowCommonWorkspaceService.handleWorkflowSubEntities({ workflowIds: payload.map((workflow) => workflow.id), - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, operation: 'delete', }); } diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-delete-one.post-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-delete-one.post-query.hook.ts index cbb7e5651..2acb05dd8 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-delete-one.post-query.hook.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-delete-one.post-query.hook.ts @@ -5,6 +5,7 @@ import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-r import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook({ key: `workflow.deleteOne`, @@ -22,9 +23,13 @@ export class WorkflowDeleteOnePostQueryHook _objectName: string, payload: WorkflowWorkspaceEntity[], ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workflowCommonWorkspaceService.handleWorkflowSubEntities({ workflowIds: payload.map((workflow) => workflow.id), - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, operation: 'delete', }); } diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-destroy-many.pre-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-destroy-many.pre-query.hook.ts index efcce46eb..e3c0c011f 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-destroy-many.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-destroy-many.pre-query.hook.ts @@ -4,6 +4,7 @@ import { DestroyManyResolverArgs } from 'src/engine/api/graphql/workspace-resolv import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook('workflow.destroyMany') export class WorkflowDestroyManyPreQueryHook @@ -18,9 +19,13 @@ export class WorkflowDestroyManyPreQueryHook _objectName: string, payload: DestroyManyResolverArgs<{ id: { in: string[] } }>, ): Promise> { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workflowCommonWorkspaceService.handleWorkflowSubEntities({ workflowIds: payload.filter.id.in, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, operation: 'destroy', }); diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-destroy-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-destroy-one.pre-query.hook.ts index a798b3a45..988c3578c 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-destroy-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-destroy-one.pre-query.hook.ts @@ -4,6 +4,7 @@ import { DestroyOneResolverArgs } from 'src/engine/api/graphql/workspace-resolve import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook('workflow.destroyOne') export class WorkflowDestroyOnePreQueryHook @@ -18,9 +19,13 @@ export class WorkflowDestroyOnePreQueryHook _objectName: string, payload: DestroyOneResolverArgs, ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workflowCommonWorkspaceService.handleWorkflowSubEntities({ workflowIds: [payload.id], - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, operation: 'destroy', }); diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-restore-many.post-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-restore-many.post-query.hook.ts index a7a736357..8e8bb376d 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-restore-many.post-query.hook.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-restore-many.post-query.hook.ts @@ -5,6 +5,7 @@ import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-r import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook({ key: 'workflow.restoreMany', @@ -22,9 +23,13 @@ export class WorkflowRestoreManyPostQueryHook _objectName: string, payload: WorkflowWorkspaceEntity[], ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workflowCommonWorkspaceService.handleWorkflowSubEntities({ workflowIds: payload.map((workflow) => workflow.id), - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, operation: 'restore', }); } diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-restore-one.post-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-restore-one.post-query.hook.ts index 42c2764df..a820f54eb 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-restore-one.post-query.hook.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-restore-one.post-query.hook.ts @@ -5,6 +5,7 @@ import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-r import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook({ key: 'workflow.restoreOne', @@ -22,9 +23,13 @@ export class WorkflowRestoreOnePostQueryHook _objectName: string, payload: WorkflowWorkspaceEntity[], ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workflowCommonWorkspaceService.handleWorkflowSubEntities({ workflowIds: payload.map((workflow) => workflow.id), - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, operation: 'restore', }); } diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-delete-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-delete-one.pre-query.hook.ts index af67f09df..b476d08e9 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-delete-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-delete-one.pre-query.hook.ts @@ -4,6 +4,7 @@ import { DeleteOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkflowVersionValidationWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-version-validation.workspace-service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`workflowVersion.deleteOne`) export class WorkflowVersionDeleteOnePreQueryHook @@ -20,6 +21,8 @@ export class WorkflowVersionDeleteOnePreQueryHook ): Promise { const { workspace } = authContext; + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workflowVersionValidationWorkspaceService.validateWorkflowVersionForDeleteOne( workspace.id, payload, diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-update-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-update-one.pre-query.hook.ts index 0eb63902f..0e13634b3 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-update-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-update-one.pre-query.hook.ts @@ -5,6 +5,7 @@ import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runne import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; import { WorkflowVersionValidationWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-version-validation.workspace-service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`workflowVersion.updateOne`) export class WorkflowVersionUpdateOnePreQueryHook @@ -21,6 +22,8 @@ export class WorkflowVersionUpdateOnePreQueryHook ): Promise> { const { workspace } = authContext; + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workflowVersionValidationWorkspaceService.validateWorkflowVersionForUpdateOne( { workspaceId: workspace.id, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-create-many.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-create-many.pre-query.hook.ts index 0e863abef..2d126e3dc 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-create-many.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-create-many.pre-query.hook.ts @@ -4,6 +4,7 @@ import { CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolve import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`workspaceMember.createMany`) export class WorkspaceMemberCreateManyPreQueryHook @@ -18,10 +19,14 @@ export class WorkspaceMemberCreateManyPreQueryHook objectName: string, payload: CreateManyResolverArgs, ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow( { userWorkspaceId: authContext.userWorkspaceId, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, apiKey: authContext.apiKey, workspaceMemberId: authContext.workspaceMemberId, }, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-create-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-create-one.pre-query.hook.ts index ec20d6e15..322c3d088 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-create-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-create-one.pre-query.hook.ts @@ -4,6 +4,7 @@ import { CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`workspaceMember.createOne`) export class WorkspaceMemberCreateOnePreQueryHook @@ -18,10 +19,14 @@ export class WorkspaceMemberCreateOnePreQueryHook objectName: string, payload: CreateOneResolverArgs, ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow( { userWorkspaceId: authContext.userWorkspaceId, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, apiKey: authContext.apiKey, workspaceMemberId: authContext.workspaceMemberId, }, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-many.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-many.pre-query.hook.ts index 1d56670e6..23849b2c3 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-many.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-many.pre-query.hook.ts @@ -4,6 +4,7 @@ import { DeleteManyResolverArgs } from 'src/engine/api/graphql/workspace-resolve import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`workspaceMember.deleteMany`) export class WorkspaceMemberDeleteManyPreQueryHook @@ -18,10 +19,14 @@ export class WorkspaceMemberDeleteManyPreQueryHook objectName: string, payload: DeleteManyResolverArgs, ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow( { userWorkspaceId: authContext.userWorkspaceId, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, apiKey: authContext.apiKey, workspaceMemberId: authContext.workspaceMemberId, }, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook.ts index a4b8231eb..db2350084 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook.ts @@ -6,6 +6,7 @@ import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.typ import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`workspaceMember.deleteOne`) export class WorkspaceMemberDeleteOnePreQueryHook @@ -23,12 +24,16 @@ export class WorkspaceMemberDeleteOnePreQueryHook ): Promise { const targettedWorkspaceMemberId = payload.id; + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow( { userWorkspaceId: authContext.userWorkspaceId, workspaceMemberId: authContext.workspaceMemberId, targettedWorkspaceMemberId, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, apiKey: authContext.apiKey, }, ); diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-destroy-many.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-destroy-many.pre-query.hook.ts index 9dbff99ff..f7d40db42 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-destroy-many.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-destroy-many.pre-query.hook.ts @@ -4,6 +4,7 @@ import { DeleteManyResolverArgs } from 'src/engine/api/graphql/workspace-resolve import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`workspaceMember.destroyMany`) export class WorkspaceMemberDestroyManyPreQueryHook @@ -18,10 +19,14 @@ export class WorkspaceMemberDestroyManyPreQueryHook objectName: string, payload: DeleteManyResolverArgs, ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow( { userWorkspaceId: authContext.userWorkspaceId, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, apiKey: authContext.apiKey, workspaceMemberId: authContext.workspaceMemberId, }, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-destroy-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-destroy-one.pre-query.hook.ts index 32888564b..ebe04740a 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-destroy-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-destroy-one.pre-query.hook.ts @@ -4,6 +4,7 @@ import { DeleteOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`workspaceMember.destroyOne`) export class WorkspaceMemberDestroyOnePreQueryHook @@ -18,11 +19,15 @@ export class WorkspaceMemberDestroyOnePreQueryHook objectName: string, payload: DeleteOneResolverArgs, ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow( { userWorkspaceId: authContext.userWorkspaceId, targettedWorkspaceMemberId: payload.id, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, apiKey: authContext.apiKey, workspaceMemberId: authContext.workspaceMemberId, }, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-restore-many.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-restore-many.pre-query.hook.ts index 591ad3759..37065c302 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-restore-many.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-restore-many.pre-query.hook.ts @@ -4,6 +4,7 @@ import { RestoreManyResolverArgs } from 'src/engine/api/graphql/workspace-resolv import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`workspaceMember.restoreMany`) export class WorkspaceMemberRestoreManyPreQueryHook @@ -18,10 +19,14 @@ export class WorkspaceMemberRestoreManyPreQueryHook objectName: string, payload: RestoreManyResolverArgs, ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow( { userWorkspaceId: authContext.userWorkspaceId, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, apiKey: authContext.apiKey, workspaceMemberId: authContext.workspaceMemberId, }, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-restore-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-restore-one.pre-query.hook.ts index ba412fbbb..8ed3d49dc 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-restore-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-restore-one.pre-query.hook.ts @@ -4,6 +4,7 @@ import { RestoreOneResolverArgs } from 'src/engine/api/graphql/workspace-resolve import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`workspaceMember.restoreOne`) export class WorkspaceMemberRestoreOnePreQueryHook @@ -18,11 +19,15 @@ export class WorkspaceMemberRestoreOnePreQueryHook objectName: string, payload: RestoreOneResolverArgs, ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow( { userWorkspaceId: authContext.userWorkspaceId, targettedWorkspaceMemberId: payload.id, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, apiKey: authContext.apiKey, workspaceMemberId: authContext.workspaceMemberId, }, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-many.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-many.pre-query.hook.ts index 1eeb1a42d..c09224505 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-many.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-many.pre-query.hook.ts @@ -4,6 +4,7 @@ import { UpdateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolve import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`workspaceMember.updateMany`) export class WorkspaceMemberUpdateManyPreQueryHook @@ -18,10 +19,14 @@ export class WorkspaceMemberUpdateManyPreQueryHook objectName: string, payload: UpdateManyResolverArgs, ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow( { userWorkspaceId: authContext.userWorkspaceId, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, apiKey: authContext.apiKey, workspaceMemberId: authContext.workspaceMemberId, }, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook.ts index 5448b83c1..3262b3031 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook.ts @@ -4,6 +4,7 @@ import { UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; @WorkspaceQueryHook(`workspaceMember.updateOne`) export class WorkspaceMemberUpdateOnePreQueryHook @@ -18,11 +19,15 @@ export class WorkspaceMemberUpdateOnePreQueryHook objectName: string, payload: UpdateOneResolverArgs, ): Promise { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow( { userWorkspaceId: authContext.userWorkspaceId, targettedWorkspaceMemberId: payload.id, - workspaceId: authContext.workspace.id, + workspaceId: workspace.id, apiKey: authContext.apiKey, workspaceMemberId: authContext.workspaceMemberId, },