diff --git a/LICENSE b/LICENSE index 0ad25db4bd1d..50a6a10a2861 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,8 @@ + +This project is mostly licensed under the GNU General Public License (GPL) as described below. However, certain files within this project are licensed under a different commercial license. These files are clearly marked with the following comment at the top of the file: /* @license Enterprise */ +Files with this comment are not licensed under the aGPL v3, but instead are subject to the commercial license terms defined later in this file. + + GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 @@ -659,3 +664,47 @@ specific requirements. if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . + + + +------------------------------------ + + + + The Twenty.com Commercial License (the “Commercial License”) + Copyright (c) 2023-present Twenty.com, PBC + +With regard to Twenty's Software: + +This part of the software and associated documentation files (the "Software") may only be +used in production, if you (and any entity that you represent) have agreed to, +and are in compliance with, the Terms available +at https://twenty.com/legal/terms, or other agreements governing +the use of the Software, as mutually agreed by you and Twenty.com, PBC ("Twenty"), +and otherwise have a valid Twenty Enterprise Edition subscription +for the correct number of hosts and seats as defined in the Commercial Terms. +Subject to the foregoing sentence, +you are free to modify this Software and publish patches to the Software. You agree +that Twenty and/or its licensors (as applicable) retain all right, title and interest in +and to all such modifications and/or patches, and all such modifications and/or +patches may only be used, copied, modified, displayed, distributed, or otherwise +exploited with a valid Commercial Subscription for the correct number of hosts and seats. +Notwithstanding the foregoing, you may copy and modify the Software for development +and testing purposes, without requiring a subscription. You agree that Twenty.Com and/or +its licensors (as applicable) retain all right, title and interest in and to all such +modifications. You are not granted any other rights beyond what is expressly stated herein. +Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, +and/or sell the Software. + + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +For all third party components incorporated into the Twenty Software, those +components are licensed under the original license provided by the owner of the +applicable component. \ No newline at end of file diff --git a/packages/twenty-front/folderStructure.json b/packages/twenty-front/folderStructure.json index 4dab3b2cda49..9c8fcb827c2b 100644 --- a/packages/twenty-front/folderStructure.json +++ b/packages/twenty-front/folderStructure.json @@ -1,7 +1,7 @@ { "$schema": "../../node_modules/eslint-plugin-project-structure/folderStructure.schema.json", "regexParameters": { - "camelCase": "^[a-z]+([A-Za-z0-9]+)+" + "camelCase": "^[a-z]+[A-Za-z0-9]+" }, "structure": [ { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 6947f8aa34a4..620863e11757 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -71,6 +71,7 @@ export type AuthProviders = { magicLink: Scalars['Boolean']['output']; microsoft: Scalars['Boolean']['output']; password: Scalars['Boolean']['output']; + sso: Scalars['Boolean']['output']; }; export type AuthToken = { @@ -148,6 +149,7 @@ export enum CaptchaDriverType { export type ClientConfig = { __typename?: 'ClientConfig'; + analyticsEnabled: Scalars['Boolean']['output']; api: ApiConfig; authProviders: AuthProviders; billing: Billing; @@ -275,6 +277,15 @@ export type DeleteServerlessFunctionInput = { id: Scalars['ID']['input']; }; +export type DeleteSsoInput = { + identityProviderId: Scalars['String']['input']; +}; + +export type DeleteSsoOutput = { + __typename?: 'DeleteSsoOutput'; + identityProviderId: Scalars['String']['output']; +}; + /** Schema update on a table */ export enum DistantTableUpdate { ColumnsAdded = 'COLUMNS_ADDED', @@ -283,6 +294,20 @@ export enum DistantTableUpdate { TableDeleted = 'TABLE_DELETED' } +export type EditSsoInput = { + id: Scalars['String']['input']; + status: SsoIdentityProviderStatus; +}; + +export type EditSsoOutput = { + __typename?: 'EditSsoOutput'; + id: Scalars['String']['output']; + issuer: Scalars['String']['output']; + name: Scalars['String']['output']; + status: SsoIdentityProviderStatus; + type: IdpType; +}; + export type EmailPasswordResetLink = { __typename?: 'EmailPasswordResetLink'; /** Boolean that confirms query was dispatched */ @@ -372,6 +397,20 @@ export enum FileFolder { WorkspaceLogo = 'WorkspaceLogo' } +export type FindAvailableSsoidpInput = { + email: Scalars['String']['input']; +}; + +export type FindAvailableSsoidpOutput = { + __typename?: 'FindAvailableSSOIDPOutput'; + id: Scalars['String']['output']; + issuer: Scalars['String']['output']; + name: Scalars['String']['output']; + status: SsoIdentityProviderStatus; + type: IdpType; + workspace: WorkspaceNameAndId; +}; + export type FindManyRemoteTablesInput = { /** The id of the remote server. */ id: Scalars['ID']['input']; @@ -385,6 +424,33 @@ export type FullName = { lastName: Scalars['String']['output']; }; +export type GenerateJwt = GenerateJwtOutputWithAuthTokens | GenerateJwtOutputWithSsoauth; + +export type GenerateJwtOutputWithAuthTokens = { + __typename?: 'GenerateJWTOutputWithAuthTokens'; + authTokens: AuthTokens; + reason: Scalars['String']['output']; + success: Scalars['Boolean']['output']; +}; + +export type GenerateJwtOutputWithSsoauth = { + __typename?: 'GenerateJWTOutputWithSSOAUTH'; + availableSSOIDPs: Array; + reason: Scalars['String']['output']; + success: Scalars['Boolean']['output']; +}; + +export type GetAuthorizationUrlInput = { + identityProviderId: Scalars['String']['input']; +}; + +export type GetAuthorizationUrlOutput = { + __typename?: 'GetAuthorizationUrlOutput'; + authorizationURL: Scalars['String']['output']; + id: Scalars['String']['output']; + type: Scalars['String']['output']; +}; + export type GetServerlessFunctionSourceCodeInput = { /** The id of the function. */ id: Scalars['ID']['input']; @@ -392,6 +458,11 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']['input']; }; +export enum IdpType { + Oidc = 'OIDC', + Saml = 'SAML' +} + export type IndexConnection = { __typename?: 'IndexConnection'; /** Array of edges. */ @@ -461,12 +532,14 @@ export type Mutation = { authorizeApp: AuthorizeApp; challenge: LoginToken; checkoutSession: SessionEntity; + createOIDCIdentityProvider: SetupSsoOutput; createOneAppToken: AppToken; createOneField: Field; createOneObject: Object; createOneRelation: Relation; createOneRemoteServer: RemoteServer; createOneServerlessFunction: ServerlessFunction; + createSAMLIdentityProvider: SetupSsoOutput; deactivateWorkflowVersion: Scalars['Boolean']['output']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; @@ -474,16 +547,20 @@ export type Mutation = { deleteOneRelation: Relation; deleteOneRemoteServer: RemoteServer; deleteOneServerlessFunction: ServerlessFunction; + deleteSSOIdentityProvider: DeleteSsoOutput; deleteUser: User; deleteWorkspaceInvitation: Scalars['String']['output']; disablePostgresProxy: PostgresCredentials; + editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; enablePostgresProxy: PostgresCredentials; exchangeAuthorizationCode: ExchangeAuthCode; executeOneServerlessFunction: ServerlessFunctionExecutionResult; + findAvailableSSOIdentityProviders: Array; generateApiKeyToken: ApiKeyToken; - generateJWT: AuthTokens; + generateJWT: GenerateJwt; generateTransientToken: TransientToken; + getAuthorizationUrl: GetAuthorizationUrlOutput; impersonate: Verify; publishServerlessFunction: ServerlessFunction; renewToken: AuthTokens; @@ -551,6 +628,11 @@ export type MutationCheckoutSessionArgs = { }; +export type MutationCreateOidcIdentityProviderArgs = { + input: SetupOidcSsoInput; +}; + + export type MutationCreateOneAppTokenArgs = { input: CreateOneAppTokenInput; }; @@ -581,6 +663,11 @@ export type MutationCreateOneServerlessFunctionArgs = { }; +export type MutationCreateSamlIdentityProviderArgs = { + input: SetupSamlSsoInput; +}; + + export type MutationDeactivateWorkflowVersionArgs = { workflowVersionId: Scalars['String']['input']; }; @@ -611,11 +698,21 @@ export type MutationDeleteOneServerlessFunctionArgs = { }; +export type MutationDeleteSsoIdentityProviderArgs = { + input: DeleteSsoInput; +}; + + export type MutationDeleteWorkspaceInvitationArgs = { appTokenId: Scalars['String']['input']; }; +export type MutationEditSsoIdentityProviderArgs = { + input: EditSsoInput; +}; + + export type MutationEmailPasswordResetLinkArgs = { email: Scalars['String']['input']; }; @@ -633,6 +730,11 @@ export type MutationExecuteOneServerlessFunctionArgs = { }; +export type MutationFindAvailableSsoIdentityProvidersArgs = { + input: FindAvailableSsoidpInput; +}; + + export type MutationGenerateApiKeyTokenArgs = { apiKeyId: Scalars['String']['input']; expiresAt: Scalars['String']['input']; @@ -644,6 +746,11 @@ export type MutationGenerateJwtArgs = { }; +export type MutationGetAuthorizationUrlArgs = { + input: GetAuthorizationUrlInput; +}; + + export type MutationImpersonateArgs = { userId: Scalars['String']['input']; }; @@ -865,6 +972,7 @@ export type Query = { getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; index: Index; indexMetadatas: IndexConnection; + listSSOIdentityProvidersByWorkspaceId: Array; object: Object; objects: ObjectConnection; relation: Relation; @@ -1091,6 +1199,12 @@ export type RunWorkflowVersionInput = { workflowVersionId: Scalars['String']['input']; }; +export enum SsoIdentityProviderStatus { + Active = 'Active', + Error = 'Error', + Inactive = 'Inactive' +} + export type SendInvitationsOutput = { __typename?: 'SendInvitationsOutput'; errors: Array; @@ -1179,6 +1293,31 @@ export type SessionEntity = { url?: Maybe; }; +export type SetupOidcSsoInput = { + clientID: Scalars['String']['input']; + clientSecret: Scalars['String']['input']; + issuer: Scalars['String']['input']; + name: Scalars['String']['input']; +}; + +export type SetupSamlSsoInput = { + certificate: Scalars['String']['input']; + fingerprint?: InputMaybe; + id: Scalars['String']['input']; + issuer: Scalars['String']['input']; + name: Scalars['String']['input']; + ssoURL: Scalars['String']['input']; +}; + +export type SetupSsoOutput = { + __typename?: 'SetupSsoOutput'; + id: Scalars['String']['output']; + issuer: Scalars['String']['output']; + name: Scalars['String']['output']; + status: SsoIdentityProviderStatus; + type: IdpType; +}; + /** Sort Directions */ export enum SortDirection { Asc = 'ASC', @@ -1368,11 +1507,13 @@ export type UpdateWorkspaceInput = { displayName?: InputMaybe; domainName?: InputMaybe; inviteHash?: InputMaybe; + isPublicInviteLinkEnabled?: InputMaybe; logo?: InputMaybe; }; export type User = { __typename?: 'User'; + analyticsTinybirdJwt?: Maybe; canImpersonate: Scalars['Boolean']['output']; createdAt: Scalars['DateTime']['output']; defaultAvatarUrl?: Maybe; @@ -1467,6 +1608,7 @@ export type Workspace = { featureFlags?: Maybe>; id: Scalars['UUID']['output']; inviteHash?: Maybe; + isPublicInviteLinkEnabled: Scalars['Boolean']['output']; logo?: Maybe; metadataVersion: Scalars['Float']['output']; updatedAt: Scalars['DateTime']['output']; @@ -1539,6 +1681,12 @@ export enum WorkspaceMemberTimeFormatEnum { System = 'SYSTEM' } +export type WorkspaceNameAndId = { + __typename?: 'WorkspaceNameAndId'; + displayName?: Maybe; + id: Scalars['String']['output']; +}; + export type Field = { __typename?: 'field'; createdAt: Scalars['DateTime']['output']; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index aab2dbddd2d5..3006c0487f72 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -64,6 +64,7 @@ export type AuthProviders = { magicLink: Scalars['Boolean']; microsoft: Scalars['Boolean']; password: Scalars['Boolean']; + sso: Scalars['Boolean']; }; export type AuthToken = { @@ -180,6 +181,15 @@ export type DeleteServerlessFunctionInput = { id: Scalars['ID']; }; +export type DeleteSsoInput = { + identityProviderId: Scalars['String']; +}; + +export type DeleteSsoOutput = { + __typename?: 'DeleteSsoOutput'; + identityProviderId: Scalars['String']; +}; + /** Schema update on a table */ export enum DistantTableUpdate { ColumnsAdded = 'COLUMNS_ADDED', @@ -188,6 +198,20 @@ export enum DistantTableUpdate { TableDeleted = 'TABLE_DELETED' } +export type EditSsoInput = { + id: Scalars['String']; + status: SsoIdentityProviderStatus; +}; + +export type EditSsoOutput = { + __typename?: 'EditSsoOutput'; + id: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; + status: SsoIdentityProviderStatus; + type: IdpType; +}; + export type EmailPasswordResetLink = { __typename?: 'EmailPasswordResetLink'; /** Boolean that confirms query was dispatched */ @@ -277,12 +301,53 @@ export enum FileFolder { WorkspaceLogo = 'WorkspaceLogo' } +export type FindAvailableSsoidpInput = { + email: Scalars['String']; +}; + +export type FindAvailableSsoidpOutput = { + __typename?: 'FindAvailableSSOIDPOutput'; + id: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; + status: SsoIdentityProviderStatus; + type: IdpType; + workspace: WorkspaceNameAndId; +}; + export type FullName = { __typename?: 'FullName'; firstName: Scalars['String']; lastName: Scalars['String']; }; +export type GenerateJwt = GenerateJwtOutputWithAuthTokens | GenerateJwtOutputWithSsoauth; + +export type GenerateJwtOutputWithAuthTokens = { + __typename?: 'GenerateJWTOutputWithAuthTokens'; + authTokens: AuthTokens; + reason: Scalars['String']; + success: Scalars['Boolean']; +}; + +export type GenerateJwtOutputWithSsoauth = { + __typename?: 'GenerateJWTOutputWithSSOAUTH'; + availableSSOIDPs: Array; + reason: Scalars['String']; + success: Scalars['Boolean']; +}; + +export type GetAuthorizationUrlInput = { + identityProviderId: Scalars['String']; +}; + +export type GetAuthorizationUrlOutput = { + __typename?: 'GetAuthorizationUrlOutput'; + authorizationURL: Scalars['String']; + id: Scalars['String']; + type: Scalars['String']; +}; + export type GetServerlessFunctionSourceCodeInput = { /** The id of the function. */ id: Scalars['ID']; @@ -290,6 +355,11 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']; }; +export enum IdpType { + Oidc = 'OIDC', + Saml = 'SAML' +} + export type IndexConnection = { __typename?: 'IndexConnection'; /** Array of edges. */ @@ -359,23 +429,29 @@ export type Mutation = { authorizeApp: AuthorizeApp; challenge: LoginToken; checkoutSession: SessionEntity; + createOIDCIdentityProvider: SetupSsoOutput; createOneAppToken: AppToken; createOneObject: Object; createOneServerlessFunction: ServerlessFunction; + createSAMLIdentityProvider: SetupSsoOutput; deactivateWorkflowVersion: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; deleteOneObject: Object; deleteOneServerlessFunction: ServerlessFunction; + deleteSSOIdentityProvider: DeleteSsoOutput; deleteUser: User; deleteWorkspaceInvitation: Scalars['String']; disablePostgresProxy: PostgresCredentials; + editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; enablePostgresProxy: PostgresCredentials; exchangeAuthorizationCode: ExchangeAuthCode; executeOneServerlessFunction: ServerlessFunctionExecutionResult; + findAvailableSSOIdentityProviders: Array; generateApiKeyToken: ApiKeyToken; - generateJWT: AuthTokens; + generateJWT: GenerateJwt; generateTransientToken: TransientToken; + getAuthorizationUrl: GetAuthorizationUrlOutput; impersonate: Verify; publishServerlessFunction: ServerlessFunction; renewToken: AuthTokens; @@ -438,11 +514,21 @@ export type MutationCheckoutSessionArgs = { }; +export type MutationCreateOidcIdentityProviderArgs = { + input: SetupOidcSsoInput; +}; + + export type MutationCreateOneServerlessFunctionArgs = { input: CreateServerlessFunctionInput; }; +export type MutationCreateSamlIdentityProviderArgs = { + input: SetupSamlSsoInput; +}; + + export type MutationDeactivateWorkflowVersionArgs = { workflowVersionId: Scalars['String']; }; @@ -458,11 +544,21 @@ export type MutationDeleteOneServerlessFunctionArgs = { }; +export type MutationDeleteSsoIdentityProviderArgs = { + input: DeleteSsoInput; +}; + + export type MutationDeleteWorkspaceInvitationArgs = { appTokenId: Scalars['String']; }; +export type MutationEditSsoIdentityProviderArgs = { + input: EditSsoInput; +}; + + export type MutationEmailPasswordResetLinkArgs = { email: Scalars['String']; }; @@ -480,6 +576,11 @@ export type MutationExecuteOneServerlessFunctionArgs = { }; +export type MutationFindAvailableSsoIdentityProvidersArgs = { + input: FindAvailableSsoidpInput; +}; + + export type MutationGenerateApiKeyTokenArgs = { apiKeyId: Scalars['String']; expiresAt: Scalars['String']; @@ -491,6 +592,11 @@ export type MutationGenerateJwtArgs = { }; +export type MutationGetAuthorizationUrlArgs = { + input: GetAuthorizationUrlInput; +}; + + export type MutationImpersonateArgs = { userId: Scalars['String']; }; @@ -682,6 +788,7 @@ export type Query = { getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; index: Index; indexMetadatas: IndexConnection; + listSSOIdentityProvidersByWorkspaceId: Array; object: Object; objects: ObjectConnection; serverlessFunction: ServerlessFunction; @@ -822,6 +929,12 @@ export type RunWorkflowVersionInput = { workflowVersionId: Scalars['String']; }; +export enum SsoIdentityProviderStatus { + Active = 'Active', + Error = 'Error', + Inactive = 'Inactive' +} + export type SendInvitationsOutput = { __typename?: 'SendInvitationsOutput'; errors: Array; @@ -894,6 +1007,31 @@ export type SessionEntity = { url?: Maybe; }; +export type SetupOidcSsoInput = { + clientID: Scalars['String']; + clientSecret: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; +}; + +export type SetupSamlSsoInput = { + certificate: Scalars['String']; + fingerprint?: InputMaybe; + id: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; + ssoURL: Scalars['String']; +}; + +export type SetupSsoOutput = { + __typename?: 'SetupSsoOutput'; + id: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; + status: SsoIdentityProviderStatus; + type: IdpType; +}; + /** Sort Directions */ export enum SortDirection { Asc = 'ASC', @@ -1053,6 +1191,7 @@ export type UpdateWorkspaceInput = { displayName?: InputMaybe; domainName?: InputMaybe; inviteHash?: InputMaybe; + isPublicInviteLinkEnabled?: InputMaybe; logo?: InputMaybe; }; @@ -1143,6 +1282,7 @@ export type Workspace = { featureFlags?: Maybe>; id: Scalars['UUID']; inviteHash?: Maybe; + isPublicInviteLinkEnabled: Scalars['Boolean']; logo?: Maybe; metadataVersion: Scalars['Float']; updatedAt: Scalars['DateTime']; @@ -1215,6 +1355,12 @@ export enum WorkspaceMemberTimeFormatEnum { System = 'SYSTEM' } +export type WorkspaceNameAndId = { + __typename?: 'WorkspaceNameAndId'; + displayName?: Maybe; + id: Scalars['String']; +}; + export type Field = { __typename?: 'field'; createdAt: Scalars['DateTime']; @@ -1471,6 +1617,8 @@ 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 AvailableSsoIdentityProvidersFragmentFragment = { __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }; + export type AuthorizeAppMutationVariables = Exact<{ clientId: Scalars['String']; codeChallenge: Scalars['String']; @@ -1496,6 +1644,13 @@ export type EmailPasswordResetLinkMutationVariables = Exact<{ export type EmailPasswordResetLinkMutation = { __typename?: 'Mutation', emailPasswordResetLink: { __typename?: 'EmailPasswordResetLink', success: boolean } }; +export type FindAvailableSsoIdentityProvidersMutationVariables = Exact<{ + input: FindAvailableSsoidpInput; +}>; + + +export type FindAvailableSsoIdentityProvidersMutation = { __typename?: 'Mutation', findAvailableSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }> }; + export type GenerateApiKeyTokenMutationVariables = Exact<{ apiKeyId: Scalars['String']; expiresAt: Scalars['String']; @@ -1509,19 +1664,26 @@ export type GenerateJwtMutationVariables = Exact<{ }>; -export type GenerateJwtMutation = { __typename?: 'Mutation', generateJWT: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type GenerateJwtMutation = { __typename?: 'Mutation', generateJWT: { __typename?: 'GenerateJWTOutputWithAuthTokens', success: boolean, reason: string, authTokens: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } } | { __typename?: 'GenerateJWTOutputWithSSOAUTH', success: boolean, reason: string, availableSSOIDPs: Array<{ __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }> } }; export type GenerateTransientTokenMutationVariables = Exact<{ [key: string]: never; }>; export type GenerateTransientTokenMutation = { __typename?: 'Mutation', generateTransientToken: { __typename?: 'TransientToken', transientToken: { __typename?: 'AuthToken', token: string } } }; +export type GetAuthorizationUrlMutationVariables = Exact<{ + input: GetAuthorizationUrlInput; +}>; + + +export type GetAuthorizationUrlMutation = { __typename?: 'Mutation', getAuthorizationUrl: { __typename?: 'GetAuthorizationUrlOutput', id: string, type: string, authorizationURL: string } }; + export type ImpersonateMutationVariables = Exact<{ userId: Scalars['String']; }>; -export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type RenewTokenMutationVariables = Exact<{ appToken: Scalars['String']; @@ -1554,7 +1716,7 @@ export type VerifyMutationVariables = Exact<{ }>; -export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type CheckUserExistsQueryVariables = Exact<{ email: Scalars['String']; @@ -1601,14 +1763,47 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; -export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } }; +export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } }; export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; +export type CreateOidcIdentityProviderMutationVariables = Exact<{ + input: SetupOidcSsoInput; +}>; + + +export type CreateOidcIdentityProviderMutation = { __typename?: 'Mutation', createOIDCIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; + +export type CreateSamlIdentityProviderMutationVariables = Exact<{ + input: SetupSamlSsoInput; +}>; + + +export type CreateSamlIdentityProviderMutation = { __typename?: 'Mutation', createSAMLIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; + +export type DeleteSsoIdentityProviderMutationVariables = Exact<{ + input: DeleteSsoInput; +}>; + + +export type DeleteSsoIdentityProviderMutation = { __typename?: 'Mutation', deleteSSOIdentityProvider: { __typename?: 'DeleteSsoOutput', identityProviderId: string } }; + +export type EditSsoIdentityProviderMutationVariables = Exact<{ + input: EditSsoInput; +}>; + + +export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; + +export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key: string]: never; }>; + + +export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdpType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; + +export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -1625,7 +1820,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, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; export type ActivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['String']; @@ -1803,6 +1998,18 @@ export const AuthTokensFragmentFragmentDoc = gql` } } ${AuthTokenFragmentFragmentDoc}`; +export const AvailableSsoIdentityProvidersFragmentFragmentDoc = gql` + fragment AvailableSSOIdentityProvidersFragment on FindAvailableSSOIDPOutput { + id + issuer + name + status + workspace { + id + displayName + } +} + `; export const WorkspaceMemberQueryFragmentFragmentDoc = gql` fragment WorkspaceMemberQueryFragment on WorkspaceMember { id @@ -1842,6 +2049,7 @@ export const UserQueryFragmentFragmentDoc = gql` inviteHash allowImpersonation activationStatus + isPublicInviteLinkEnabled featureFlags { id key @@ -2238,6 +2446,39 @@ export function useEmailPasswordResetLinkMutation(baseOptions?: Apollo.MutationH export type EmailPasswordResetLinkMutationHookResult = ReturnType; export type EmailPasswordResetLinkMutationResult = Apollo.MutationResult; export type EmailPasswordResetLinkMutationOptions = Apollo.BaseMutationOptions; +export const FindAvailableSsoIdentityProvidersDocument = gql` + mutation FindAvailableSSOIdentityProviders($input: FindAvailableSSOIDPInput!) { + findAvailableSSOIdentityProviders(input: $input) { + ...AvailableSSOIdentityProvidersFragment + } +} + ${AvailableSsoIdentityProvidersFragmentFragmentDoc}`; +export type FindAvailableSsoIdentityProvidersMutationFn = Apollo.MutationFunction; + +/** + * __useFindAvailableSsoIdentityProvidersMutation__ + * + * To run a mutation, you first call `useFindAvailableSsoIdentityProvidersMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useFindAvailableSsoIdentityProvidersMutation` 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 [findAvailableSsoIdentityProvidersMutation, { data, loading, error }] = useFindAvailableSsoIdentityProvidersMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useFindAvailableSsoIdentityProvidersMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(FindAvailableSsoIdentityProvidersDocument, options); + } +export type FindAvailableSsoIdentityProvidersMutationHookResult = ReturnType; +export type FindAvailableSsoIdentityProvidersMutationResult = Apollo.MutationResult; +export type FindAvailableSsoIdentityProvidersMutationOptions = Apollo.BaseMutationOptions; export const GenerateApiKeyTokenDocument = gql` mutation GenerateApiKeyToken($apiKeyId: String!, $expiresAt: String!) { generateApiKeyToken(apiKeyId: $apiKeyId, expiresAt: $expiresAt) { @@ -2275,12 +2516,26 @@ export type GenerateApiKeyTokenMutationOptions = Apollo.BaseMutationOptions; /** @@ -2341,6 +2596,41 @@ export function useGenerateTransientTokenMutation(baseOptions?: Apollo.MutationH export type GenerateTransientTokenMutationHookResult = ReturnType; export type GenerateTransientTokenMutationResult = Apollo.MutationResult; export type GenerateTransientTokenMutationOptions = Apollo.BaseMutationOptions; +export const GetAuthorizationUrlDocument = gql` + mutation GetAuthorizationUrl($input: GetAuthorizationUrlInput!) { + getAuthorizationUrl(input: $input) { + id + type + authorizationURL + } +} + `; +export type GetAuthorizationUrlMutationFn = Apollo.MutationFunction; + +/** + * __useGetAuthorizationUrlMutation__ + * + * To run a mutation, you first call `useGetAuthorizationUrlMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGetAuthorizationUrlMutation` 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 [getAuthorizationUrlMutation, { data, loading, error }] = useGetAuthorizationUrlMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useGetAuthorizationUrlMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(GetAuthorizationUrlDocument, options); + } +export type GetAuthorizationUrlMutationHookResult = ReturnType; +export type GetAuthorizationUrlMutationResult = Apollo.MutationResult; +export type GetAuthorizationUrlMutationOptions = Apollo.BaseMutationOptions; export const ImpersonateDocument = gql` mutation Impersonate($userId: String!) { impersonate(userId: $userId) { @@ -2759,6 +3049,7 @@ export const GetClientConfigDocument = gql` google password microsoft + sso } billing { isBillingEnabled @@ -2848,6 +3139,188 @@ export function useSkipSyncEmailOnboardingStepMutation(baseOptions?: Apollo.Muta export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType; export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult; export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions; +export const CreateOidcIdentityProviderDocument = gql` + mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) { + createOIDCIdentityProvider(input: $input) { + id + type + issuer + name + status + } +} + `; +export type CreateOidcIdentityProviderMutationFn = Apollo.MutationFunction; + +/** + * __useCreateOidcIdentityProviderMutation__ + * + * To run a mutation, you first call `useCreateOidcIdentityProviderMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateOidcIdentityProviderMutation` 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 [createOidcIdentityProviderMutation, { data, loading, error }] = useCreateOidcIdentityProviderMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateOidcIdentityProviderMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateOidcIdentityProviderDocument, options); + } +export type CreateOidcIdentityProviderMutationHookResult = ReturnType; +export type CreateOidcIdentityProviderMutationResult = Apollo.MutationResult; +export type CreateOidcIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const CreateSamlIdentityProviderDocument = gql` + mutation CreateSAMLIdentityProvider($input: SetupSAMLSsoInput!) { + createSAMLIdentityProvider(input: $input) { + id + type + issuer + name + status + } +} + `; +export type CreateSamlIdentityProviderMutationFn = Apollo.MutationFunction; + +/** + * __useCreateSamlIdentityProviderMutation__ + * + * To run a mutation, you first call `useCreateSamlIdentityProviderMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateSamlIdentityProviderMutation` 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 [createSamlIdentityProviderMutation, { data, loading, error }] = useCreateSamlIdentityProviderMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateSamlIdentityProviderMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateSamlIdentityProviderDocument, options); + } +export type CreateSamlIdentityProviderMutationHookResult = ReturnType; +export type CreateSamlIdentityProviderMutationResult = Apollo.MutationResult; +export type CreateSamlIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const DeleteSsoIdentityProviderDocument = gql` + mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) { + deleteSSOIdentityProvider(input: $input) { + identityProviderId + } +} + `; +export type DeleteSsoIdentityProviderMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteSsoIdentityProviderMutation__ + * + * To run a mutation, you first call `useDeleteSsoIdentityProviderMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteSsoIdentityProviderMutation` 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 [deleteSsoIdentityProviderMutation, { data, loading, error }] = useDeleteSsoIdentityProviderMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useDeleteSsoIdentityProviderMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteSsoIdentityProviderDocument, options); + } +export type DeleteSsoIdentityProviderMutationHookResult = ReturnType; +export type DeleteSsoIdentityProviderMutationResult = Apollo.MutationResult; +export type DeleteSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const EditSsoIdentityProviderDocument = gql` + mutation EditSSOIdentityProvider($input: EditSsoInput!) { + editSSOIdentityProvider(input: $input) { + id + type + issuer + name + status + } +} + `; +export type EditSsoIdentityProviderMutationFn = Apollo.MutationFunction; + +/** + * __useEditSsoIdentityProviderMutation__ + * + * To run a mutation, you first call `useEditSsoIdentityProviderMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useEditSsoIdentityProviderMutation` 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 [editSsoIdentityProviderMutation, { data, loading, error }] = useEditSsoIdentityProviderMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useEditSsoIdentityProviderMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(EditSsoIdentityProviderDocument, options); + } +export type EditSsoIdentityProviderMutationHookResult = ReturnType; +export type EditSsoIdentityProviderMutationResult = Apollo.MutationResult; +export type EditSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const ListSsoIdentityProvidersByWorkspaceIdDocument = gql` + query ListSSOIdentityProvidersByWorkspaceId { + listSSOIdentityProvidersByWorkspaceId { + type + id + name + issuer + status + } +} + `; + +/** + * __useListSsoIdentityProvidersByWorkspaceIdQuery__ + * + * To run a query within a React component, call `useListSsoIdentityProvidersByWorkspaceIdQuery` and pass it any options that fit your needs. + * When your component renders, `useListSsoIdentityProvidersByWorkspaceIdQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useListSsoIdentityProvidersByWorkspaceIdQuery({ + * variables: { + * }, + * }); + */ +export function useListSsoIdentityProvidersByWorkspaceIdQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ListSsoIdentityProvidersByWorkspaceIdDocument, options); + } +export function useListSsoIdentityProvidersByWorkspaceIdLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ListSsoIdentityProvidersByWorkspaceIdDocument, options); + } +export type ListSsoIdentityProvidersByWorkspaceIdQueryHookResult = ReturnType; +export type ListSsoIdentityProvidersByWorkspaceIdLazyQueryHookResult = ReturnType; +export type ListSsoIdentityProvidersByWorkspaceIdQueryResult = Apollo.QueryResult; export const DeleteUserAccountDocument = gql` mutation DeleteUserAccount { deleteUser { diff --git a/packages/twenty-front/src/modules/app/components/AppRouter.tsx b/packages/twenty-front/src/modules/app/components/AppRouter.tsx index 9e474de5ad44..45aa98098643 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouter.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouter.tsx @@ -8,6 +8,7 @@ export const AppRouter = () => { const billing = useRecoilValue(billingState); const isFreeAccessEnabled = useIsFeatureEnabled('IS_FREE_ACCESS_ENABLED'); const isCRMMigrationEnabled = useIsFeatureEnabled('IS_CRM_MIGRATION_ENABLED'); + const isSSOEnabled = useIsFeatureEnabled('IS_SSO_ENABLED'); const isServerlessFunctionSettingsEnabled = useIsFeatureEnabled( 'IS_FUNCTION_SETTINGS_ENABLED', ); @@ -21,6 +22,7 @@ export const AppRouter = () => { isBillingPageEnabled, isCRMMigrationEnabled, isServerlessFunctionSettingsEnabled, + isSSOEnabled, )} /> ); diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 5eede03959b5..4b48c3c1b38d 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -234,16 +234,32 @@ const SettingsCRMMigration = lazy(() => ), ); +const SettingsSecurity = lazy(() => + import('~/pages/settings/security/SettingsSecurity').then((module) => ({ + default: module.SettingsSecurity, + })), +); + +const SettingsSecuritySSOIdentifyProvider = lazy(() => + import('~/pages/settings/security/SettingsSecuritySSOIdentifyProvider').then( + (module) => ({ + default: module.SettingsSecuritySSOIdentifyProvider, + }), + ), +); + type SettingsRoutesProps = { isBillingEnabled?: boolean; isCRMMigrationEnabled?: boolean; isServerlessFunctionSettingsEnabled?: boolean; + isSSOEnabled?: boolean; }; export const SettingsRoutes = ({ isBillingEnabled, isCRMMigrationEnabled, isServerlessFunctionSettingsEnabled, + isSSOEnabled, }: SettingsRoutesProps) => ( }> @@ -357,6 +373,15 @@ export const SettingsRoutes = ({ element={} /> } /> + {isSSOEnabled && ( + <> + } /> + } + /> + + )} ); diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx index 83d13c45303f..0aa19e6e16cb 100644 --- a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx +++ b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx @@ -29,6 +29,7 @@ export const useCreateAppRouter = ( isBillingEnabled?: boolean, isCRMMigrationEnabled?: boolean, isServerlessFunctionSettingsEnabled?: boolean, + isSSOEnabled?: boolean, ) => createBrowserRouter( createRoutesFromElements( @@ -65,6 +66,7 @@ export const useCreateAppRouter = ( isServerlessFunctionSettingsEnabled={ isServerlessFunctionSettingsEnabled } + isSSOEnabled={isSSOEnabled} /> } /> diff --git a/packages/twenty-front/src/modules/auth/graphql/fragments/availableSSOIdentityProvidersFragment.ts b/packages/twenty-front/src/modules/auth/graphql/fragments/availableSSOIdentityProvidersFragment.ts new file mode 100644 index 000000000000..45bc6c944cbe --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/fragments/availableSSOIdentityProvidersFragment.ts @@ -0,0 +1,16 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const AVAILABLE_SSO_IDENTITY_PROVIDERS_FRAGMENT = gql` + fragment AvailableSSOIdentityProvidersFragment on FindAvailableSSOIDPOutput { + id + issuer + name + status + workspace { + id + displayName + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/findAvailableSSOIdentityProviders.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/findAvailableSSOIdentityProviders.ts new file mode 100644 index 000000000000..888cda398c71 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/findAvailableSSOIdentityProviders.ts @@ -0,0 +1,13 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const FIND_AVAILABLE_SSO_IDENTITY_PROVIDERS = gql` + mutation FindAvailableSSOIdentityProviders( + $input: FindAvailableSSOIDPInput! + ) { + findAvailableSSOIdentityProviders(input: $input) { + ...AvailableSSOIdentityProvidersFragment + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/generateJWT.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/generateJWT.ts index 7f7d19ae71be..620f70c69c86 100644 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/generateJWT.ts +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/generateJWT.ts @@ -3,8 +3,21 @@ import { gql } from '@apollo/client'; export const GENERATE_JWT = gql` mutation GenerateJWT($workspaceId: String!) { generateJWT(workspaceId: $workspaceId) { - tokens { - ...AuthTokensFragment + ... on GenerateJWTOutputWithAuthTokens { + success + reason + authTokens { + tokens { + ...AuthTokensFragment + } + } + } + ... on GenerateJWTOutputWithSSOAUTH { + success + reason + availableSSOIDPs { + ...AvailableSSOIdentityProvidersFragment + } } } } diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/getAuthorizationUrl.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/getAuthorizationUrl.ts new file mode 100644 index 000000000000..5492a9fade33 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/getAuthorizationUrl.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const GET_AUTHORIZATION_URL = gql` + mutation GetAuthorizationUrl($input: GetAuthorizationUrlInput!) { + getAuthorizationUrl(input: $input) { + id + type + authorizationURL + } + } +`; 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 60e4025a814f..8e30a3287ab5 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 @@ -116,6 +116,7 @@ describe('useAuth', () => { microsoft: false, magicLink: false, password: false, + sso: false, }); expect(state.billing).toBeNull(); expect(state.isSignInPrefilled).toBe(false); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx index 3bad98dcb083..46e38da11c9a 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx @@ -1,8 +1,6 @@ import styled from '@emotion/styled'; import React from 'react'; -type FooterNoteProps = { children: React.ReactNode }; - const StyledContainer = styled.div` align-items: center; color: ${({ theme }) => theme.font.color.tertiary}; @@ -20,6 +18,24 @@ const StyledContainer = styled.div` } `; -export const FooterNote = ({ children }: FooterNoteProps) => ( - {children} +export const FooterNote = () => ( + + By using Twenty, you agree to the{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . + ); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/HorizontalSeparator.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/HorizontalSeparator.tsx index f7c5b61f0e5d..d8d68bfbb71c 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/HorizontalSeparator.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/HorizontalSeparator.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; type HorizontalSeparatorProps = { visible?: boolean; + text?: string; }; const StyledSeparator = styled.div` background-color: ${({ theme }) => theme.border.color.medium}; @@ -12,8 +13,39 @@ const StyledSeparator = styled.div` width: 100%; `; +const StyledSeparatorContainer = styled.div` + align-items: center; + display: flex; + margin-bottom: ${({ theme }) => theme.spacing(3)}; + margin-top: ${({ theme }) => theme.spacing(3)}; + width: 100%; +`; + +const StyledLine = styled.div` + background-color: ${({ theme }) => theme.border.color.medium}; + height: ${({ visible }) => (visible ? '1px' : 0)}; + flex-grow: 1; +`; + +const StyledText = styled.span` + color: ${({ theme }) => theme.font.color.light}; + margin: 0 ${({ theme }) => theme.spacing(2)}; + white-space: nowrap; +`; + export const HorizontalSeparator = ({ visible = true, + text = '', }: HorizontalSeparatorProps): JSX.Element => ( - + <> + {text ? ( + + + {text && {text}} + + + ) : ( + + )} + ); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx index 65021f7c6824..120eacb5fd58 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx @@ -5,16 +5,12 @@ import { useMemo, useState } from 'react'; import { Controller } from 'react-hook-form'; import { useRecoilState, useRecoilValue } from 'recoil'; import { Key } from 'ts-key-enum'; -import { IconGoogle, IconMicrosoft } from 'twenty-ui'; +import { IconGoogle, IconMicrosoft, IconKey } from 'twenty-ui'; import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; -import { - SignInUpMode, - SignInUpStep, - useSignInUp, -} from '@/auth/sign-in-up/hooks/useSignInUp'; +import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; @@ -26,6 +22,7 @@ import { MainButton } from '@/ui/input/button/components/MainButton'; import { TextInput } from '@/ui/input/components/TextInput'; import { ActionLink } from '@/ui/navigation/link/components/ActionLink'; import { isDefined } from '~/utils/isDefined'; +import { SignInUpStep } from '@/auth/states/signInUpStepState'; const StyledContentContainer = styled.div` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -64,9 +61,19 @@ export const SignInUpForm = () => { signInUpMode, continueWithCredentials, continueWithEmail, + continueWithSSO, submitCredentials, + submitSSOEmail, } = useSignInUp(form); + const toggleSSOMode = () => { + if (signInUpStep === SignInUpStep.SSOEmail) { + continueWithEmail(); + } else { + continueWithSSO(); + } + }; + const handleKeyDown = async ( event: React.KeyboardEvent, ) => { @@ -86,6 +93,8 @@ export const SignInUpForm = () => { setShowErrors(true); form.handleSubmit(submitCredentials)(); } + } else if (signInUpStep === SignInUpStep.SSOEmail) { + submitSSOEmail(form.getValues('email')); } } }; @@ -99,6 +108,10 @@ export const SignInUpForm = () => { return 'Continue'; } + if (signInUpStep === SignInUpStep.SSOEmail) { + return 'Continue with SSO'; + } + return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up'; }, [signInUpMode, signInUpStep]); @@ -136,7 +149,7 @@ export const SignInUpForm = () => { onClick={signInWithGoogle} fullWidth /> - + )} @@ -148,17 +161,143 @@ export const SignInUpForm = () => { onClick={signInWithMicrosoft} fullWidth /> - + + + )} + {authProviders.sso && ( + <> + } + title={ + signInUpStep === SignInUpStep.SSOEmail + ? 'Continue with email' + : 'Single sign-on (SSO)' + } + onClick={toggleSSOMode} + fullWidth + /> + )} - {authProviders.password && ( - { - event.preventDefault(); - }} - > - {signInUpStep !== SignInUpStep.Init && ( + + + {authProviders.password && + (signInUpStep === SignInUpStep.Password || + signInUpStep === SignInUpStep.Email || + signInUpStep === SignInUpStep.Init) && ( + { + event.preventDefault(); + }} + > + {signInUpStep !== SignInUpStep.Init && ( + + ( + + { + onChange(value); + if (signInUpStep === SignInUpStep.Password) { + continueWithEmail(); + } + }} + error={showErrors ? error?.message : undefined} + fullWidth + disableHotkeys + onKeyDown={handleKeyDown} + /> + + )} + /> + + )} + {signInUpStep === SignInUpStep.Password && ( + + ( + + + + )} + /> + + )} + { + if (signInUpStep === SignInUpStep.Init) { + continueWithEmail(); + return; + } + if (signInUpStep === SignInUpStep.Email) { + if (isDefined(form?.formState?.errors?.email)) { + setShowErrors(true); + return; + } + continueWithCredentials(); + return; + } + setShowErrors(true); + form.handleSubmit(submitCredentials)(); + }} + Icon={() => form.formState.isSubmitting && } + disabled={isSubmitButtonDisabled} + fullWidth + /> + + )} + { + event.preventDefault(); + }} + > + {signInUpStep === SignInUpStep.SSOEmail && ( + <> { value={value} placeholder="Email" onBlur={onBlur} - onChange={(value: string) => { - onChange(value); - if (signInUpStep === SignInUpStep.Password) { - continueWithEmail(); - } - }} - error={showErrors ? error?.message : undefined} - fullWidth - disableHotkeys - onKeyDown={handleKeyDown} - /> - - )} - /> - - )} - {signInUpStep === SignInUpStep.Password && ( - - ( - - { )} /> - )} - { - if (signInUpStep === SignInUpStep.Init) { - continueWithEmail(); - return; - } - if (signInUpStep === SignInUpStep.Email) { - if (isDefined(form?.formState?.errors?.email)) { - setShowErrors(true); - return; - } - continueWithCredentials(); - return; - } - setShowErrors(true); - form.handleSubmit(submitCredentials)(); - }} - Icon={() => form.formState.isSubmitting && } - disabled={isSubmitButtonDisabled} - fullWidth - /> - - )} + { + setShowErrors(true); + submitSSOEmail(form.getValues('email')); + }} + Icon={() => form.formState.isSubmitting && } + disabled={isSubmitButtonDisabled} + fullWidth + /> + + )} + {signInUpStep === SignInUpStep.Password && ( Forgot your password? )} - {signInUpStep === SignInUpStep.Init && ( - - By using Twenty, you agree to the{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - - . - - )} + {signInUpStep === SignInUpStep.Init && } ); }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts new file mode 100644 index 000000000000..86a8b83928f0 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts @@ -0,0 +1,68 @@ +/* @license Enterprise */ + +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { + FindAvailableSsoIdentityProvidersMutationVariables, + GetAuthorizationUrlMutationVariables, + useFindAvailableSsoIdentityProvidersMutation, + useGetAuthorizationUrlMutation, +} from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; + +export const useSSO = () => { + const { enqueueSnackBar } = useSnackBar(); + + const [findAvailableSSOProviderByEmailMutation] = + useFindAvailableSsoIdentityProvidersMutation(); + const [getAuthorizationUrlMutation] = useGetAuthorizationUrlMutation(); + + const findAvailableSSOProviderByEmail = async ({ + email, + }: FindAvailableSsoIdentityProvidersMutationVariables['input']) => { + return await findAvailableSSOProviderByEmailMutation({ + variables: { + input: { email }, + }, + }); + }; + + const getAuthorizationUrlForSSO = async ({ + identityProviderId, + }: GetAuthorizationUrlMutationVariables['input']) => { + return await getAuthorizationUrlMutation({ + variables: { + input: { identityProviderId }, + }, + }); + }; + + const redirectToSSOLoginPage = async (identityProviderId: string) => { + const authorizationUrlForSSOResult = await getAuthorizationUrlForSSO({ + identityProviderId, + }); + + if ( + isDefined(authorizationUrlForSSOResult.errors) || + !authorizationUrlForSSOResult.data || + !authorizationUrlForSSOResult.data?.getAuthorizationUrl.authorizationURL + ) { + return enqueueSnackBar( + authorizationUrlForSSOResult.errors?.[0]?.message ?? 'Unknown error', + { + variant: SnackBarVariant.Error, + }, + ); + } + + window.location.href = + authorizationUrlForSSOResult.data?.getAuthorizationUrl.authorizationURL; + return; + }; + + return { + redirectToSSOLoginPage, + getAuthorizationUrlForSSO, + findAvailableSSOProviderByEmail, + }; +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx index 48c66f54ed65..a80473854c21 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx @@ -9,25 +9,34 @@ import { AppPath } from '@/types/AppPath'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; +import { useRecoilState, useSetRecoilState } from 'recoil'; +import { isDefined } from '~/utils/isDefined'; import { useAuth } from '../../hooks/useAuth'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; +import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; export enum SignInUpMode { SignIn = 'sign-in', SignUp = 'sign-up', } -export enum SignInUpStep { - Init = 'init', - Email = 'email', - Password = 'password', -} - export const useSignInUp = (form: UseFormReturn
) => { const { enqueueSnackBar } = useSnackBar(); + const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState); + const isMatchingLocation = useIsMatchingLocation(); + const { redirectToSSOLoginPage, findAvailableSSOProviderByEmail } = useSSO(); + const setAvailableWorkspacesForSSOState = useSetRecoilState( + availableSSOIdentityProvidersState, + ); + const workspaceInviteHash = useParams().workspaceInviteHash; const [searchParams] = useSearchParams(); const workspacePersonalInviteToken = @@ -35,10 +44,6 @@ export const useSignInUp = (form: UseFormReturn) => { const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite)); - const [signInUpStep, setSignInUpStep] = useState( - SignInUpStep.Init, - ); - const [signInUpMode, setSignInUpMode] = useState(() => { return isMatchingLocation(AppPath.SignInUp) ? SignInUpMode.SignIn @@ -62,7 +67,7 @@ export const useSignInUp = (form: UseFormReturn) => { ? SignInUpMode.SignIn : SignInUpMode.SignUp, ); - }, [isMatchingLocation, requestFreshCaptchaToken]); + }, [isMatchingLocation, requestFreshCaptchaToken, setSignInUpStep]); const continueWithCredentials = useCallback(async () => { const token = await readCaptchaToken(); @@ -95,8 +100,48 @@ export const useSignInUp = (form: UseFormReturn) => { checkUserExistsQuery, enqueueSnackBar, requestFreshCaptchaToken, + setSignInUpStep, ]); + const continueWithSSO = () => { + setSignInUpStep(SignInUpStep.SSOEmail); + }; + + const submitSSOEmail = async (email: string) => { + const result = await findAvailableSSOProviderByEmail({ + email, + }); + + if (isDefined(result.errors)) { + return enqueueSnackBar(result.errors[0].message, { + variant: SnackBarVariant.Error, + }); + } + + if ( + !result.data?.findAvailableSSOIdentityProviders || + result.data?.findAvailableSSOIdentityProviders.length === 0 + ) { + enqueueSnackBar('No workspaces with SSO found', { + variant: SnackBarVariant.Error, + }); + return; + } + // If only one workspace, redirect to SSO + if (result.data?.findAvailableSSOIdentityProviders.length === 1) { + return redirectToSSOLoginPage( + result.data.findAvailableSSOIdentityProviders[0].id, + ); + } + + if (result.data?.findAvailableSSOIdentityProviders.length > 1) { + setAvailableWorkspacesForSSOState( + result.data.findAvailableSSOIdentityProviders, + ); + setSignInUpStep(SignInUpStep.SSOWorkspaceSelection); + } + }; + const submitCredentials: SubmitHandler = useCallback( async (data) => { const token = await readCaptchaToken(); @@ -144,6 +189,8 @@ export const useSignInUp = (form: UseFormReturn) => { signInUpMode, continueWithCredentials, continueWithEmail, + continueWithSSO, + submitSSOEmail, submitCredentials, }; }; diff --git a/packages/twenty-front/src/modules/auth/states/availableWorkspacesForSSO.ts b/packages/twenty-front/src/modules/auth/states/availableWorkspacesForSSO.ts new file mode 100644 index 000000000000..e4c83c26e15e --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/availableWorkspacesForSSO.ts @@ -0,0 +1,11 @@ +import { createState } from 'twenty-ui'; +import { FindAvailableSsoIdentityProvidersMutationResult } from '~/generated/graphql'; + +export const availableSSOIdentityProvidersState = createState< + NonNullable< + FindAvailableSsoIdentityProvidersMutationResult['data'] + >['findAvailableSSOIdentityProviders'] +>({ + key: 'availableSSOIdentityProviders', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index 8dd9ea7bc791..e384b5b6a923 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -13,6 +13,7 @@ export type CurrentWorkspace = Pick< | 'activationStatus' | 'currentBillingSubscription' | 'workspaceMembersCount' + | 'isPublicInviteLinkEnabled' | 'metadataVersion' >; diff --git a/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts b/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts new file mode 100644 index 000000000000..71f359fdee2e --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts @@ -0,0 +1,14 @@ +import { createState } from 'twenty-ui'; + +export enum SignInUpStep { + Init = 'init', + Email = 'email', + Password = 'password', + SSOEmail = 'SSOEmail', + SSOWorkspaceSelection = 'SSOWorkspaceSelection', +} + +export const signInUpStepState = createState({ + key: 'signInUpStepState', + defaultValue: SignInUpStep.Init, +}); diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index ed06d3f0ee69..bf11d6713c2c 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -49,6 +49,7 @@ export const ClientConfigProviderEffect = () => { microsoft: data?.clientConfig.authProviders.microsoft, password: data?.clientConfig.authProviders.password, magicLink: false, + sso: data?.clientConfig.authProviders.sso, }); setIsDebugMode(data?.clientConfig.debugMode); setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled); diff --git a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts index 9a060b0d7b2b..eaf55e4d0a93 100644 --- a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts @@ -7,6 +7,7 @@ export const GET_CLIENT_CONFIG = gql` google password microsoft + sso } billing { isBillingEnabled diff --git a/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts b/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts index 6b705d037557..ef37f22cf9ff 100644 --- a/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts +++ b/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts @@ -9,5 +9,6 @@ export const authProvidersState = createState({ magicLink: false, password: false, microsoft: false, + sso: false, }, }); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts index 62846c8fbab5..13f5bec9a5e3 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts @@ -17,6 +17,7 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({ allowImpersonation: false, activationStatus: WorkspaceActivationStatus.Active, metadataVersion: 1, + isPublicInviteLinkEnabled: false, }); }, }); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelDetails.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelDetails.tsx index 3955904289ab..f6b2e90393b1 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelDetails.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelDetails.tsx @@ -2,11 +2,13 @@ import { CalendarChannel } from '@/accounts/types/CalendarChannel'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { SettingsAccountsEventVisibilitySettingsCard } from '@/settings/accounts/components/SettingsAccountsCalendarVisibilitySettingsCard'; -import { SettingsAccountsToggleSettingCard } from '@/settings/accounts/components/SettingsAccountsToggleSettingCard'; import styled from '@emotion/styled'; import { Section } from '@react-email/components'; import { H2Title } from 'twenty-ui'; import { CalendarChannelVisibility } from '~/generated-metadata/graphql'; +import { Card } from '@/ui/layout/card/components/Card'; +import { SettingsOptionCardContent } from '@/settings/components/SettingsOptionCardContent'; +import { Toggle } from '@/ui/input/components/Toggle'; const StyledDetailsContainer = styled.div` display: flex; @@ -21,6 +23,10 @@ type SettingsAccountsCalendarChannelDetailsProps = { >; }; +const StyledToggle = styled(Toggle)` + margin-left: auto; +`; + export const SettingsAccountsCalendarChannelDetails = ({ calendarChannel, }: SettingsAccountsCalendarChannelDetailsProps) => { @@ -63,16 +69,21 @@ export const SettingsAccountsCalendarChannelDetails = ({ title="Contact auto-creation" description="Automatically create contacts for people you've participated in an event with." /> - + + + handleContactAutoCreationToggle( + !calendarChannel.isContactAutoCreationEnabled, + ) + } + > + + + ); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelDetails.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelDetails.tsx index 622f9ca06f26..c28fa1056109 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelDetails.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelDetails.tsx @@ -9,9 +9,11 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { SettingsAccountsMessageAutoCreationCard } from '@/settings/accounts/components/SettingsAccountsMessageAutoCreationCard'; import { SettingsAccountsMessageVisibilityCard } from '@/settings/accounts/components/SettingsAccountsMessageVisibilityCard'; -import { SettingsAccountsToggleSettingCard } from '@/settings/accounts/components/SettingsAccountsToggleSettingCard'; import { Section } from '@/ui/layout/section/components/Section'; import { MessageChannelVisibility } from '~/generated-metadata/graphql'; +import { Card } from '@/ui/layout/card/components/Card'; +import { SettingsOptionCardContent } from '@/settings/components/SettingsOptionCardContent'; +import { Toggle } from '@/ui/input/components/Toggle'; type SettingsAccountsMessageChannelDetailsProps = { messageChannel: Pick< @@ -31,6 +33,10 @@ const StyledDetailsContainer = styled.div` gap: ${({ theme }) => theme.spacing(6)}; `; +const StyledToggle = styled(Toggle)` + margin-left: auto; +`; + export const SettingsAccountsMessageChannelDetails = ({ messageChannel, }: SettingsAccountsMessageChannelDetailsProps) => { @@ -99,23 +105,31 @@ export const SettingsAccountsMessageChannelDetails = ({ />
- + + + handleIsNonProfessionalEmailExcludedToggle( + !messageChannel.excludeNonProfessionalEmails, + ) + } + > + + + + handleIsGroupEmailExcludedToggle( + !messageChannel.excludeGroupEmails, + ) + } + > + + +
); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsToggleSettingCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsToggleSettingCard.tsx deleted file mode 100644 index 62bfbaa40b8e..000000000000 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsToggleSettingCard.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import styled from '@emotion/styled'; - -import { Toggle } from '@/ui/input/components/Toggle'; -import { Card } from '@/ui/layout/card/components/Card'; -import { CardContent } from '@/ui/layout/card/components/CardContent'; - -type Parameter = { - value: boolean; - title: string; - description: string; - onToggle: (value: boolean) => void; -}; - -type SettingsAccountsToggleSettingCardProps = { - parameters: Parameter[]; -}; - -const StyledCardContent = styled(CardContent)` - align-items: center; - display: flex; - gap: ${({ theme }) => theme.spacing(4)}; - cursor: pointer; - - &:hover { - background: ${({ theme }) => theme.background.transparent.lighter}; - } -`; - -const StyledTitle = styled.div` - color: ${({ theme }) => theme.font.color.primary}; - font-weight: ${({ theme }) => theme.font.weight.medium}; - margin-bottom: ${({ theme }) => theme.spacing(2)}; -`; - -const StyledDescription = styled.div` - color: ${({ theme }) => theme.font.color.tertiary}; - font-size: ${({ theme }) => theme.font.size.sm}; -`; - -const StyledToggle = styled(Toggle)` - margin-left: auto; -`; - -export const SettingsAccountsToggleSettingCard = ({ - parameters, -}: SettingsAccountsToggleSettingCardProps) => ( - - {parameters.map((parameter, index) => ( - parameter.onToggle(!parameter.value)} - > -
- {parameter.title} - {parameter.description} -
- -
- ))} -
-); diff --git a/packages/twenty-front/src/modules/settings/components/SettingsListCard.tsx b/packages/twenty-front/src/modules/settings/components/SettingsListCard.tsx index 86466c6ffe4a..ad580b2e3099 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsListCard.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsListCard.tsx @@ -42,6 +42,7 @@ type SettingsListCardProps = { isLoading?: boolean; onRowClick?: (item: ListItem) => void; RowIcon?: IconComponent; + RowIconFn?: (item: ListItem) => IconComponent; RowRightComponent: ComponentType<{ item: ListItem }>; footerButtonLabel?: string; onFooterButtonClick?: () => void; @@ -58,6 +59,7 @@ export const SettingsListCard = < isLoading, onRowClick, RowIcon, + RowIconFn, RowRightComponent, onFooterButtonClick, footerButtonLabel, @@ -71,7 +73,7 @@ export const SettingsListCard = < {items.map((item, index) => ( } divider={index < items.length - 1} diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx index e5f6dca4ee7d..8f191abf682c 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx @@ -16,6 +16,7 @@ import { IconTool, IconUserCircle, IconUsers, + IconKey, MAIN_COLORS, } from 'twenty-ui'; @@ -79,6 +80,7 @@ export const SettingsNavigationDrawerItems = () => { ); const isFreeAccessEnabled = useIsFeatureEnabled('IS_FREE_ACCESS_ENABLED'); const isCRMMigrationEnabled = useIsFeatureEnabled('IS_CRM_MIGRATION_ENABLED'); + const isSSOEnabled = useIsFeatureEnabled('IS_SSO_ENABLED'); const isBillingPageEnabled = billing?.isBillingEnabled && !isFreeAccessEnabled; @@ -186,6 +188,13 @@ export const SettingsNavigationDrawerItems = () => { Icon={IconCode} /> )} + {isSSOEnabled && ( + + )} {isAdvancedModeEnabled && ( diff --git a/packages/twenty-front/src/modules/settings/components/SettingsOptionCardContent.tsx b/packages/twenty-front/src/modules/settings/components/SettingsOptionCardContent.tsx new file mode 100644 index 000000000000..90b61b7c04e9 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/components/SettingsOptionCardContent.tsx @@ -0,0 +1,75 @@ +import styled from '@emotion/styled'; +import { useTheme } from '@emotion/react'; + +import { CardContent } from '@/ui/layout/card/components/CardContent'; +import { IconComponent } from 'twenty-ui'; +import { ReactNode } from 'react'; + +type SettingsOptionCardContentProps = { + Icon?: IconComponent; + title: string; + description: string; + onClick: () => void; + children: ReactNode; + divider?: boolean; +}; + +const StyledCardContent = styled(CardContent)` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; + cursor: pointer; + + &:hover { + background: ${({ theme }) => theme.background.transparent.lighter}; + } +`; + +const StyledTitle = styled.div` + color: ${({ theme }) => theme.font.color.primary}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + margin-bottom: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledDescription = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; +`; + +const StyledIcon = styled.div` + align-items: center; + border: 2px solid ${({ theme }) => theme.border.color.light}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + background-color: ${({ theme }) => theme.background.primary}; + display: flex; + height: ${({ theme }) => theme.spacing(8)}; + justify-content: center; + width: ${({ theme }) => theme.spacing(8)}; + min-width: ${({ theme }) => theme.icon.size.md}; +`; + +export const SettingsOptionCardContent = ({ + Icon, + title, + description, + onClick, + children, + divider, +}: SettingsOptionCardContentProps) => { + const theme = useTheme(); + + return ( + + {Icon && ( + + + + )} +
+ {title} + {description} +
+ {children} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsRadioCard.tsx b/packages/twenty-front/src/modules/settings/components/SettingsRadioCard.tsx new file mode 100644 index 000000000000..6a5c8c08f00b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/components/SettingsRadioCard.tsx @@ -0,0 +1,66 @@ +import styled from '@emotion/styled'; +import { Radio } from '@/ui/input/components/Radio'; +import { CardContent } from '@/ui/layout/card/components/CardContent'; +import { IconComponent } from 'twenty-ui'; +import { useTheme } from '@emotion/react'; + +const StyledRadioCardContent = styled(CardContent)` + display: flex; + align-items: center; + padding: ${({ theme }) => theme.spacing(2)}; + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + flex-grow: 1; + gap: ${({ theme }) => theme.spacing(2)}; + cursor: pointer; + + &:hover { + background: ${({ theme }) => theme.background.transparent.lighter}; + } +`; + +const StyledRadio = styled(Radio)` + margin-left: auto; + padding: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledTitle = styled.div` + color: ${({ theme }) => theme.font.color.secondary}; + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +const StyledDescription = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; +`; + +type SettingsRadioCardProps = { + value: string; + handleClick: (value: string) => void; + isSelected: boolean; + title: string; + description?: string; + Icon?: IconComponent; +}; + +export const SettingsRadioCard = ({ + value, + handleClick, + title, + description, + isSelected, + Icon, +}: SettingsRadioCardProps) => { + const theme = useTheme(); + + return ( + handleClick(value)}> + {Icon && } + + {title && {title}} + {description && {description}} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsRadioCardContainer.tsx b/packages/twenty-front/src/modules/settings/components/SettingsRadioCardContainer.tsx new file mode 100644 index 000000000000..3b8f49941132 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/components/SettingsRadioCardContainer.tsx @@ -0,0 +1,42 @@ +import styled from '@emotion/styled'; +import { IconComponent } from 'twenty-ui'; +import { SettingsRadioCard } from '@/settings/components/SettingsRadioCard'; + +const StyledRadioCardContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${({ theme }) => theme.spacing(4)}; +`; + +type SettingsRadioCardContainerProps = { + onChange: (value: string) => void; + value: string; + options: Array<{ + value: string; + title: string; + description?: string; + Icon?: IconComponent; + }>; +}; + +export const SettingsRadioCardContainer = ({ + options, + value, + onChange, +}: SettingsRadioCardContainerProps) => { + return ( + + {options.map((option) => ( + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx new file mode 100644 index 000000000000..0913e59a205a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx @@ -0,0 +1,124 @@ +/* @license Enterprise */ + +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { SettingsRadioCardContainer } from '@/settings/components/SettingsRadioCardContainer'; +import { SettingsSSOOIDCForm } from '@/settings/security/components/SettingsSSOOIDCForm'; +import { SettingsSSOSAMLForm } from '@/settings/security/components/SettingsSSOSAMLForm'; +import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { Section } from '@/ui/layout/section/components/Section'; +import styled from '@emotion/styled'; +import { ReactElement } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { H2Title, IconComponent, IconKey } from 'twenty-ui'; +import { IdpType } from '~/generated/graphql'; + +const StyledInputsContainer = styled.div` + display: grid; + gap: ${({ theme }) => theme.spacing(2, 4)}; + grid-template-columns: 1fr 1fr; + grid-template-areas: + 'input-1 input-1' + 'input-2 input-3' + 'input-4 input-5'; + + & :first-of-type { + grid-area: input-1; + } +`; + +export const SettingsSSOIdentitiesProvidersForm = () => { + const { control, getValues } = + useFormContext(); + + const IdpMap: Record< + IdpType, + { + form: ReactElement; + option: { + Icon: IconComponent; + title: string; + value: string; + description: string; + }; + } + > = { + OIDC: { + option: { + Icon: IconKey, + title: 'OIDC', + value: 'OIDC', + description: '', + }, + form: , + }, + SAML: { + option: { + Icon: IconKey, + title: 'SAML', + value: 'SAML', + description: '', + }, + form: , + }, + }; + + const getFormByType = (type: Uppercase | undefined) => { + switch (type) { + case IdpType.Oidc: + return IdpMap.OIDC.form; + case IdpType.Saml: + return IdpMap.SAML.form; + default: + return null; + } + }; + + return ( + +
+ + + ( + + )} + /> + +
+
+ + + ( + identityProviderType.option, + )} + onChange={onChange} + /> + )} + /> + +
+ {getFormByType(getValues().type)} +
+ ); +}; + +export default SettingsSSOIdentitiesProvidersForm; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx new file mode 100644 index 000000000000..1a47819904b5 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx @@ -0,0 +1,61 @@ +/* @license Enterprise */ + +import { useNavigate } from 'react-router-dom'; + +import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; +import { SettingsPath } from '@/types/SettingsPath'; + +import { SettingsSSOIdentitiesProvidersListEmptyStateCard } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListEmptyStateCard'; +import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SettingsSSOIdentityProviderRowRightContainer'; +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useRecoilState } from 'recoil'; +import { useListSsoIdentityProvidersByWorkspaceIdQuery } from '~/generated/graphql'; +import { SettingsListCard } from '../../components/SettingsListCard'; +import { guessSSOIdentityProviderIconByUrl } from '../utils/guessSSOIdentityProviderIconByUrl'; + +export const SettingsSSOIdentitiesProvidersListCard = () => { + const navigate = useNavigate(); + const { enqueueSnackBar } = useSnackBar(); + + const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState( + SSOIdentitiesProvidersState, + ); + + const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({ + onCompleted: (data) => { + setSSOIdentitiesProviders( + data?.listSSOIdentityProvidersByWorkspaceId ?? [], + ); + }, + onError: (error: Error) => { + enqueueSnackBar(error.message, { + variant: SnackBarVariant.Error, + }); + }, + }); + + return !SSOIdentitiesProviders.length && !loading ? ( + + ) : ( + + `${SSOIdentityProvider.name} - ${SSOIdentityProvider.type}` + } + isLoading={loading} + RowIconFn={(SSOIdentityProvider) => + guessSSOIdentityProviderIconByUrl(SSOIdentityProvider.issuer) + } + RowRightComponent={({ item: SSOIdp }) => ( + + )} + hasFooter + footerButtonLabel="Add SSO Identity Provider" + onFooterButtonClick={() => + navigate(getSettingsPagePath(SettingsPath.NewSSOIdentityProvider)) + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListEmptyStateCard.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListEmptyStateCard.tsx new file mode 100644 index 000000000000..024fe70d954d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListEmptyStateCard.tsx @@ -0,0 +1,38 @@ +/* @license Enterprise */ + +import styled from '@emotion/styled'; + +import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; +import { SettingsPath } from '@/types/SettingsPath'; +import { Button } from '@/ui/input/button/components/Button'; +import { Card } from '@/ui/layout/card/components/Card'; +import { CardContent } from '@/ui/layout/card/components/CardContent'; +import { CardHeader } from '@/ui/layout/card/components/CardHeader'; +import { IconKey } from 'twenty-ui'; + +const StyledHeader = styled(CardHeader)` + align-items: center; + display: flex; + height: ${({ theme }) => theme.spacing(6)}; +`; + +const StyledBody = styled(CardContent)` + display: flex; + justify-content: center; +`; + +export const SettingsSSOIdentitiesProvidersListEmptyStateCard = () => { + return ( + + {'No SSO Identity Providers Configured'} + + + {isXMLMetadataValid() && ( + + )} + + +
+ + + + + + + + + + + +
+ + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx new file mode 100644 index 000000000000..7e4b3b22d651 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx @@ -0,0 +1,62 @@ +import { IconLink } from 'twenty-ui'; +import { SettingsOptionCardContent } from '@/settings/components/SettingsOptionCardContent'; +import { Card } from '@/ui/layout/card/components/Card'; +import styled from '@emotion/styled'; +import { Toggle } from '@/ui/input/components/Toggle'; +import { useUpdateWorkspaceMutation } from '~/generated/graphql'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useRecoilState } from 'recoil'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; + +const StyledToggle = styled(Toggle)` + margin-left: auto; +`; + +export const SettingsSecurityOptionsList = () => { + const { enqueueSnackBar } = useSnackBar(); + + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + + const [updateWorkspace] = useUpdateWorkspaceMutation(); + + const handleChange = async (value: boolean) => { + try { + if (!currentWorkspace?.id) { + throw new Error('User is not logged in'); + } + await updateWorkspace({ + variables: { + input: { + isPublicInviteLinkEnabled: value, + }, + }, + }); + setCurrentWorkspace({ + ...currentWorkspace, + isPublicInviteLinkEnabled: value, + }); + } catch (err: any) { + enqueueSnackBar(err?.message, { + variant: SnackBarVariant.Error, + }); + } + }; + + return ( + + + handleChange(!currentWorkspace?.isPublicInviteLinkEnabled) + } + > + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecuritySSORowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecuritySSORowDropdownMenu.tsx new file mode 100644 index 000000000000..fa619ef2cd1d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecuritySSORowDropdownMenu.tsx @@ -0,0 +1,102 @@ +/* @license Enterprise */ + +import { IconArchive, IconDotsVertical, IconTrash } from 'twenty-ui'; + +import { useDeleteSSOIdentityProvider } from '@/settings/security/hooks/useDeleteSSOIdentityProvider'; +import { useUpdateSSOIdentityProvider } from '@/settings/security/hooks/useUpdateSSOIdentityProvider'; +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { UnwrapRecoilValue } from 'recoil'; +import { SsoIdentityProviderStatus } from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; + +type SettingsSecuritySSORowDropdownMenuProps = { + SSOIdp: UnwrapRecoilValue[0]; +}; + +export const SettingsSecuritySSORowDropdownMenu = ({ + SSOIdp, +}: SettingsSecuritySSORowDropdownMenuProps) => { + const dropdownId = `settings-account-row-${SSOIdp.id}`; + + const { enqueueSnackBar } = useSnackBar(); + + const { closeDropdown } = useDropdown(dropdownId); + + const { deleteSSOIdentityProvider } = useDeleteSSOIdentityProvider(); + const { updateSSOIdentityProvider } = useUpdateSSOIdentityProvider(); + + const handleDeleteSSOIdentityProvider = async ( + identityProviderId: string, + ) => { + const result = await deleteSSOIdentityProvider({ + identityProviderId, + }); + if (isDefined(result.errors)) { + enqueueSnackBar('Error deleting SSO Identity Provider', { + variant: SnackBarVariant.Error, + duration: 2000, + }); + } + }; + + const toggleSSOIdentityProviderStatus = async ( + identityProviderId: string, + ) => { + const result = await updateSSOIdentityProvider({ + id: identityProviderId, + status: + SSOIdp.status === 'Active' + ? SsoIdentityProviderStatus.Inactive + : SsoIdentityProviderStatus.Active, + }); + if (isDefined(result.errors)) { + enqueueSnackBar('Error editing SSO Identity Provider', { + variant: SnackBarVariant.Error, + duration: 2000, + }); + } + }; + + return ( + + } + dropdownComponents={ + + + { + toggleSSOIdentityProviderStatus(SSOIdp.id); + closeDropdown(); + }} + /> + { + handleDeleteSSOIdentityProvider(SSOIdp.id); + closeDropdown(); + }} + /> + + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/createOIDCSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createOIDCSSOIdentityProvider.ts new file mode 100644 index 000000000000..e0cd4b6dd323 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createOIDCSSOIdentityProvider.ts @@ -0,0 +1,15 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const CREATE_OIDC_SSO_IDENTITY_PROVIDER = gql` + mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) { + createOIDCIdentityProvider(input: $input) { + id + type + issuer + name + status + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/createSAMLSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createSAMLSSOIdentityProvider.ts new file mode 100644 index 000000000000..3729b4f504a9 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createSAMLSSOIdentityProvider.ts @@ -0,0 +1,15 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const CREATE_SAML_SSO_IDENTITY_PROVIDER = gql` + mutation CreateSAMLIdentityProvider($input: SetupSAMLSsoInput!) { + createSAMLIdentityProvider(input: $input) { + id + type + issuer + name + status + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteSSOIdentityProvider.ts new file mode 100644 index 000000000000..d9153d33d909 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteSSOIdentityProvider.ts @@ -0,0 +1,11 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const DELETE_SSO_IDENTITY_PROVIDER = gql` + mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) { + deleteSSOIdentityProvider(input: $input) { + identityProviderId + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/editSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/editSSOIdentityProvider.ts new file mode 100644 index 000000000000..78a83a3b53fa --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/editSSOIdentityProvider.ts @@ -0,0 +1,15 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const EDIT_SSO_IDENTITY_PROVIDER = gql` + mutation EditSSOIdentityProvider($input: EditSsoInput!) { + editSSOIdentityProvider(input: $input) { + id + type + issuer + name + status + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceSSOIdentitiesProviders.ts b/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceSSOIdentitiesProviders.ts new file mode 100644 index 000000000000..0fdd9701e722 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceSSOIdentitiesProviders.ts @@ -0,0 +1,15 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const LIST_WORKSPACE_SSO_IDENTITY_PROVIDERS = gql` + query ListSSOIdentityProvidersByWorkspaceId { + listSSOIdentityProvidersByWorkspaceId { + type + id + name + issuer + status + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useCreateSSOIdentityProvider.test.tsx b/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useCreateSSOIdentityProvider.test.tsx new file mode 100644 index 000000000000..50b71e727b2b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useCreateSSOIdentityProvider.test.tsx @@ -0,0 +1,94 @@ +/* @license Enterprise */ + +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { RecoilRoot } from 'recoil'; + +import { useCreateSSOIdentityProvider } from '@/settings/security/hooks/useCreateSSOIdentityProvider'; + +const mutationOIDCCallSpy = jest.fn(); +const mutationSAMLCallSpy = jest.fn(); + +jest.mock('~/generated/graphql', () => ({ + useCreateOidcIdentityProviderMutation: () => [mutationOIDCCallSpy], + useCreateSamlIdentityProviderMutation: () => [mutationSAMLCallSpy], +})); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useCreateSSOIdentityProvider', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('create OIDC sso identity provider', async () => { + const OIDCParams = { + type: 'OIDC' as const, + name: 'test', + clientID: 'test', + clientSecret: 'test', + issuer: 'test', + }; + renderHook( + () => { + const { createSSOIdentityProvider } = useCreateSSOIdentityProvider(); + createSSOIdentityProvider(OIDCParams); + }, + { wrapper: Wrapper }, + ); + + // eslint-disable-next-line unused-imports/no-unused-vars + const { type, ...input } = OIDCParams; + expect(mutationOIDCCallSpy).toHaveBeenCalledWith({ + onCompleted: expect.any(Function), + variables: { + input, + }, + }); + }); + it('create SAML sso identity provider', async () => { + const SAMLParams = { + type: 'SAML' as const, + name: 'test', + metadata: 'test', + certificate: 'test', + id: 'test', + issuer: 'test', + ssoURL: 'test', + }; + renderHook( + () => { + const { createSSOIdentityProvider } = useCreateSSOIdentityProvider(); + createSSOIdentityProvider(SAMLParams); + }, + { wrapper: Wrapper }, + ); + + // eslint-disable-next-line unused-imports/no-unused-vars + const { type, ...input } = SAMLParams; + expect(mutationOIDCCallSpy).not.toHaveBeenCalled(); + expect(mutationSAMLCallSpy).toHaveBeenCalledWith({ + onCompleted: expect.any(Function), + variables: { + input, + }, + }); + }); + it('throw error if provider is not SAML or OIDC', async () => { + const OTHERParams = { + type: 'OTHER' as const, + }; + renderHook( + async () => { + const { createSSOIdentityProvider } = useCreateSSOIdentityProvider(); + await expect( + // @ts-expect-error - It's expected to throw an error + createSSOIdentityProvider(OTHERParams), + ).rejects.toThrowError(); + }, + { wrapper: Wrapper }, + ); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useDeleteSSOIdentityProvider.test.tsx b/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useDeleteSSOIdentityProvider.test.tsx new file mode 100644 index 000000000000..48b5e101918c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useDeleteSSOIdentityProvider.test.tsx @@ -0,0 +1,40 @@ +/* @license Enterprise */ + +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { RecoilRoot } from 'recoil'; + +import { useDeleteSSOIdentityProvider } from '@/settings/security/hooks/useDeleteSSOIdentityProvider'; + +const mutationDeleteSSOIDPCallSpy = jest.fn(); + +jest.mock('~/generated/graphql', () => ({ + useDeleteSsoIdentityProviderMutation: () => [mutationDeleteSSOIDPCallSpy], +})); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useDeleteSsoIdentityProvider', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('delete SSO identity provider', async () => { + renderHook( + () => { + const { deleteSSOIdentityProvider } = useDeleteSSOIdentityProvider(); + deleteSSOIdentityProvider({ identityProviderId: 'test' }); + }, + { wrapper: Wrapper }, + ); + + expect(mutationDeleteSSOIDPCallSpy).toHaveBeenCalledWith({ + onCompleted: expect.any(Function), + variables: { + input: { identityProviderId: 'test' }, + }, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useEditSSOIdentityProvider.test.tsx b/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useEditSSOIdentityProvider.test.tsx new file mode 100644 index 000000000000..f253f10cb432 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useEditSSOIdentityProvider.test.tsx @@ -0,0 +1,49 @@ +/* @license Enterprise */ + +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { RecoilRoot } from 'recoil'; + +import { useUpdateSSOIdentityProvider } from '@/settings/security/hooks/useUpdateSSOIdentityProvider'; +import { SsoIdentityProviderStatus } from '~/generated/graphql'; + +const mutationEditSSOIDPCallSpy = jest.fn(); + +jest.mock('~/generated/graphql', () => { + const actual = jest.requireActual('~/generated/graphql'); + return { + useEditSsoIdentityProviderMutation: () => [mutationEditSSOIDPCallSpy], + SsoIdentityProviderStatus: actual.SsoIdentityProviderStatus, + }; +}); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useEditSsoIdentityProvider', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Deactivate SSO identity provider', async () => { + const params = { + id: 'test', + status: SsoIdentityProviderStatus.Inactive, + }; + renderHook( + () => { + const { updateSSOIdentityProvider } = useUpdateSSOIdentityProvider(); + updateSSOIdentityProvider(params); + }, + { wrapper: Wrapper }, + ); + + expect(mutationEditSSOIDPCallSpy).toHaveBeenCalledWith({ + onCompleted: expect.any(Function), + variables: { + input: params, + }, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/security/hooks/useCreateSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/hooks/useCreateSSOIdentityProvider.ts new file mode 100644 index 000000000000..b7dd56f1b13e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/useCreateSSOIdentityProvider.ts @@ -0,0 +1,63 @@ +/* @license Enterprise */ + +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; +import { useSetRecoilState } from 'recoil'; +import { + CreateOidcIdentityProviderMutationVariables, + CreateSamlIdentityProviderMutationVariables, + useCreateOidcIdentityProviderMutation, + useCreateSamlIdentityProviderMutation, +} from '~/generated/graphql'; + +export const useCreateSSOIdentityProvider = () => { + const [createOidcIdentityProviderMutation] = + useCreateOidcIdentityProviderMutation(); + const [createSamlIdentityProviderMutation] = + useCreateSamlIdentityProviderMutation(); + + const setSSOIdentitiesProviders = useSetRecoilState( + SSOIdentitiesProvidersState, + ); + + const createSSOIdentityProvider = async ( + input: + | ({ + type: 'OIDC'; + } & CreateOidcIdentityProviderMutationVariables['input']) + | ({ + type: 'SAML'; + } & CreateSamlIdentityProviderMutationVariables['input']), + ) => { + if (input.type === 'OIDC') { + // eslint-disable-next-line unused-imports/no-unused-vars + const { type, ...params } = input; + return await createOidcIdentityProviderMutation({ + variables: { input: params }, + onCompleted: (data) => { + setSSOIdentitiesProviders((existingProvider) => [ + ...existingProvider, + data.createOIDCIdentityProvider, + ]); + }, + }); + } else if (input.type === 'SAML') { + // eslint-disable-next-line unused-imports/no-unused-vars + const { type, ...params } = input; + return await createSamlIdentityProviderMutation({ + variables: { input: params }, + onCompleted: (data) => { + setSSOIdentitiesProviders((existingProvider) => [ + ...existingProvider, + data.createSAMLIdentityProvider, + ]); + }, + }); + } else { + throw new Error('Invalid IdpType'); + } + }; + + return { + createSSOIdentityProvider, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/security/hooks/useDeleteSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/hooks/useDeleteSSOIdentityProvider.ts new file mode 100644 index 000000000000..a140444631cb --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/useDeleteSSOIdentityProvider.ts @@ -0,0 +1,40 @@ +/* @license Enterprise */ + +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; +import { useSetRecoilState } from 'recoil'; +import { + DeleteSsoIdentityProviderMutationVariables, + useDeleteSsoIdentityProviderMutation, +} from '~/generated/graphql'; + +export const useDeleteSSOIdentityProvider = () => { + const [deleteSsoIdentityProviderMutation] = + useDeleteSsoIdentityProviderMutation(); + + const setSSOIdentitiesProviders = useSetRecoilState( + SSOIdentitiesProvidersState, + ); + + const deleteSSOIdentityProvider = async ({ + identityProviderId, + }: DeleteSsoIdentityProviderMutationVariables['input']) => { + return await deleteSsoIdentityProviderMutation({ + variables: { + input: { identityProviderId }, + }, + onCompleted: (data) => { + setSSOIdentitiesProviders((SSOIdentitiesProviders) => + SSOIdentitiesProviders.filter( + (identityProvider) => + identityProvider.id !== + data.deleteSSOIdentityProvider.identityProviderId, + ), + ); + }, + }); + }; + + return { + deleteSSOIdentityProvider, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/security/hooks/useUpdateSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/hooks/useUpdateSSOIdentityProvider.ts new file mode 100644 index 000000000000..07baaaae6a7a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/useUpdateSSOIdentityProvider.ts @@ -0,0 +1,40 @@ +/* @license Enterprise */ + +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; +import { useSetRecoilState } from 'recoil'; +import { + EditSsoIdentityProviderMutationVariables, + useEditSsoIdentityProviderMutation, +} from '~/generated/graphql'; + +export const useUpdateSSOIdentityProvider = () => { + const [editSsoIdentityProviderMutation] = + useEditSsoIdentityProviderMutation(); + + const setSSOIdentitiesProviders = useSetRecoilState( + SSOIdentitiesProvidersState, + ); + + const updateSSOIdentityProvider = async ( + payload: EditSsoIdentityProviderMutationVariables['input'], + ) => { + return await editSsoIdentityProviderMutation({ + variables: { + input: payload, + }, + onCompleted: (data) => { + setSSOIdentitiesProviders((SSOIdentitiesProviders) => + SSOIdentitiesProviders.map((identityProvider) => + identityProvider.id === data.editSSOIdentityProvider.id + ? data.editSSOIdentityProvider + : identityProvider, + ), + ); + }, + }); + }; + + return { + updateSSOIdentityProvider, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/security/states/SSOIdentitiesProviders.state.ts b/packages/twenty-front/src/modules/settings/security/states/SSOIdentitiesProviders.state.ts new file mode 100644 index 000000000000..76dc7cfdfbde --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/states/SSOIdentitiesProviders.state.ts @@ -0,0 +1,11 @@ +/* @license Enterprise */ + +import { SSOIdentityProvider } from '@/settings/security/types/SSOIdentityProvider'; +import { createState } from 'twenty-ui'; + +export const SSOIdentitiesProvidersState = createState< + Omit[] +>({ + key: 'SSOIdentitiesProvidersState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts new file mode 100644 index 000000000000..fe7226c9d2c4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts @@ -0,0 +1,18 @@ +/* @license Enterprise */ + +import { SSOIdentitiesProvidersParamsSchema } from '@/settings/security/validation-schemas/SSOIdentityProviderSchema'; +import { z } from 'zod'; +import { IdpType, SsoIdentityProviderStatus } from '~/generated/graphql'; + +export type SSOIdentityProvider = { + __typename: 'SSOIdentityProvider'; + id: string; + type: IdpType; + issuer: string; + name?: string | null; + status: SsoIdentityProviderStatus; +}; + +export type SettingSecurityNewSSOIdentityFormValues = z.infer< + typeof SSOIdentitiesProvidersParamsSchema +>; diff --git a/packages/twenty-front/src/modules/settings/security/utils/__tests__/parseSAMLMetadataFromXMLFile.test.ts b/packages/twenty-front/src/modules/settings/security/utils/__tests__/parseSAMLMetadataFromXMLFile.test.ts new file mode 100644 index 000000000000..e1d79168e823 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/utils/__tests__/parseSAMLMetadataFromXMLFile.test.ts @@ -0,0 +1,39 @@ +/* @license Enterprise */ + +import { parseSAMLMetadataFromXMLFile } from '../parseSAMLMetadataFromXMLFile'; + +describe('parseSAMLMetadataFromXMLFile', () => { + it('should parse SAML metadata from XML file', () => { + const xmlString = ` + + + + + test + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + +`; + const result = parseSAMLMetadataFromXMLFile(xmlString); + expect(result).toEqual({ + success: true, + data: { + entityID: 'https://test.com', + ssoUrl: 'https://test.com', + certificate: 'test', + }, + }); + }); + it('should return error if XML is invalid', () => { + const xmlString = 'invalid xml'; + const result = parseSAMLMetadataFromXMLFile(xmlString); + expect(result).toEqual({ + success: false, + error: new Error('Error parsing XML'), + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/security/utils/getColorBySSOIdentityProviderStatus.ts b/packages/twenty-front/src/modules/settings/security/utils/getColorBySSOIdentityProviderStatus.ts new file mode 100644 index 000000000000..94fd86f95dd0 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/utils/getColorBySSOIdentityProviderStatus.ts @@ -0,0 +1,13 @@ +/* @license Enterprise */ + +import { ThemeColor } from 'twenty-ui'; +import { SsoIdentityProviderStatus } from '~/generated/graphql'; + +export const getColorBySSOIdentityProviderStatus: Record< + SsoIdentityProviderStatus, + ThemeColor +> = { + Active: 'green', + Inactive: 'gray', + Error: 'red', +}; diff --git a/packages/twenty-front/src/modules/settings/security/utils/guessSSOIdentityProviderIconByUrl.ts b/packages/twenty-front/src/modules/settings/security/utils/guessSSOIdentityProviderIconByUrl.ts new file mode 100644 index 000000000000..f8582577f999 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/utils/guessSSOIdentityProviderIconByUrl.ts @@ -0,0 +1,13 @@ +/* @license Enterprise */ + +import { IconComponent, IconGoogle, IconKey } from 'twenty-ui'; + +export const guessSSOIdentityProviderIconByUrl = ( + url: string, +): IconComponent => { + if (url.includes('google')) { + return IconGoogle; + } + + return IconKey; +}; diff --git a/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts b/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts new file mode 100644 index 000000000000..2e4fdf294b2d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts @@ -0,0 +1,59 @@ +/* @license Enterprise */ + +import { z } from 'zod'; + +const validator = z.object({ + entityID: z.string().url(), + ssoUrl: z.string().url(), + certificate: z.string().min(1), +}); + +export const parseSAMLMetadataFromXMLFile = ( + xmlString: string, +): + | { success: true; data: z.infer } + | { success: false; error: unknown } => { + try { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xmlString, 'application/xml'); + + if (xmlDoc.getElementsByTagName('parsererror').length > 0) { + throw new Error('Error parsing XML'); + } + + const entityDescriptor = xmlDoc.getElementsByTagName( + 'md:EntityDescriptor', + )?.[0]; + const idpSSODescriptor = xmlDoc.getElementsByTagName( + 'md:IDPSSODescriptor', + )?.[0]; + const keyDescriptor = xmlDoc.getElementsByTagName('md:KeyDescriptor')[0]; + const keyInfo = keyDescriptor.getElementsByTagName('ds:KeyInfo')[0]; + const x509Data = keyInfo.getElementsByTagName('ds:X509Data')[0]; + const x509Certificate = x509Data + .getElementsByTagName('ds:X509Certificate')?.[0] + .textContent?.trim(); + + const singleSignOnServices = Array.from( + idpSSODescriptor.getElementsByTagName('md:SingleSignOnService'), + ).map((service) => ({ + Binding: service.getAttribute('Binding'), + Location: service.getAttribute('Location'), + })); + + const result = { + ssoUrl: singleSignOnServices.find((singleSignOnService) => { + return ( + singleSignOnService.Binding === + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' + ); + })?.Location, + certificate: x509Certificate, + entityID: entityDescriptor?.getAttribute('entityID'), + }; + + return { success: true, data: validator.parse(result) }; + } catch (error) { + return { success: false, error }; + } +}; diff --git a/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts b/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts new file mode 100644 index 000000000000..a5358e948b86 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts @@ -0,0 +1,25 @@ +/* @license Enterprise */ + +import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; +import { IdpType } from '~/generated/graphql'; + +export const sSOIdentityProviderDefaultValues: Record< + IdpType, + () => SettingSecurityNewSSOIdentityFormValues +> = { + SAML: () => ({ + type: 'SAML', + ssoURL: '', + name: '', + id: crypto.randomUUID(), + certificate: '', + issuer: '', + }), + OIDC: () => ({ + type: 'OIDC', + name: '', + clientID: '', + clientSecret: '', + issuer: '', + }), +}; diff --git a/packages/twenty-front/src/modules/settings/security/validation-schemas/SSOIdentityProviderSchema.ts b/packages/twenty-front/src/modules/settings/security/validation-schemas/SSOIdentityProviderSchema.ts new file mode 100644 index 000000000000..adfd8680f590 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/validation-schemas/SSOIdentityProviderSchema.ts @@ -0,0 +1,34 @@ +/* @license Enterprise */ + +import { z } from 'zod'; + +export const SSOIdentitiesProvidersOIDCParamsSchema = z + .object({ + type: z.literal('OIDC'), + clientID: z.string().optional(), + clientSecret: z.string().optional(), + }) + .required(); + +export const SSOIdentitiesProvidersSAMLParamsSchema = z + .object({ + type: z.literal('SAML'), + id: z.string().optional(), + ssoURL: z.string().url().optional(), + certificate: z.string().optional(), + }) + .required(); + +export const SSOIdentitiesProvidersParamsSchema = z + .discriminatedUnion('type', [ + SSOIdentitiesProvidersOIDCParamsSchema, + SSOIdentitiesProvidersSAMLParamsSchema, + ]) + .and( + z + .object({ + name: z.string().min(1), + issuer: z.string().url().optional(), + }) + .required(), + ); diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index 96efe89cdb7a..2d7b9ebdcbc2 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -30,6 +30,9 @@ export enum SettingsPath { IntegrationDatabaseConnection = 'integrations/:databaseKey/:connectionId', IntegrationEditDatabaseConnection = 'integrations/:databaseKey/:connectionId/edit', IntegrationNewDatabaseConnection = 'integrations/:databaseKey/new', + Security = 'security', + NewSSOIdentityProvider = 'security/sso/new', + EditSSOIdentityProvider = 'security/sso/:identityProviderId', DevelopersNewWebhook = 'webhooks/new', DevelopersNewWebhookDetail = 'webhooks/:webhookId', Releases = 'releases', diff --git a/packages/twenty-front/src/modules/ui/input/button/components/MainButton.tsx b/packages/twenty-front/src/modules/ui/input/button/components/MainButton.tsx index 333eaeb2fb34..cb9dbd27191e 100644 --- a/packages/twenty-front/src/modules/ui/input/button/components/MainButton.tsx +++ b/packages/twenty-front/src/modules/ui/input/button/components/MainButton.tsx @@ -77,6 +77,7 @@ const StyledButton = styled.button< justify-content: center; outline: none; padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)}; + max-height: ${({ theme }) => theme.spacing(8)}; width: ${({ fullWidth, width }) => fullWidth ? '100%' : width ? `${width}px` : 'auto'}; ${({ theme, variant, disabled }) => { diff --git a/packages/twenty-front/src/modules/ui/input/components/Toggle.tsx b/packages/twenty-front/src/modules/ui/input/components/Toggle.tsx index cd45fdca1fba..f6c28cc2f571 100644 --- a/packages/twenty-front/src/modules/ui/input/components/Toggle.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/Toggle.tsx @@ -39,7 +39,7 @@ const StyledCircle = styled(motion.span)<{ export type ToggleProps = { id?: string; value?: boolean; - onChange?: (value: boolean) => void; + onChange?: (value: boolean, e?: React.MouseEvent) => void; color?: string; toggleSize?: ToggleSize; className?: string; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts index 7a04238d1e53..dfdd4082bbfa 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts @@ -6,11 +6,24 @@ import { AppPath } from '@/types/AppPath'; import { useGenerateJwtMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; import { sleep } from '~/utils/sleep'; +import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; +import { useAuth } from '@/auth/hooks/useAuth'; export const useWorkspaceSwitching = () => { const setTokenPair = useSetRecoilState(tokenPairState); const [generateJWT] = useGenerateJwtMutation(); + const { redirectToSSOLoginPage } = useSSO(); const currentWorkspace = useRecoilValue(currentWorkspaceState); + const setAvailableWorkspacesForSSOState = useSetRecoilState( + availableSSOIdentityProvidersState, + ); + const setSignInUpStep = useSetRecoilState(signInUpStepState); + const { signOut } = useAuth(); const switchWorkspace = async (workspaceId: string) => { if (currentWorkspace?.id === workspaceId) return; @@ -28,10 +41,34 @@ export const useWorkspaceSwitching = () => { throw new Error('could not create token'); } - const { tokens } = jwt.data.generateJWT; - setTokenPair(tokens); - await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly. - window.location.href = AppPath.Index; + if ( + jwt.data.generateJWT.reason === 'WORKSPACE_USE_SSO_AUTH' && + 'availableSSOIDPs' in jwt.data.generateJWT + ) { + if (jwt.data.generateJWT.availableSSOIDPs.length === 1) { + redirectToSSOLoginPage(jwt.data.generateJWT.availableSSOIDPs[0].id); + } + + if (jwt.data.generateJWT.availableSSOIDPs.length > 1) { + await signOut(); + setAvailableWorkspacesForSSOState( + jwt.data.generateJWT.availableSSOIDPs, + ); + setSignInUpStep(SignInUpStep.SSOWorkspaceSelection); + } + + return; + } + + if ( + jwt.data.generateJWT.reason !== 'WORKSPACE_USE_SSO_AUTH' && + 'authTokens' in jwt.data.generateJWT + ) { + const { tokens } = jwt.data.generateJWT.authTokens; + setTokenPair(tokens); + await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly. + window.location.href = AppPath.Index; + } }; return { switchWorkspace }; 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 8cdb26be8a17..5ca3faa9ac31 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -24,6 +24,7 @@ export const USER_QUERY_FRAGMENT = gql` inviteHash allowImpersonation activationStatus + isPublicInviteLinkEnabled featureFlags { id key diff --git a/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useCreateWorkspaceInvitation.test.tsx b/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useCreateWorkspaceInvitation.test.tsx new file mode 100644 index 000000000000..a14f17012d82 --- /dev/null +++ b/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useCreateWorkspaceInvitation.test.tsx @@ -0,0 +1,36 @@ +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { RecoilRoot } from 'recoil'; +import { useCreateWorkspaceInvitation } from '@/workspace-invitation/hooks/useCreateWorkspaceInvitation'; + +const mutationSendInvitationsCallSpy = jest.fn(); + +jest.mock('~/generated/graphql', () => ({ + useSendInvitationsMutation: () => [mutationSendInvitationsCallSpy], +})); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useCreateWorkspaceInvitation', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Send invitations', async () => { + const invitationParams = { emails: ['test@twenty.com'] }; + renderHook( + () => { + const { sendInvitation } = useCreateWorkspaceInvitation(); + sendInvitation(invitationParams); + }, + { wrapper: Wrapper }, + ); + + expect(mutationSendInvitationsCallSpy).toHaveBeenCalledWith({ + onCompleted: expect.any(Function), + variables: invitationParams, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useDeleteWorkspaceInvitation.test.tsx b/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useDeleteWorkspaceInvitation.test.tsx new file mode 100644 index 000000000000..5075d7225cda --- /dev/null +++ b/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useDeleteWorkspaceInvitation.test.tsx @@ -0,0 +1,38 @@ +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { RecoilRoot } from 'recoil'; +import { useDeleteWorkspaceInvitation } from '@/workspace-invitation/hooks/useDeleteWorkspaceInvitation'; + +const mutationDeleteWorspaceInvitationCallSpy = jest.fn(); + +jest.mock('~/generated/graphql', () => ({ + useDeleteWorkspaceInvitationMutation: () => [ + mutationDeleteWorspaceInvitationCallSpy, + ], +})); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useDeleteWorkspaceInvitation', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Delete Workspace Invitation', async () => { + const params = { appTokenId: 'test' }; + renderHook( + () => { + const { deleteWorkspaceInvitation } = useDeleteWorkspaceInvitation(); + deleteWorkspaceInvitation(params); + }, + { wrapper: Wrapper }, + ); + + expect(mutationDeleteWorspaceInvitationCallSpy).toHaveBeenCalledWith({ + onCompleted: expect.any(Function), + variables: params, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useResendWorkspaceInvitation.test.tsx b/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useResendWorkspaceInvitation.test.tsx new file mode 100644 index 000000000000..97456ca39c1d --- /dev/null +++ b/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useResendWorkspaceInvitation.test.tsx @@ -0,0 +1,38 @@ +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { RecoilRoot } from 'recoil'; +import { useResendWorkspaceInvitation } from '@/workspace-invitation/hooks/useResendWorkspaceInvitation'; + +const mutationResendWorspaceInvitationCallSpy = jest.fn(); + +jest.mock('~/generated/graphql', () => ({ + useResendWorkspaceInvitationMutation: () => [ + mutationResendWorspaceInvitationCallSpy, + ], +})); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useResendWorkspaceInvitation', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Resend Workspace Invitation', async () => { + const params = { appTokenId: 'test' }; + renderHook( + () => { + const { resendInvitation } = useResendWorkspaceInvitation(); + resendInvitation(params); + }, + { wrapper: Wrapper }, + ); + + expect(mutationResendWorspaceInvitationCallSpy).toHaveBeenCalledWith({ + onCompleted: expect.any(Function), + variables: params, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workspace-invitation/hooks/useCreateWorkspaceInvitation.ts b/packages/twenty-front/src/modules/workspace-invitation/hooks/useCreateWorkspaceInvitation.ts index 6894f9b700e4..5ee4a97fb7c2 100644 --- a/packages/twenty-front/src/modules/workspace-invitation/hooks/useCreateWorkspaceInvitation.ts +++ b/packages/twenty-front/src/modules/workspace-invitation/hooks/useCreateWorkspaceInvitation.ts @@ -1,6 +1,8 @@ import { useSetRecoilState } from 'recoil'; -import { useSendInvitationsMutation } from '~/generated/graphql'; -import { SendInvitationsMutationVariables } from '../../../generated/graphql'; +import { + useSendInvitationsMutation, + SendInvitationsMutationVariables, +} from '~/generated/graphql'; import { workspaceInvitationsState } from '../states/workspaceInvitationsStates'; export const useCreateWorkspaceInvitation = () => { diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts index f0346d505548..5573956fddbe 100644 --- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts +++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts @@ -13,5 +13,6 @@ export type FeatureFlagKey = | 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED' | 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED' | 'IS_ANALYTICS_V2_ENABLED' + | 'IS_SSO_ENABLED' | 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_ARRAY_AND_JSON_FILTER_ENABLED'; diff --git a/packages/twenty-front/src/pages/auth/Invite.tsx b/packages/twenty-front/src/pages/auth/Invite.tsx index 7260a6b59041..f14b7ea751ef 100644 --- a/packages/twenty-front/src/pages/auth/Invite.tsx +++ b/packages/twenty-front/src/pages/auth/Invite.tsx @@ -91,25 +91,7 @@ export const Invite = () => { fullWidth /> - - By using Twenty, you agree to the{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - - . - + ) : ( diff --git a/packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx b/packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx new file mode 100644 index 000000000000..6d3e604a5893 --- /dev/null +++ b/packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx @@ -0,0 +1,69 @@ +/* @license Enterprise */ + +import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; +import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; +import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; +import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; +import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl'; +import { MainButton } from '@/ui/input/button/components/MainButton'; +import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; + +const StyledContentContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(8)}; + margin-top: ${({ theme }) => theme.spacing(4)}; +`; + +const StyledTitle = styled.h2` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + margin: 0; +`; + +export const SSOWorkspaceSelection = () => { + const availableSSOIdentityProviders = useRecoilValue( + availableSSOIdentityProvidersState, + ); + + const { redirectToSSOLoginPage } = useSSO(); + + const availableWorkspacesForSSOGroupByWorkspace = + availableSSOIdentityProviders.reduce( + (acc, idp) => { + acc[idp.workspace.id] = [...(acc[idp.workspace.id] ?? []), idp]; + return acc; + }, + {} as Record, + ); + + return ( + <> + + {Object.values(availableWorkspacesForSSOGroupByWorkspace).map( + (idps) => ( + <> + + {idps[0].workspace.displayName ?? DEFAULT_WORKSPACE_NAME} + + + {idps.map((idp) => ( + <> + redirectToSSOLoginPage(idp.id)} + Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)} + fullWidth + /> + + + ))} + + ), + )} + + + + ); +}; diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 719e665baf7c..d9eefac9ed39 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -4,15 +4,14 @@ import { useRecoilValue } from 'recoil'; import { Logo } from '@/auth/components/Logo'; import { Title } from '@/auth/components/Title'; import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; -import { - SignInUpMode, - SignInUpStep, - useSignInUp, -} from '@/auth/sign-in-up/hooks/useSignInUp'; +import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn'; import { isDefined } from '~/utils/isDefined'; +import { SignInUpStep } from '@/auth/states/signInUpStepState'; +import { IconLockCustom } from '@ui/display/icon/components/IconLock'; +import { SSOWorkspaceSelection } from './SSOWorkspaceSelection'; export const SignInUp = () => { const { form } = useSignInUpForm(); @@ -27,6 +26,9 @@ export const SignInUp = () => { ) { return 'Welcome to Twenty'; } + if (signInUpStep === SignInUpStep.SSOWorkspaceSelection) { + return 'Choose SSO connection'; + } return signInUpMode === SignInUpMode.SignIn ? 'Sign in to Twenty' : 'Sign up to Twenty'; @@ -39,10 +41,18 @@ export const SignInUp = () => { return ( <> - + {signInUpStep === SignInUpStep.SSOWorkspaceSelection ? ( + + ) : ( + + )} {title} - + {signInUpStep === SignInUpStep.SSOWorkspaceSelection ? ( + + ) : ( + + )} ); }; diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx index 033f103a9ff9..6d60faf1bb24 100644 --- a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx @@ -148,17 +148,18 @@ export const SettingsWorkspaceMembers = () => { ]} > - {currentWorkspace?.inviteHash && ( -
- - -
- )} + {currentWorkspace?.inviteHash && + currentWorkspace?.isPublicInviteLinkEnabled && ( +
+ + +
+ )}
{ + return ( + } + links={[ + { + children: 'Workspace', + href: getSettingsPagePath(SettingsPath.Workspace), + }, + { children: 'Security' }, + ]} + > + +
+ + +
+
+ + +
+
+
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx new file mode 100644 index 000000000000..2f7cc0a079a8 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx @@ -0,0 +1,86 @@ +/* @license Enterprise */ + +import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; +import SettingsSSOIdentitiesProvidersForm from '@/settings/security/components/SettingsSSOIdentitiesProvidersForm'; +import { useCreateSSOIdentityProvider } from '@/settings/security/hooks/useCreateSSOIdentityProvider'; +import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; +import { sSOIdentityProviderDefaultValues } from '@/settings/security/utils/sSOIdentityProviderDefaultValues'; +import { SSOIdentitiesProvidersParamsSchema } from '@/settings/security/validation-schemas/SSOIdentityProviderSchema'; +import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; +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 { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useEffect } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; + +export const SettingsSecuritySSOIdentifyProvider = () => { + const navigate = useNavigate(); + + const { enqueueSnackBar } = useSnackBar(); + const { createSSOIdentityProvider } = useCreateSSOIdentityProvider(); + + const formConfig = useForm({ + mode: 'onChange', + resolver: zodResolver(SSOIdentitiesProvidersParamsSchema), + defaultValues: Object.values(sSOIdentityProviderDefaultValues).reduce( + (acc, fn) => ({ ...acc, ...fn() }), + {}, + ), + }); + + const selectedType = formConfig.watch('type'); + + useEffect( + () => + formConfig.reset({ + ...sSOIdentityProviderDefaultValues[selectedType](), + name: formConfig.getValues('name'), + }), + [formConfig, selectedType], + ); + + const handleSave = async () => { + try { + await createSSOIdentityProvider(formConfig.getValues()); + navigate(getSettingsPagePath(SettingsPath.Security)); + } catch (error) { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + } + }; + + return ( + navigate(getSettingsPagePath(SettingsPath.Security))} + onSave={handleSave} + /> + } + links={[ + { + children: 'Workspace', + href: getSettingsPagePath(SettingsPath.Workspace), + }, + { + children: 'Security', + href: getSettingsPagePath(SettingsPath.Security), + }, + { children: 'New' }, + ]} + > + + + + + ); +}; diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 1ed65869a7ca..6e8ade28b55f 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -6,7 +6,9 @@ export const mockedClientConfig: ClientConfig = { signUpDisabled: false, chromeExtensionId: 'MOCKED_EXTENSION_ID', debugMode: false, + analyticsEnabled: true, authProviders: { + sso: false, google: true, password: true, magicLink: false, diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index cc483bd1e570..b8af2e37a0cb 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -40,6 +40,7 @@ export const mockDefaultWorkspace: Workspace = { domainName: 'twenty.com', inviteHash: 'twenty.com-invite-hash', logo: workspaceLogoUrl, + isPublicInviteLinkEnabled: true, allowImpersonation: true, activationStatus: WorkspaceActivationStatus.Active, featureFlags: [ diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 0b520154c4eb..0ea9a607a946 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -37,6 +37,7 @@ REDIS_URL=redis://localhost:6379 # AUTH_GOOGLE_CLIENT_SECRET=replace_me_with_google_client_secret # AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/redirect # AUTH_GOOGLE_APIS_CALLBACK_URL=http://localhost:3000/auth/google-apis/get-access-token +# AUTH_SSO_ENABLED=false # SERVERLESS_TYPE=local # STORAGE_TYPE=local # STORAGE_LOCAL_PATH=.local-storage @@ -74,3 +75,5 @@ REDIS_URL=redis://localhost:6379 # MUTATION_MAXIMUM_AFFECTED_RECORDS=100 # CHROME_EXTENSION_ID=bggmipldbceihilonnbpgoeclgbkblkp # PG_SSL_ALLOW_SELF_SIGNED=true +# SESSION_STORE_SECRET=replace_me_with_a_random_string_session +# ENTERPRISE_KEY=replace_me_with_a_valid_enterprise_key diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index c5778aa6493d..82fca3b04d00 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -23,12 +23,15 @@ "@nestjs/cache-manager": "^2.2.1", "@nestjs/devtools-integration": "^0.1.6", "@nestjs/graphql": "patch:@nestjs/graphql@12.1.1#./patches/@nestjs+graphql+12.1.1.patch", + "@node-saml/passport-saml": "^5.0.0", "@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch", "@revertdotdev/revert-react": "^0.0.21", "@sentry/nestjs": "^8.30.0", "cache-manager": "^5.4.0", "cache-manager-redis-yet": "^4.1.2", "class-validator": "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch", + "connect-redis": "^7.1.1", + "express-session": "^1.18.1", "graphql-middleware": "^6.1.35", "handlebars": "^4.7.8", "jsdom": "~22.1.0", @@ -42,8 +45,10 @@ "lodash.uniqby": "^4.7.0", "monaco-editor": "^0.51.0", "monaco-editor-auto-typings": "^0.4.5", + "openid-client": "^5.7.0", "passport": "^0.7.0", "psl": "^1.9.0", + "redis": "^4.7.0", "ts-morph": "^24.0.0", "tsconfig-paths": "^4.2.0", "typeorm": "patch:typeorm@0.3.20#./patches/typeorm+0.3.20.patch", @@ -53,6 +58,7 @@ "devDependencies": { "@nestjs/cli": "10.3.0", "@nx/js": "18.3.3", + "@types/express-session": "^1.18.0", "@types/lodash.differencewith": "^4.5.9", "@types/lodash.isempty": "^4.4.7", "@types/lodash.isequal": "^4.5.8", @@ -64,6 +70,7 @@ "@types/lodash.uniq": "^4.5.9", "@types/lodash.uniqby": "^4.7.9", "@types/lodash.upperfirst": "^4.3.7", + "@types/openid-client": "^3.7.0", "@types/react": "^18.2.39", "@types/unzipper": "^0", "rimraf": "^5.0.5", diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index 97b34a8844c7..b3d984156eb8 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -60,6 +60,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IsSSOEnabled, + workspaceId: workspaceId, + value: true, + }, { key: FeatureFlagKey.IsGmailSendEmailScopeEnabled, workspaceId: workspaceId, diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1727181198403-addWorkspaceSSOIdentityProvider.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1727181198403-addWorkspaceSSOIdentityProvider.ts new file mode 100644 index 000000000000..413e9c57adc4 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/1727181198403-addWorkspaceSSOIdentityProvider.ts @@ -0,0 +1,66 @@ +/* @license Enterprise */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWorkspaceSSOIdentityProvider1727181198403 + implements MigrationInterface +{ + name = 'AddWorkspaceSSOIdentityProvider1727181198403'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "core"."idp_type_enum" AS ENUM('OIDC', 'SAML'); + `); + + await queryRunner.query(` + CREATE TABLE "core"."workspaceSSOIdentityProvider" ( + "id" uuid DEFAULT uuid_generate_v4() PRIMARY KEY, + "name" varchar NULL, + "workspaceId" uuid NOT NULL, + "createdAt" timestamptz DEFAULT now() NOT NULL, + "updatedAt" timestamptz DEFAULT now() NOT NULL, + "type" "core"."idp_type_enum" DEFAULT 'OIDC' NOT NULL, + "issuer" varchar NOT NULL, + "ssoURL" varchar NULL, + "clientID" varchar NULL, + "clientSecret" varchar NULL, + "certificate" varchar NULL, + "fingerprint" varchar NULL, + "status" varchar DEFAULT 'Active' NOT NULL + ); + `); + + await queryRunner.query(` + ALTER TABLE "core"."workspaceSSOIdentityProvider" + ADD CONSTRAINT "FK_workspaceId" + FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") + ON DELETE CASCADE; + `); + + await queryRunner.query(` + ALTER TABLE "core"."workspaceSSOIdentityProvider" ADD CONSTRAINT "CHK_OIDC" CHECK ( + ("type" = 'OIDC' AND "clientID" IS NOT NULL AND "clientSecret" IS NOT NULL) OR "type" = 'SAML' + ) + `); + await queryRunner.query(` + ALTER TABLE "core"."workspaceSSOIdentityProvider" ADD CONSTRAINT "CHK_SAML" CHECK ( + ("type" = 'SAML' AND "ssoURL" IS NOT NULL AND "certificate" IS NOT NULL) OR "type" = 'OIDC' + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "core"."workspaceSSOIdentityProvider" + DROP CONSTRAINT "FK_workspaceId"; + `); + + await queryRunner.query(` + DROP TABLE "core"."workspaceSSOIdentityProvider"; + `); + + await queryRunner.query(` + DROP TYPE "core"."idp_type_enum"; + `); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1728986317196-addIsPublicInviteLinkEnabledOnWorkspace.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1728986317196-addIsPublicInviteLinkEnabledOnWorkspace.ts new file mode 100644 index 000000000000..38177a558e7f --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/1728986317196-addIsPublicInviteLinkEnabledOnWorkspace.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIsPublicInviteLinkEnabledOnWorkspace1728986317196 + implements MigrationInterface +{ + name = 'AddIsPublicInviteLinkEnabledOnWorkspace1728986317196'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "isPublicInviteLinkEnabled" boolean NOT NULL DEFAULT true`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "isPublicInviteLinkEnabled"`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/typeorm.service.ts b/packages/twenty-server/src/database/typeorm/typeorm.service.ts index 46b546d65ee5..73466982d07b 100644 --- a/packages/twenty-server/src/database/typeorm/typeorm.service.ts +++ b/packages/twenty-server/src/database/typeorm/typeorm.service.ts @@ -13,6 +13,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; +import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; @Injectable() export class TypeORMService implements OnModuleInit, OnModuleDestroy { @@ -36,6 +37,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy { BillingSubscription, BillingSubscriptionItem, PostgresCredentials, + WorkspaceSSOIdentityProvider, ], metadataTableName: '_typeorm_generated_columns_and_materialized_views', ssl: environmentService.get('PG_SSL_ALLOW_SELF_SIGNED') 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 998fa634a757..38f17ddaa7fc 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 @@ -22,6 +22,7 @@ export enum AppTokenType { AuthorizationCode = 'AUTHORIZATION_CODE', PasswordResetToken = 'PASSWORD_RESET_TOKEN', InvitationToken = 'INVITATION_TOKEN', + OIDCCodeVerifier = 'OIDC_CODE_VERIFIER', } @Entity({ name: 'appToken', schema: 'core' }) 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 62b215f2691b..43d4d7312e4e 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 @@ -17,4 +17,6 @@ export enum AuthExceptionCode { INVALID_DATA = 'INVALID_DATA', INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', OAUTH_ACCESS_DENIED = 'OAUTH_ACCESS_DENIED', + SSO_AUTH_FAILED = 'SSO_AUTH_FAILED', + USE_SSO_AUTH = 'USE_SSO_AUTH', } diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 708472826d10..6a102e8b360c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -27,7 +27,13 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; +import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; +import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller'; +import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; +import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; +import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { AuthResolver } from './auth.resolver'; @@ -43,7 +49,14 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; WorkspaceManagerModule, TypeORMModule, TypeOrmModule.forFeature( - [Workspace, User, AppToken, FeatureFlagEntity], + [ + Workspace, + User, + AppToken, + FeatureFlagEntity, + WorkspaceSSOIdentityProvider, + KeyValuePair, + ], 'core', ), HttpModule, @@ -52,7 +65,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; WorkspaceModule, OnboardingModule, WorkspaceDataSourceModule, + WorkspaceInvitationModule, ConnectedAccountModule, + WorkspaceSSOModule, FeatureFlagModule, ], controllers: [ @@ -60,11 +75,13 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; MicrosoftAuthController, GoogleAPIsAuthController, VerifyAuthController, + SSOAuthController, ], providers: [ SignInUpService, AuthService, JwtAuthStrategy, + SamlAuthStrategy, AuthResolver, TokenService, GoogleAPIsService, 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 033210af120a..2e470589ef07 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 @@ -24,6 +24,11 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { + GenerateJWTOutput, + GenerateJWTOutputWithAuthTokens, + GenerateJWTOutputWithSSOAUTH, +} from 'src/engine/core-modules/auth/dto/generateJWT.output'; import { ChallengeInput } from './dto/challenge.input'; import { ImpersonateInput } from './dto/impersonate.input'; @@ -159,18 +164,41 @@ export class AuthResolver { return authorizedApp; } - @Mutation(() => AuthTokens) + @Mutation(() => GenerateJWTOutput) @UseGuards(WorkspaceAuthGuard, UserAuthGuard) async generateJWT( @AuthUser() user: User, @Args() args: GenerateJwtInput, - ): Promise { - const token = await this.tokenService.generateSwitchWorkspaceToken( + ): Promise { + const result = await this.tokenService.switchWorkspace( user, args.workspaceId, ); - return token; + if (result.useSSOAuth) { + return { + success: true, + reason: 'WORKSPACE_USE_SSO_AUTH', + availableSSOIDPs: result.availableSSOIdentityProviders.map( + (identityProvider) => ({ + ...identityProvider, + workspace: { + id: result.workspace.id, + displayName: result.workspace.displayName, + }, + }), + ), + }; + } + + return { + success: true, + reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH', + authTokens: await this.tokenService.generateSwitchWorkspaceToken( + user, + result.workspace, + ), + }; } @Mutation(() => AuthTokens) 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 new file mode 100644 index 000000000000..18b9dbb4d6bf --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -0,0 +1,161 @@ +/* @license Enterprise */ + +import { + Controller, + Get, + Post, + Req, + Res, + UseFilters, + UseGuards, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { generateServiceProviderMetadata } from '@node-saml/node-saml'; +import { Response } from 'express'; +import { Repository } from 'typeorm'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; +import { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.guard'; +import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.guard'; +import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard'; +import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { + IdentityProviderType, + WorkspaceSSOIdentityProvider, +} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; + +@Controller('auth') +@UseFilters(AuthRestApiExceptionFilter) +export class SSOAuthController { + constructor( + private readonly tokenService: TokenService, + private readonly authService: AuthService, + private readonly workspaceInvitationService: WorkspaceInvitationService, + private readonly environmentService: EnvironmentService, + private readonly userWorkspaceService: UserWorkspaceService, + private readonly ssoService: SSOService, + @InjectRepository(WorkspaceSSOIdentityProvider, 'core') + private readonly workspaceSSOIdentityProviderRepository: Repository, + ) {} + + @Get('saml/metadata/:identityProviderId') + @UseGuards(SSOProviderEnabledGuard) + async generateMetadata(@Req() req: any): Promise { + return generateServiceProviderMetadata({ + wantAssertionsSigned: false, + issuer: this.ssoService.buildIssuerURL({ + id: req.params.identityProviderId, + type: IdentityProviderType.SAML, + }), + callbackUrl: this.ssoService.buildCallbackUrl({ + type: IdentityProviderType.SAML, + }), + }); + } + + @Get('oidc/login/:identityProviderId') + @UseGuards(SSOProviderEnabledGuard, OIDCAuthGuard) + async oidcAuth() { + // As this method is protected by OIDC Auth guard, it will trigger OIDC SSO flow + return; + } + + @Get('saml/login/:identityProviderId') + @UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard) + async samlAuth() { + // As this method is protected by SAML Auth guard, it will trigger SAML SSO flow + return; + } + + @Get('oidc/callback') + @UseGuards(SSOProviderEnabledGuard, OIDCAuthGuard) + async oidcAuthCallback(@Req() req: any, @Res() res: Response) { + try { + const loginToken = await this.generateLoginToken(req.user); + + return res.redirect( + this.tokenService.computeRedirectURI(loginToken.token), + ); + } catch (err) { + // TODO: improve error management + res.status(403).send(err.message); + } + } + + @Post('saml/callback') + @UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard) + async samlAuthCallback(@Req() req: any, @Res() res: Response) { + try { + const loginToken = await this.generateLoginToken(req.user); + + return res.redirect( + this.tokenService.computeRedirectURI(loginToken.token), + ); + } catch (err) { + // TODO: improve error management + res.status(403).send(err.message); + res.redirect(`${this.environmentService.get('FRONT_BASE_URL')}/verify`); + } + } + + private async generateLoginToken({ + user, + identityProviderId, + }: { + identityProviderId?: string; + user: { email: string } & Record; + }) { + const identityProvider = + await this.workspaceSSOIdentityProviderRepository.findOne({ + where: { id: identityProviderId }, + relations: ['workspace'], + }); + + if (!identityProvider) { + throw new AuthException( + 'Identity provider not found', + AuthExceptionCode.INVALID_DATA, + ); + } + + const invitation = + await this.workspaceInvitationService.getOneWorkspaceInvitation( + identityProvider.workspaceId, + user.email, + ); + + if (invitation) { + await this.authService.signInUp({ + ...user, + workspacePersonalInviteToken: invitation.value, + workspaceInviteHash: identityProvider.workspace.inviteHash, + fromSSO: true, + }); + } + + const isUserExistInWorkspace = + await this.userWorkspaceService.checkUserWorkspaceExistsByEmail( + user.email, + identityProvider.workspaceId, + ); + + if (!isUserExistInWorkspace) { + throw new AuthException( + 'User not found in workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + return this.tokenService.generateLoginToken(user.email); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/generateJWT.output.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/generateJWT.output.ts new file mode 100644 index 000000000000..cc27d8c6c000 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/generateJWT.output.ts @@ -0,0 +1,43 @@ +import { Field, ObjectType, createUnionType } from '@nestjs/graphql'; + +import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity'; +import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output'; + +@ObjectType() +export class GenerateJWTOutputWithAuthTokens { + @Field(() => Boolean) + success: boolean; + + @Field(() => String) + reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH'; + + @Field(() => AuthTokens) + authTokens: AuthTokens; +} + +@ObjectType() +export class GenerateJWTOutputWithSSOAUTH { + @Field(() => Boolean) + success: boolean; + + @Field(() => String) + reason: 'WORKSPACE_USE_SSO_AUTH'; + + @Field(() => [FindAvailableSSOIDPOutput]) + availableSSOIDPs: Array; +} + +export const GenerateJWTOutput = createUnionType({ + name: 'GenerateJWT', + types: () => [GenerateJWTOutputWithAuthTokens, GenerateJWTOutputWithSSOAUTH], + resolveType(value) { + if (value.reason === 'WORKSPACE_AVAILABLE_FOR_SWITCH') { + return GenerateJWTOutputWithAuthTokens; + } + if (value.reason === 'WORKSPACE_USE_SSO_AUTH') { + return GenerateJWTOutputWithSSOAUTH; + } + + return null; + }, +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/oidc-auth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/oidc-auth.guard.ts new file mode 100644 index 000000000000..d7b8de1e8a47 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/oidc-auth.guard.ts @@ -0,0 +1,73 @@ +/* @license Enterprise */ + +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +import { Issuer } from 'openid-client'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { OIDCAuthStrategy } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; + +@Injectable() +export class OIDCAuthGuard extends AuthGuard('openidconnect') { + constructor(private readonly ssoService: SSOService) { + super(); + } + + private getIdentityProviderId(request: any): string { + if (request.params.identityProviderId) { + return request.params.identityProviderId; + } + + if ( + request.query.state && + typeof request.query.state === 'string' && + request.query.state.startsWith('{') && + request.query.state.endsWith('}') + ) { + const state = JSON.parse(request.query.state); + + return state.identityProviderId; + } + + throw new Error('Invalid OIDC identity provider params'); + } + + async canActivate(context: ExecutionContext): Promise { + try { + const request = context.switchToHttp().getRequest(); + + const identityProviderId = this.getIdentityProviderId(request); + + const identityProvider = + await this.ssoService.findSSOIdentityProviderById(identityProviderId); + + if (!identityProvider) { + throw new AuthException( + 'Identity provider not found', + AuthExceptionCode.INVALID_DATA, + ); + } + + const issuer = await Issuer.discover(identityProvider.issuer); + + new OIDCAuthStrategy( + this.ssoService.getOIDCClient(identityProvider, issuer), + identityProvider.id, + ); + + return (await super.canActivate(context)) as boolean; + } catch (err) { + if (err instanceof AuthException) { + return false; + } + + // TODO AMOREAUX: trigger sentry error + return false; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/saml-auth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/saml-auth.guard.ts new file mode 100644 index 000000000000..fba753a0727f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/saml-auth.guard.ts @@ -0,0 +1,48 @@ +/* @license Enterprise */ + +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; + +@Injectable() +export class SAMLAuthGuard extends AuthGuard('saml') { + constructor(private readonly sSOService: SSOService) { + super(); + } + + async canActivate(context: ExecutionContext) { + try { + const request = context.switchToHttp().getRequest(); + + const RelayState = + 'RelayState' in request.body ? JSON.parse(request.body.RelayState) : {}; + + request.params.identityProviderId = + request.params.identityProviderId ?? RelayState.identityProviderId; + + if (!request.params.identityProviderId) { + throw new AuthException( + 'Invalid SAML identity provider', + AuthExceptionCode.INVALID_DATA, + ); + } + + new SamlAuthStrategy(this.sSOService); + + return (await super.canActivate(context)) as boolean; + } catch (err) { + if (err instanceof AuthException) { + return false; + } + + // TODO AMOREAUX: trigger sentry error + return false; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/sso-provider-enabled.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/sso-provider-enabled.guard.ts new file mode 100644 index 000000000000..ce1d6b11a72d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/sso-provider-enabled.guard.ts @@ -0,0 +1,27 @@ +/* @license Enterprise */ + +import { CanActivate, Injectable } from '@nestjs/common'; + +import { Observable } from 'rxjs'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; + +@Injectable() +export class SSOProviderEnabledGuard implements CanActivate { + constructor(private readonly environmentService: EnvironmentService) {} + + canActivate(): boolean | Promise | Observable { + if (!this.environmentService.get('ENTERPRISE_KEY')) { + throw new AuthException( + 'Enterprise key must be defined to use SSO', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + return true; + } +} 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 bba83839a315..0092499d2b41 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 @@ -35,7 +35,6 @@ import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-u import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -43,7 +42,6 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; export class AuthService { constructor( private readonly tokenService: TokenService, - private readonly userService: UserService, private readonly signInUpService: SignInUpService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, 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 6e0e96203876..c5cb98f0c659 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 @@ -225,23 +225,45 @@ export class SignInUpService { email, }) { if (!workspacePersonalInviteToken && !workspaceInviteHash) { - throw new Error('No invite token or hash provided'); + throw new AuthException( + 'No invite token or hash provided', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); } - if (!workspacePersonalInviteToken && workspaceInviteHash) { - return ( - (await this.workspaceRepository.findOneBy({ - inviteHash: workspaceInviteHash, - })) ?? undefined + const workspace = await this.workspaceRepository.findOneBy({ + inviteHash: workspaceInviteHash, + }); + + if (!workspace) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, ); } - const appToken = await this.userWorkspaceService.validateInvitation( - workspacePersonalInviteToken, - email, - ); + if (!workspacePersonalInviteToken && !workspace.isPublicInviteLinkEnabled) { + throw new AuthException( + 'Workspace does not allow public invites', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + if (workspacePersonalInviteToken && workspace.isPublicInviteLinkEnabled) { + try { + await this.userWorkspaceService.validateInvitation( + workspacePersonalInviteToken, + email, + ); + } catch (err) { + throw new AuthException( + err.message, + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + } - return appToken?.workspace; + return workspace; } private async activateOnboardingForNewUser( diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/oidc.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/oidc.auth.strategy.ts new file mode 100644 index 000000000000..493e3972d3c7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/oidc.auth.strategy.ts @@ -0,0 +1,86 @@ +/* @license Enterprise */ + +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; + +import { + Strategy, + StrategyOptions, + StrategyVerifyCallbackReq, +} from 'openid-client'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; + +@Injectable() +export class OIDCAuthStrategy extends PassportStrategy( + Strategy, + 'openidconnect', +) { + constructor( + private client: StrategyOptions['client'], + sessionKey: string, + ) { + super({ + params: { + scope: 'openid email profile', + code_challenge_method: 'S256', + }, + client, + usePKCE: true, + passReqToCallback: true, + sessionKey, + }); + } + + async authenticate(req: any, options: any) { + return super.authenticate(req, { + ...options, + state: JSON.stringify({ + identityProviderId: req.params.identityProviderId, + }), + }); + } + + validate: StrategyVerifyCallbackReq<{ + identityProviderId: string; + user: { + email: string; + firstName?: string | null; + lastName?: string | null; + }; + }> = async (req, tokenset, done) => { + try { + const state = JSON.parse( + 'query' in req && + req.query && + typeof req.query === 'object' && + 'state' in req.query && + req.query.state && + typeof req.query.state === 'string' + ? req.query.state + : '{}', + ); + + const userinfo = await this.client.userinfo(tokenset); + + if (!userinfo || !userinfo.email) { + return done( + new AuthException('Email not found', AuthExceptionCode.INVALID_DATA), + ); + } + + const user = { + email: userinfo.email, + ...(userinfo.given_name ? { firstName: userinfo.given_name } : {}), + ...(userinfo.family_name ? { lastName: userinfo.family_name } : {}), + }; + + done(null, { user, identityProviderId: state.identityProviderId }); + } catch (err) { + done(err); + } + }; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/saml.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/saml.auth.strategy.ts new file mode 100644 index 000000000000..c1514c8f9977 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/saml.auth.strategy.ts @@ -0,0 +1,98 @@ +/* @license Enterprise */ + +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; + +import { + MultiSamlStrategy, + MultiStrategyConfig, + PassportSamlConfig, + SamlConfig, + VerifyWithRequest, +} from '@node-saml/passport-saml'; +import { AuthenticateOptions } from '@node-saml/passport-saml/lib/types'; +import { isEmail } from 'class-validator'; +import { Request } from 'express'; + +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; + +@Injectable() +export class SamlAuthStrategy extends PassportStrategy( + MultiSamlStrategy, + 'saml', +) { + constructor(private readonly sSOService: SSOService) { + super({ + getSamlOptions: (req, callback) => { + this.sSOService + .findSSOIdentityProviderById(req.params.identityProviderId) + .then((identityProvider) => { + if ( + identityProvider && + this.sSOService.isSAMLIdentityProvider(identityProvider) + ) { + const config: SamlConfig = { + entryPoint: identityProvider.ssoURL, + issuer: this.sSOService.buildIssuerURL(identityProvider), + callbackUrl: this.sSOService.buildCallbackUrl(identityProvider), + idpCert: identityProvider.certificate, + wantAssertionsSigned: false, + // TODO: Improve the feature by sign the response + wantAuthnResponseSigned: false, + signatureAlgorithm: 'sha256', + }; + + return callback(null, config); + } + + // TODO: improve error management + return callback(new Error('Invalid SAML identity provider')); + }) + .catch((err) => { + // TODO: improve error management + return callback(err); + }); + }, + passReqToCallback: true, + } as PassportSamlConfig & MultiStrategyConfig); + } + + authenticate(req: Request, options: AuthenticateOptions) { + super.authenticate(req, { + ...options, + additionalParams: { + RelayState: JSON.stringify({ + identityProviderId: req.params.identityProviderId, + }), + }, + }); + } + + validate: VerifyWithRequest = async (request, profile, done) => { + if (!profile) { + return done(new Error('Profile is must be provided')); + } + + const email = profile.email ?? profile.mail ?? profile.nameID; + + if (!isEmail(email)) { + return done(new Error('Invalid email')); + } + + const result: { + user: Record; + identityProviderId?: string; + } = { user: { email } }; + + if ( + 'RelayState' in request.body && + typeof request.body.RelayState === 'string' + ) { + const RelayState = JSON.parse(request.body.RelayState); + + result.identityProviderId = RelayState.identityProviderId; + } + + done(null, result); + }; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts index 777b1febde7f..7bc44ffd001f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts @@ -17,6 +17,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { TokenService } from './token.service'; @@ -50,6 +51,12 @@ describe('TokenService', () => { send: jest.fn(), }, }, + { + provide: SSOService, + useValue: { + send: jest.fn(), + }, + }, { provide: getRepositoryToken(User, 'core'), useValue: { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts index c740cb9a0613..d323ba83168e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts @@ -46,6 +46,7 @@ import { } from 'src/engine/core-modules/workspace/workspace.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 { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; @Injectable() export class TokenService { @@ -60,6 +61,7 @@ export class TokenService { @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, private readonly emailService: EmailService, + private readonly sSSOService: SSOService, private readonly twentyORMGlobalManager: TwentyORMGlobalManager, ) {} @@ -341,10 +343,7 @@ export class TokenService { }; } - async generateSwitchWorkspaceToken( - user: User, - workspaceId: string, - ): Promise { + async switchWorkspace(user: User, workspaceId: string) { const userExists = await this.userRepository.findBy({ id: user.id }); if (!userExists) { @@ -356,7 +355,7 @@ export class TokenService { const workspace = await this.workspaceRepository.findOne({ where: { id: workspaceId }, - relations: ['workspaceUsers'], + relations: ['workspaceUsers', 'workspaceSSOIdentityProviders'], }); if (!workspace) { @@ -377,12 +376,44 @@ export class TokenService { ); } + if (workspace.workspaceSSOIdentityProviders.length > 0) { + return { + useSSOAuth: true, + workspace, + availableSSOIdentityProviders: + await this.sSSOService.listSSOIdentityProvidersByWorkspaceId( + workspaceId, + ), + } as { + useSSOAuth: true; + workspace: Workspace; + availableSSOIdentityProviders: Awaited< + ReturnType< + typeof this.sSSOService.listSSOIdentityProvidersByWorkspaceId + > + >; + }; + } + + return { + useSSOAuth: false, + workspace, + } as { + useSSOAuth: false; + workspace: Workspace; + }; + } + + async generateSwitchWorkspaceToken( + user: User, + workspace: Workspace, + ): Promise { await this.userRepository.save({ id: user.id, defaultWorkspace: workspace, }); - const token = await this.generateAccessToken(user.id, workspaceId); + const token = await this.generateAccessToken(user.id, workspace.id); const refreshToken = await this.generateRefreshToken(user.id); return { 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 ea104687291f..42d65621e528 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 @@ -11,6 +11,7 @@ import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; +import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s TypeORMModule, DataSourceModule, EmailModule, + WorkspaceSSOModule, ], providers: [TokenService, JwtAuthStrategy], exports: [TokenService], diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index 6001a90f4ade..00e2c3cb4304 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -15,6 +15,9 @@ class AuthProviders { @Field(() => Boolean) microsoft: boolean; + + @Field(() => Boolean) + sso: boolean; } @ObjectType() diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts index f6ba1aaf4abd..9f2660876568 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts @@ -16,6 +16,7 @@ export class ClientConfigResolver { magicLink: false, password: this.environmentService.get('AUTH_PASSWORD_ENABLED'), microsoft: this.environmentService.get('AUTH_MICROSOFT_ENABLED'), + sso: this.environmentService.get('AUTH_SSO_ENABLED'), }, billing: { isBillingEnabled: this.environmentService.get('IS_BILLING_ENABLED'), diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index af651c18c5d2..00cb30716f39 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -40,6 +40,7 @@ import { WorkflowTriggerApiModule } from 'src/engine/core-modules/workflow/workf import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; +import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { ClientConfigModule } from './client-config/client-config.module'; @@ -61,6 +62,7 @@ import { FileModule } from './file/file.module'; UserModule, WorkspaceModule, WorkspaceInvitationModule, + WorkspaceSSOModule, PostgresCredentialsModule, WorkflowTriggerApiModule, WorkspaceEventEmitterModule, @@ -117,6 +119,7 @@ import { FileModule } from './file/file.module'; UserModule, WorkspaceModule, WorkspaceInvitationModule, + WorkspaceSSOModule, ], }) export class CoreEngineModule {} diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index 03b0d234e1fe..77d54025fda6 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -225,6 +225,15 @@ export class EnvironmentVariables { @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) AUTH_GOOGLE_CALLBACK_URL: string; + @CastToBoolean() + @IsOptional() + @IsBoolean() + AUTH_SSO_ENABLED = false; + + @IsString() + @IsOptional() + ENTERPRISE_KEY: string; + // Custom Code Engine @IsEnum(ServerlessDriverType) @IsOptional() @@ -443,6 +452,9 @@ export class EnvironmentVariables { @CastToPositiveNumber() CACHE_STORAGE_TTL: number = 3600 * 24 * 7; + @ValidateIf((env) => env.ENTERPRISE_KEY) + SESSION_STORE_SECRET: string; + @CastToBoolean() CALENDAR_PROVIDER_GOOGLE_ENABLED = false; diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 58282f9deca4..c78a1bf066db 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -10,6 +10,7 @@ export enum FeatureFlagKey { IsMessageThreadSubscriberEnabled = 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED', IsQueryRunnerTwentyORMEnabled = 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED', IsWorkspaceFavoriteEnabled = 'IS_WORKSPACE_FAVORITE_ENABLED', + IsSSOEnabled = 'IS_SSO_ENABLED', IsGmailSendEmailScopeEnabled = 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED', IsAnalyticsV2Enabled = 'IS_ANALYTICS_V2_ENABLED', IsUniqueIndexesEnabled = 'IS_UNIQUE_INDEXES_ENABLED', diff --git a/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts b/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts new file mode 100644 index 000000000000..8f5099ba5bd9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/session-storage/session-storage.module-factory.ts @@ -0,0 +1,66 @@ +import { Logger } from '@nestjs/common'; + +import { createClient } from 'redis'; +import RedisStore from 'connect-redis'; +import session from 'express-session'; + +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { CacheStorageType } from 'src/engine/core-modules/cache-storage/types/cache-storage-type.enum'; +import { MessageQueueDriverType } from 'src/engine/core-modules/message-queue/interfaces'; + +export const getSessionStorageOptions = ( + environmentService: EnvironmentService, +): session.SessionOptions => { + const cacheStorageType = environmentService.get('CACHE_STORAGE_TYPE'); + + const SERVER_URL = environmentService.get('SERVER_URL'); + + const sessionStorage = { + secret: environmentService.get('SESSION_STORE_SECRET'), + resave: false, + saveUninitialized: false, + cookie: { + secure: !!(SERVER_URL && SERVER_URL.startsWith('https')), + maxAge: 1000 * 60 * 30, // 30 minutes + }, + }; + + switch (cacheStorageType) { + case CacheStorageType.Memory: { + Logger.warn( + 'Memory session storage is not recommended for production. Prefer Redis.', + ); + + return sessionStorage; + } + case CacheStorageType.Redis: { + const connectionString = environmentService.get('REDIS_URL'); + + if (!connectionString) { + throw new Error( + `${CacheStorageType.Redis} session storage requires REDIS_URL to be defined, check your .env file`, + ); + } + + const redisClient = createClient({ + url: connectionString, + }); + + redisClient.connect().catch((err) => { + throw new Error(`Redis connection failed: ${err}`); + }); + + return { + ...sessionStorage, + store: new RedisStore({ + client: redisClient, + prefix: 'engine:session:', + }), + }; + } + default: + throw new Error( + `Invalid session-storage (${cacheStorageType}), check your .env file`, + ); + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/delete-sso.input.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/delete-sso.input.ts new file mode 100644 index 000000000000..4a0e1002df57 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/dtos/delete-sso.input.ts @@ -0,0 +1,12 @@ +/* @license Enterprise */ + +import { Field, InputType } from '@nestjs/graphql'; + +import { IsUUID } from 'class-validator'; + +@InputType() +export class DeleteSsoInput { + @Field(() => String) + @IsUUID() + identityProviderId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/delete-sso.output.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/delete-sso.output.ts new file mode 100644 index 000000000000..0857ac3a4bf9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/dtos/delete-sso.output.ts @@ -0,0 +1,9 @@ +/* @license Enterprise */ + +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class DeleteSsoOutput { + @Field(() => String) + identityProviderId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/edit-sso.input.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/edit-sso.input.ts new file mode 100644 index 000000000000..617183633712 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/dtos/edit-sso.input.ts @@ -0,0 +1,19 @@ +/* @license Enterprise */ + +import { Field, InputType } from '@nestjs/graphql'; + +import { IsString, IsUUID } from 'class-validator'; + +import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; +import { SSOIdentityProviderStatus } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; + +@InputType() +export class EditSsoInput { + @Field(() => String) + @IsUUID() + id: string; + + @Field(() => SSOIdentityProviderStatus) + @IsString() + status: SSOConfiguration['status']; +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/edit-sso.output.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/edit-sso.output.ts new file mode 100644 index 000000000000..35209c642e86 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/dtos/edit-sso.output.ts @@ -0,0 +1,27 @@ +/* @license Enterprise */ + +import { Field, ObjectType } from '@nestjs/graphql'; + +import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; +import { + IdentityProviderType, + SSOIdentityProviderStatus, +} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; + +@ObjectType() +export class EditSsoOutput { + @Field(() => String) + id: string; + + @Field(() => IdentityProviderType) + type: string; + + @Field(() => String) + issuer: string; + + @Field(() => String) + name: string; + + @Field(() => SSOIdentityProviderStatus) + status: SSOConfiguration['status']; +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts new file mode 100644 index 000000000000..3cd5c91df79f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts @@ -0,0 +1,13 @@ +/* @license Enterprise */ + +import { Field, InputType } from '@nestjs/graphql'; + +import { IsEmail, IsNotEmpty } from 'class-validator'; + +@InputType() +export class FindAvailableSSOIDPInput { + @Field(() => String) + @IsNotEmpty() + @IsEmail() + email: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output.ts new file mode 100644 index 000000000000..3c62fdbcac37 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output.ts @@ -0,0 +1,39 @@ +/* @license Enterprise */ + +import { Field, ObjectType } from '@nestjs/graphql'; + +import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; +import { + IdentityProviderType, + SSOIdentityProviderStatus, +} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; + +@ObjectType() +class WorkspaceNameAndId { + @Field(() => String, { nullable: true }) + displayName?: string | null; + + @Field(() => String) + id: string; +} + +@ObjectType() +export class FindAvailableSSOIDPOutput { + @Field(() => IdentityProviderType) + type: SSOConfiguration['type']; + + @Field(() => String) + id: string; + + @Field(() => String) + issuer: string; + + @Field(() => String) + name: string; + + @Field(() => SSOIdentityProviderStatus) + status: SSOConfiguration['status']; + + @Field(() => WorkspaceNameAndId) + workspace: WorkspaceNameAndId; +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/get-authorization-url.input.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/get-authorization-url.input.ts new file mode 100644 index 000000000000..e0adc9645f26 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/dtos/get-authorization-url.input.ts @@ -0,0 +1,12 @@ +/* @license Enterprise */ + +import { Field, InputType } from '@nestjs/graphql'; + +import { IsString } from 'class-validator'; + +@InputType() +export class GetAuthorizationUrlInput { + @Field(() => String) + @IsString() + identityProviderId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/get-authorization-url.output.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/get-authorization-url.output.ts new file mode 100644 index 000000000000..d0c78e37ddc6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/dtos/get-authorization-url.output.ts @@ -0,0 +1,17 @@ +/* @license Enterprise */ + +import { Field, ObjectType } from '@nestjs/graphql'; + +import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; + +@ObjectType() +export class GetAuthorizationUrlOutput { + @Field(() => String) + authorizationURL: string; + + @Field(() => String) + type: SSOConfiguration['type']; + + @Field(() => String) + id: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/setup-sso.input.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/setup-sso.input.ts new file mode 100644 index 000000000000..29890f184ae0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/dtos/setup-sso.input.ts @@ -0,0 +1,50 @@ +/* @license Enterprise */ + +import { Field, InputType } from '@nestjs/graphql'; + +import { IsOptional, IsString, IsUrl, IsUUID } from 'class-validator'; + +import { IsX509Certificate } from 'src/engine/core-modules/sso/dtos/validators/x509.validator'; + +@InputType() +class SetupSsoInputCommon { + @Field(() => String) + @IsString() + name: string; + + @Field(() => String) + @IsString() + @IsUrl({ protocols: ['http', 'https'] }) + issuer: string; +} + +@InputType() +export class SetupOIDCSsoInput extends SetupSsoInputCommon { + @Field(() => String) + @IsString() + clientID: string; + + @Field(() => String) + @IsString() + clientSecret: string; +} + +@InputType() +export class SetupSAMLSsoInput extends SetupSsoInputCommon { + @Field(() => String) + @IsUUID() + id: string; + + @Field(() => String) + @IsUrl({ protocols: ['http', 'https'] }) + ssoURL: string; + + @Field(() => String) + @IsX509Certificate() + certificate: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + fingerprint?: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/setup-sso.output.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/setup-sso.output.ts new file mode 100644 index 000000000000..b5b4ee076039 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/dtos/setup-sso.output.ts @@ -0,0 +1,27 @@ +/* @license Enterprise */ + +import { Field, ObjectType } from '@nestjs/graphql'; + +import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; +import { + IdentityProviderType, + SSOIdentityProviderStatus, +} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; + +@ObjectType() +export class SetupSsoOutput { + @Field(() => String) + id: string; + + @Field(() => IdentityProviderType) + type: string; + + @Field(() => String) + issuer: string; + + @Field(() => String) + name: string; + + @Field(() => SSOIdentityProviderStatus) + status: SSOConfiguration['status']; +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/validators/x509.validator.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/validators/x509.validator.ts new file mode 100644 index 000000000000..22486aa3eb76 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/dtos/validators/x509.validator.ts @@ -0,0 +1,52 @@ +/* @license Enterprise */ + +import * as crypto from 'crypto'; + +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +@ValidatorConstraint({ async: false }) +export class IsX509CertificateConstraint + implements ValidatorConstraintInterface +{ + validate(value: any) { + if (typeof value !== 'string') { + return false; + } + + try { + const cleanCert = value.replace( + /-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\n|\r/g, + '', + ); + + const der = Buffer.from(cleanCert, 'base64'); + + const cert = new crypto.X509Certificate(der); + + return cert instanceof crypto.X509Certificate; + } catch (error) { + return false; + } + } + + defaultMessage() { + return 'The string is not a valid X509 certificate'; + } +} + +export function IsX509Certificate(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsX509CertificateConstraint, + }); + }; +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts new file mode 100644 index 000000000000..7b2148d23000 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts @@ -0,0 +1,327 @@ +/* @license Enterprise */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Issuer } from 'openid-client'; +import { Repository } from 'typeorm'; + +import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; +import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service'; +import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output'; +import { + SSOException, + SSOExceptionCode, +} from 'src/engine/core-modules/sso/sso.exception'; +import { + OIDCConfiguration, + SAMLConfiguration, + SSOConfiguration, +} from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; +import { + IdentityProviderType, + OIDCResponseType, + WorkspaceSSOIdentityProvider, +} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; + +@Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository +export class SSOService { + constructor( + @InjectRepository(FeatureFlagEntity, 'core') + private readonly featureFlagRepository: Repository, + @InjectRepository(WorkspaceSSOIdentityProvider, 'core') + private readonly workspaceSSOIdentityProviderRepository: Repository, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + private readonly environmentService: EnvironmentService, + @InjectCacheStorage(CacheStorageNamespace.EngineWorkspace) + private readonly cacheStorageService: CacheStorageService, + ) {} + + private async isSSOEnabled(workspaceId: string) { + const isSSOEnabledFeatureFlag = await this.featureFlagRepository.findOneBy({ + workspaceId, + key: FeatureFlagKey.IsSSOEnabled, + value: true, + }); + + if (!isSSOEnabledFeatureFlag?.value) { + throw new SSOException( + `${FeatureFlagKey.IsSSOEnabled} feature flag is disabled`, + SSOExceptionCode.SSO_DISABLE, + ); + } + } + + async createOIDCIdentityProvider( + data: Pick< + WorkspaceSSOIdentityProvider, + 'issuer' | 'clientID' | 'clientSecret' | 'name' + >, + workspaceId: string, + ) { + try { + await this.isSSOEnabled(workspaceId); + + if (!data.issuer) { + throw new SSOException( + 'Invalid issuer URL', + SSOExceptionCode.INVALID_ISSUER_URL, + ); + } + + const issuer = await Issuer.discover(data.issuer); + + if (!issuer.metadata.issuer) { + throw new SSOException( + 'Invalid issuer URL from discovery', + SSOExceptionCode.INVALID_ISSUER_URL, + ); + } + + const identityProvider = + await this.workspaceSSOIdentityProviderRepository.save({ + type: IdentityProviderType.OIDC, + clientID: data.clientID, + clientSecret: data.clientSecret, + issuer: issuer.metadata.issuer, + name: data.name, + workspaceId, + }); + + return { + id: identityProvider.id, + type: identityProvider.type, + name: identityProvider.name, + status: identityProvider.status, + issuer: identityProvider.issuer, + }; + } catch (err) { + if (err instanceof SSOException) { + return err; + } + + return new SSOException( + 'Unknown SSO configuration error', + SSOExceptionCode.UNKNOWN_SSO_CONFIGURATION_ERROR, + ); + } + } + + async createSAMLIdentityProvider( + data: Pick< + WorkspaceSSOIdentityProvider, + 'ssoURL' | 'certificate' | 'fingerprint' | 'id' + >, + workspaceId: string, + ) { + await this.isSSOEnabled(workspaceId); + + const identityProvider = + await this.workspaceSSOIdentityProviderRepository.save({ + ...data, + type: IdentityProviderType.SAML, + workspaceId, + }); + + return { + id: identityProvider.id, + type: identityProvider.type, + name: identityProvider.name, + issuer: this.buildIssuerURL(identityProvider), + status: identityProvider.status, + }; + } + + async findAvailableSSOIdentityProviders(email: string) { + const user = await this.userRepository.findOne({ + where: { email }, + relations: [ + 'workspaces', + 'workspaces.workspace', + 'workspaces.workspace.workspaceSSOIdentityProviders', + ], + }); + + if (!user) { + throw new SSOException('User not found', SSOExceptionCode.USER_NOT_FOUND); + } + + return user.workspaces.flatMap((userWorkspace) => + ( + userWorkspace.workspace + .workspaceSSOIdentityProviders as Array + ).reduce((acc, identityProvider) => { + if (identityProvider.status === 'Inactive') return acc; + + acc.push({ + id: identityProvider.id, + name: identityProvider.name ?? 'Unknown', + issuer: identityProvider.issuer, + type: identityProvider.type, + status: identityProvider.status, + workspace: { + id: userWorkspace.workspaceId, + displayName: userWorkspace.workspace.displayName, + }, + }); + + return acc; + }, [] as Array), + ); + } + + async findSSOIdentityProviderById(identityProviderId?: string) { + // if identityProviderId is not provide, typeorm return a random idp instead of undefined + if (!identityProviderId) return undefined; + + return (await this.workspaceSSOIdentityProviderRepository.findOne({ + where: { id: identityProviderId }, + })) as (SSOConfiguration & WorkspaceSSOIdentityProvider) | undefined; + } + + buildCallbackUrl( + identityProvider: Pick, + ) { + const callbackURL = new URL(this.environmentService.get('SERVER_URL')); + + callbackURL.pathname = `/auth/${identityProvider.type.toLowerCase()}/callback`; + + return callbackURL.toString(); + } + + buildIssuerURL( + identityProvider: Pick, + ) { + return `${this.environmentService.get('SERVER_URL')}/auth/${identityProvider.type.toLowerCase()}/login/${identityProvider.id}`; + } + + private isOIDCIdentityProvider( + identityProvider: WorkspaceSSOIdentityProvider, + ): identityProvider is OIDCConfiguration & WorkspaceSSOIdentityProvider { + return identityProvider.type === IdentityProviderType.OIDC; + } + + isSAMLIdentityProvider( + identityProvider: WorkspaceSSOIdentityProvider, + ): identityProvider is SAMLConfiguration & WorkspaceSSOIdentityProvider { + return identityProvider.type === IdentityProviderType.SAML; + } + + getOIDCClient( + identityProvider: WorkspaceSSOIdentityProvider, + issuer: Issuer, + ) { + if (!this.isOIDCIdentityProvider(identityProvider)) { + throw new SSOException( + 'Invalid Identity Provider type', + SSOExceptionCode.INVALID_IDP_TYPE, + ); + } + + return new issuer.Client({ + client_id: identityProvider.clientID, + client_secret: identityProvider.clientSecret, + redirect_uris: [this.buildCallbackUrl(identityProvider)], + response_types: [OIDCResponseType.CODE], + }); + } + + async getAuthorizationUrl(identityProviderId: string) { + const identityProvider = + (await this.workspaceSSOIdentityProviderRepository.findOne({ + where: { + id: identityProviderId, + }, + })) as WorkspaceSSOIdentityProvider & SSOConfiguration; + + if (!identityProvider) { + throw new SSOException( + 'Identity Provider not found', + SSOExceptionCode.USER_NOT_FOUND, + ); + } + + return { + id: identityProvider.id, + authorizationURL: this.buildIssuerURL(identityProvider), + type: identityProvider.type, + }; + } + + async listSSOIdentityProvidersByWorkspaceId(workspaceId: string) { + return (await this.workspaceSSOIdentityProviderRepository.find({ + where: { workspaceId }, + select: ['id', 'name', 'type', 'issuer', 'status'], + })) as Array< + Pick< + WorkspaceSSOIdentityProvider, + 'id' | 'name' | 'type' | 'issuer' | 'status' + > + >; + } + + async deleteSSOIdentityProvider( + identityProviderId: string, + workspaceId: string, + ) { + const identityProvider = + await this.workspaceSSOIdentityProviderRepository.findOne({ + where: { + id: identityProviderId, + workspaceId, + }, + }); + + if (!identityProvider) { + throw new SSOException( + 'Identity Provider not found', + SSOExceptionCode.IDENTITY_PROVIDER_NOT_FOUND, + ); + } + + await this.workspaceSSOIdentityProviderRepository.delete({ + id: identityProvider.id, + }); + + return { identityProviderId: identityProvider.id }; + } + + async editSSOIdentityProvider( + payload: Partial, + workspaceId: string, + ) { + const ssoIdp = await this.workspaceSSOIdentityProviderRepository.findOne({ + where: { + id: payload.id, + workspaceId, + }, + }); + + if (!ssoIdp) { + throw new SSOException( + 'Identity Provider not found', + SSOExceptionCode.IDENTITY_PROVIDER_NOT_FOUND, + ); + } + + const result = await this.workspaceSSOIdentityProviderRepository.save({ + ...ssoIdp, + ...payload, + }); + + return { + id: result.id, + type: result.type, + issuer: result.issuer, + name: result.name, + status: result.status, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/sso.exception.ts b/packages/twenty-server/src/engine/core-modules/sso/sso.exception.ts new file mode 100644 index 000000000000..ec5520c5a371 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/sso.exception.ts @@ -0,0 +1,20 @@ +/* @license Enterprise */ + +import { CustomException } from 'src/utils/custom-exception'; + +export class SSOException extends CustomException { + code: SSOExceptionCode; + constructor(message: string, code: SSOExceptionCode) { + super(message, code); + } +} + +export enum SSOExceptionCode { + USER_NOT_FOUND = 'USER_NOT_FOUND', + INVALID_SSO_CONFIGURATION = 'INVALID_SSO_CONFIGURATION', + IDENTITY_PROVIDER_NOT_FOUND = 'IDENTITY_PROVIDER_NOT_FOUND', + INVALID_ISSUER_URL = 'INVALID_ISSUER_URL', + INVALID_IDP_TYPE = 'INVALID_IDP_TYPE', + UNKNOWN_SSO_CONFIGURATION_ERROR = 'UNKNOWN_SSO_CONFIGURATION_ERROR', + SSO_DISABLE = 'SSO_DISABLE', +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/sso.module.ts b/packages/twenty-server/src/engine/core-modules/sso/sso.module.ts new file mode 100644 index 000000000000..fc7fe9979987 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/sso.module.ts @@ -0,0 +1,24 @@ +/* @license Enterprise */ + +import { Module } from '@nestjs/common'; + +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { SSOResolver } from 'src/engine/core-modules/sso/sso.resolver'; +import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; + +@Module({ + imports: [ + NestjsQueryTypeOrmModule.forFeature( + [WorkspaceSSOIdentityProvider, User, AppToken, FeatureFlagEntity], + 'core', + ), + ], + exports: [SSOService], + providers: [SSOService, SSOResolver], +}) +export class WorkspaceSSOModule {} diff --git a/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts b/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts new file mode 100644 index 000000000000..e6e492b5b90f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts @@ -0,0 +1,97 @@ +/* @license Enterprise */ + +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard'; +import { DeleteSsoInput } from 'src/engine/core-modules/sso/dtos/delete-sso.input'; +import { DeleteSsoOutput } from 'src/engine/core-modules/sso/dtos/delete-sso.output'; +import { EditSsoInput } from 'src/engine/core-modules/sso/dtos/edit-sso.input'; +import { EditSsoOutput } from 'src/engine/core-modules/sso/dtos/edit-sso.output'; +import { FindAvailableSSOIDPInput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input'; +import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output'; +import { GetAuthorizationUrlInput } from 'src/engine/core-modules/sso/dtos/get-authorization-url.input'; +import { GetAuthorizationUrlOutput } from 'src/engine/core-modules/sso/dtos/get-authorization-url.output'; +import { + SetupOIDCSsoInput, + SetupSAMLSsoInput, +} from 'src/engine/core-modules/sso/dtos/setup-sso.input'; +import { SetupSsoOutput } from 'src/engine/core-modules/sso/dtos/setup-sso.output'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { SSOException } from 'src/engine/core-modules/sso/sso.exception'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@Resolver() +export class SSOResolver { + constructor(private readonly sSOService: SSOService) {} + + @UseGuards(WorkspaceAuthGuard, SSOProviderEnabledGuard) + @Mutation(() => SetupSsoOutput) + async createOIDCIdentityProvider( + @Args('input') setupSsoInput: SetupOIDCSsoInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ): Promise { + return this.sSOService.createOIDCIdentityProvider( + setupSsoInput, + workspaceId, + ); + } + + @UseGuards(SSOProviderEnabledGuard) + @Mutation(() => [FindAvailableSSOIDPOutput]) + async findAvailableSSOIdentityProviders( + @Args('input') input: FindAvailableSSOIDPInput, + ): Promise> { + return this.sSOService.findAvailableSSOIdentityProviders(input.email); + } + + @UseGuards(SSOProviderEnabledGuard) + @Query(() => [FindAvailableSSOIDPOutput]) + async listSSOIdentityProvidersByWorkspaceId( + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.sSOService.listSSOIdentityProvidersByWorkspaceId(workspaceId); + } + + @Mutation(() => GetAuthorizationUrlOutput) + async getAuthorizationUrl( + @Args('input') { identityProviderId }: GetAuthorizationUrlInput, + ) { + return this.sSOService.getAuthorizationUrl(identityProviderId); + } + + @UseGuards(WorkspaceAuthGuard, SSOProviderEnabledGuard) + @Mutation(() => SetupSsoOutput) + async createSAMLIdentityProvider( + @Args('input') setupSsoInput: SetupSAMLSsoInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ): Promise { + return this.sSOService.createSAMLIdentityProvider( + setupSsoInput, + workspaceId, + ); + } + + @UseGuards(WorkspaceAuthGuard, SSOProviderEnabledGuard) + @Mutation(() => DeleteSsoOutput) + async deleteSSOIdentityProvider( + @Args('input') { identityProviderId }: DeleteSsoInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.sSOService.deleteSSOIdentityProvider( + identityProviderId, + workspaceId, + ); + } + + @UseGuards(WorkspaceAuthGuard, SSOProviderEnabledGuard) + @Mutation(() => EditSsoOutput) + async editSSOIdentityProvider( + @Args('input') input: EditSsoInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.sSOService.editSSOIdentityProvider(input, workspaceId); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/sso/types/SSOConfigurations.type.ts b/packages/twenty-server/src/engine/core-modules/sso/types/SSOConfigurations.type.ts new file mode 100644 index 000000000000..ea41aded6d08 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/types/SSOConfigurations.type.ts @@ -0,0 +1,28 @@ +/* @license Enterprise */ + +import { + IdentityProviderType, + SSOIdentityProviderStatus, +} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; + +type CommonSSOConfiguration = { + id: string; + issuer: string; + name?: string; + status: SSOIdentityProviderStatus; +}; + +export type OIDCConfiguration = { + type: IdentityProviderType.OIDC; + clientID: string; + clientSecret: string; +} & CommonSSOConfiguration; + +export type SAMLConfiguration = { + type: IdentityProviderType.SAML; + ssoURL: string; + certificate: string; + fingerprint?: string; +} & CommonSSOConfiguration; + +export type SSOConfiguration = OIDCConfiguration | SAMLConfiguration; diff --git a/packages/twenty-server/src/engine/core-modules/sso/workspace-sso-identity-provider.entity.ts b/packages/twenty-server/src/engine/core-modules/sso/workspace-sso-identity-provider.entity.ts new file mode 100644 index 000000000000..b860353314c1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/sso/workspace-sso-identity-provider.entity.ts @@ -0,0 +1,110 @@ +/* @license Enterprise */ + +import { ObjectType, registerEnumType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + UpdateDateColumn, +} from 'typeorm'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +export enum IdentityProviderType { + OIDC = 'OIDC', + SAML = 'SAML', +} + +export enum OIDCResponseType { + // Only Authorization Code is used for now + CODE = 'code', + ID_TOKEN = 'id_token', + TOKEN = 'token', + NONE = 'none', +} + +registerEnumType(IdentityProviderType, { + name: 'IdpType', +}); + +export enum SSOIdentityProviderStatus { + Active = 'Active', + Inactive = 'Inactive', + Error = 'Error', +} + +registerEnumType(SSOIdentityProviderStatus, { + name: 'SSOIdentityProviderStatus', +}); + +@Entity({ name: 'workspaceSSOIdentityProvider', schema: 'core' }) +@ObjectType('WorkspaceSSOIdentityProvider') +export class WorkspaceSSOIdentityProvider { + // COMMON + @IDField(() => UUIDScalarType) + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ + type: 'enum', + enum: SSOIdentityProviderStatus, + default: SSOIdentityProviderStatus.Active, + }) + status: SSOIdentityProviderStatus; + + @ManyToOne( + () => Workspace, + (workspace) => workspace.workspaceSSOIdentityProviders, + { + onDelete: 'CASCADE', + }, + ) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation; + + @Column() + workspaceId: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @Column({ + type: 'enum', + enum: IdentityProviderType, + default: IdentityProviderType.OIDC, + }) + type: IdentityProviderType; + + @Column() + issuer: string; + + // OIDC + @Column({ nullable: true }) + clientID?: string; + + @Column({ nullable: true }) + clientSecret?: string; + + // SAML + @Column({ nullable: true }) + ssoURL?: string; + + @Column({ nullable: true }) + certificate?: string; + + @Column({ nullable: true }) + fingerprint?: string; +} 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 4f26a8b0e0df..3810176f3592 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 @@ -126,7 +126,7 @@ export class UserWorkspaceService extends TypeOrmQueryService { throw new Error('Invalid invitation token'); } - if (appToken.context?.email !== email) { + if (!appToken.context?.email && appToken.context?.email !== email) { throw new Error('Email does not match the invitation'); } 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 8c193efefaa6..7e7f8cf1f79b 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 @@ -36,7 +36,7 @@ export class WorkspaceInvitationService { private readonly onboardingService: OnboardingService, ) {} - private async getOneWorkspaceInvitation(workspaceId: string, email: string) { + async getOneWorkspaceInvitation(workspaceId: string, email: string) { return await this.appTokenRepository .createQueryBuilder('appToken') .where('"appToken"."workspaceId" = :workspaceId', { @@ -160,7 +160,7 @@ export class WorkspaceInvitationService { }, }); - if (!appToken || !appToken.context || !('email' in appToken.context)) { + if (!appToken || !appToken.context?.email) { throw new WorkspaceInvitationException( 'Invalid appToken', WorkspaceInvitationExceptionCode.INVALID_INVITATION, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts index 75b40376c38c..f56a8a54c6ff 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts @@ -24,6 +24,11 @@ export class UpdateWorkspaceInput { @IsOptional() inviteHash?: string; + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + isPublicInviteLinkEnabled?: boolean; + @Field({ nullable: true }) @IsBoolean() @IsOptional() diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index b9948f623b2c..ac5c38287104 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -19,6 +19,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; export enum WorkspaceActivationStatus { ONGOING_CREATION = 'ONGOING_CREATION', @@ -92,6 +93,10 @@ export class Workspace { @Column({ default: true }) allowImpersonation: boolean; + @Field() + @Column({ default: true }) + isPublicInviteLinkEnabled: boolean; + @OneToMany(() => FeatureFlagEntity, (featureFlag) => featureFlag.workspace) featureFlags: Relation; @@ -118,6 +123,12 @@ export class Workspace { ) allPostgresCredentials: Relation; + @OneToMany( + () => WorkspaceSSOIdentityProvider, + (workspaceSSOIdentityProviders) => workspaceSSOIdentityProviders.workspace, + ) + workspaceSSOIdentityProviders: Relation; + @Field() @Column({ default: 1 }) metadataVersion: number; diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts index 7375c64a8e4b..4ea5ba05ffcf 100644 --- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts @@ -54,6 +54,8 @@ export class GraphQLHydrateRequestFromTokenMiddleware 'UpdatePasswordViaResetToken', 'IntrospectionQuery', 'ExchangeAuthorizationCode', + 'GetAuthorizationUrl', + 'FindAvailableSSOIdentityProviders', ]; if ( diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index b00532747154..469845f41103 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -2,12 +2,15 @@ import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; +import session from 'express-session'; import bytes from 'bytes'; import { useContainer } from 'class-validator'; import { graphqlUploadExpress } from 'graphql-upload'; import { LoggerService } from 'src/engine/core-modules/logger/logger.service'; import { ApplyCorsToExceptions } from 'src/utils/apply-cors-to-exceptions'; +import { getSessionStorageOptions } from 'src/engine/core-modules/session-storage/session-storage.module-factory'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { AppModule } from './app.module'; import './instrument'; @@ -23,6 +26,7 @@ const bootstrap = async () => { snapshot: process.env.DEBUG_MODE === 'true', }); const logger = app.get(LoggerService); + const environmentService = app.get(EnvironmentService); // TODO: Double check this as it's not working for now, it's going to be heplful for durable trees in twenty "orm" // // Apply context id strategy for durable trees @@ -59,6 +63,11 @@ const bootstrap = async () => { // Create the env-config.js of the front at runtime generateFrontConfig(); + // Enable session - Today it's used only for SSO + if (environmentService.get('AUTH_SSO_ENABLED')) { + app.use(session(getSessionStorageOptions(environmentService))); + } + await app.listen(process.env.PORT ?? 3000); }; diff --git a/packages/twenty-ui/src/display/icon/assets/lock.svg b/packages/twenty-ui/src/display/icon/assets/lock.svg new file mode 100644 index 000000000000..6fd1e546430e --- /dev/null +++ b/packages/twenty-ui/src/display/icon/assets/lock.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/twenty-ui/src/display/icon/components/IconLock.tsx b/packages/twenty-ui/src/display/icon/components/IconLock.tsx new file mode 100644 index 000000000000..32053e4663f3 --- /dev/null +++ b/packages/twenty-ui/src/display/icon/components/IconLock.tsx @@ -0,0 +1,13 @@ +import { useTheme } from '@emotion/react'; + +import IconLockRaw from '@ui/display/icon/assets/lock.svg?react'; +import { IconComponentProps } from '@ui/display/icon/types/IconComponent'; + +type IconLockCustomProps = Pick; + +export const IconLockCustom = (props: IconLockCustomProps) => { + const theme = useTheme(); + const size = props.size ?? theme.icon.size.lg; + + return ; +}; diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index bdccc2a098dc..3bab15bcf604 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -14,6 +14,7 @@ export * from './icon/components/IconAddressBook'; export * from './icon/components/IconGmail'; export * from './icon/components/IconGoogle'; export * from './icon/components/IconGoogleCalendar'; +export * from './icon/components/IconLock'; export * from './icon/components/IconMicrosoft'; export * from './icon/components/IconRelationManyToOne'; export * from './icon/components/IconTwentyStar'; diff --git a/yarn.lock b/yarn.lock index b6ed9d913b45..615c20e4f3b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7705,6 +7705,40 @@ __metadata: languageName: node linkType: hard +"@node-saml/node-saml@npm:^5.0.0": + version: 5.0.0 + resolution: "@node-saml/node-saml@npm:5.0.0" + dependencies: + "@types/debug": "npm:^4.1.12" + "@types/qs": "npm:^6.9.11" + "@types/xml-encryption": "npm:^1.2.4" + "@types/xml2js": "npm:^0.4.14" + "@xmldom/is-dom-node": "npm:^1.0.1" + "@xmldom/xmldom": "npm:^0.8.10" + debug: "npm:^4.3.4" + xml-crypto: "npm:^6.0.0" + xml-encryption: "npm:^3.0.2" + xml2js: "npm:^0.6.2" + xmlbuilder: "npm:^15.1.1" + xpath: "npm:^0.0.34" + checksum: 10c0/50a7aab94d410c0b1169eb5b0cf13ac964281a88d6fc155345e82afb2d6ccc159db90ebffa89c3d348fc233c0558af8d2b7b11f0ce8e65f90cd8297c0d274c1a + languageName: node + linkType: hard + +"@node-saml/passport-saml@npm:^5.0.0": + version: 5.0.0 + resolution: "@node-saml/passport-saml@npm:5.0.0" + dependencies: + "@node-saml/node-saml": "npm:^5.0.0" + "@types/express": "npm:^4.17.21" + "@types/passport": "npm:^1.0.16" + "@types/passport-strategy": "npm:^0.2.38" + passport: "npm:^0.7.0" + passport-strategy: "npm:^1.0.0" + checksum: 10c0/bbe72899ce26bb830147f53c44f7399e459ec852c6b5837b5e03e9652def53a62cd3a39ef0a27024ab616f8630d198a25481c729c25e52375f506e3825b930dd + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -15681,7 +15715,7 @@ __metadata: languageName: node linkType: hard -"@types/debug@npm:^4.0.0": +"@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.12": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" dependencies: @@ -15831,7 +15865,16 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:*, @types/express@npm:^4.17.13, @types/express@npm:^4.7.0": +"@types/express-session@npm:^1.18.0": + version: 1.18.0 + resolution: "@types/express-session@npm:1.18.0" + dependencies: + "@types/express": "npm:*" + checksum: 10c0/a41a1fcc4a433c71e2a7ffbac82bc7fb5ad436757a9d27fd30ae4656dee137d244f04de9ad756b566be136cf82f6a75e9ca55ac6c260396e74d1931021b09621 + languageName: node + linkType: hard + +"@types/express@npm:*, @types/express@npm:^4.17.13, @types/express@npm:^4.17.21, @types/express@npm:^4.7.0": version: 4.17.21 resolution: "@types/express@npm:4.17.21" dependencies: @@ -16551,6 +16594,15 @@ __metadata: languageName: node linkType: hard +"@types/openid-client@npm:^3.7.0": + version: 3.7.0 + resolution: "@types/openid-client@npm:3.7.0" + dependencies: + openid-client: "npm:*" + checksum: 10c0/16f9bb3516e427ff580f664a329cfdbbe1dc7658c1aad08b6a45581c23588230a9b75e9fe5295316117a38f76c13e2d44c587a439cdaaae580f5f6059fbb435a + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.2 resolution: "@types/parse-json@npm:4.0.2" @@ -16607,7 +16659,7 @@ __metadata: languageName: node linkType: hard -"@types/passport-strategy@npm:*": +"@types/passport-strategy@npm:*, @types/passport-strategy@npm:^0.2.38": version: 0.2.38 resolution: "@types/passport-strategy@npm:0.2.38" dependencies: @@ -16617,7 +16669,7 @@ __metadata: languageName: node linkType: hard -"@types/passport@npm:*": +"@types/passport@npm:*, @types/passport@npm:^1.0.16": version: 1.0.16 resolution: "@types/passport@npm:1.0.16" dependencies: @@ -16692,6 +16744,13 @@ __metadata: languageName: node linkType: hard +"@types/qs@npm:^6.9.11": + version: 6.9.16 + resolution: "@types/qs@npm:6.9.16" + checksum: 10c0/a4e871b80fff623755e356fd1f225aea45ff7a29da30f99fddee1a05f4f5f33485b314ab5758145144ed45708f97e44595aa9a8368e9bbc083932f931b12dbb6 + languageName: node + linkType: hard + "@types/range-parser@npm:*": version: 1.2.7 resolution: "@types/range-parser@npm:1.2.7" @@ -16981,6 +17040,24 @@ __metadata: languageName: node linkType: hard +"@types/xml-encryption@npm:^1.2.4": + version: 1.2.4 + resolution: "@types/xml-encryption@npm:1.2.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/33191fc1a8ef6b81108f438d3f3bc8aac987cb68eaab8f70653a1e231c903de7998f961078345fa5444f2681513c47d452e039bd438d66ebaebd4b907194175d + languageName: node + linkType: hard + +"@types/xml2js@npm:^0.4.14": + version: 0.4.14 + resolution: "@types/xml2js@npm:0.4.14" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/06776e7f7aec55a698795e60425417caa7d7db3ff680a7b4ccaae1567c5fec28ff49b9975e9a0d74ff4acb8f4a43730501bbe64f9f761d784c6476ba4db12e13 + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" @@ -17910,6 +17987,20 @@ __metadata: languageName: node linkType: hard +"@xmldom/is-dom-node@npm:^1.0.1": + version: 1.0.1 + resolution: "@xmldom/is-dom-node@npm:1.0.1" + checksum: 10c0/138d5e74441b16f065ce360d81737673986d93f14d5bb09b1e3bcfc2b18fae70b86beb9b7bfbffe916dd36b3bdab012acaa81cc0b49450acadfd66978b62827f + languageName: node + linkType: hard + +"@xmldom/xmldom@npm:^0.8.10, @xmldom/xmldom@npm:^0.8.5": + version: 0.8.10 + resolution: "@xmldom/xmldom@npm:0.8.10" + checksum: 10c0/c7647c442502720182b0d65b17d45d2d95317c1c8c497626fe524bda79b4fb768a9aa4fae2da919f308e7abcff7d67c058b102a9d641097e9a57f0b80187851f + languageName: node + linkType: hard + "@xobotyi/scrollbar-width@npm:^1.9.5": version: 1.9.5 resolution: "@xobotyi/scrollbar-width@npm:1.9.5" @@ -22564,6 +22655,15 @@ __metadata: languageName: node linkType: hard +"connect-redis@npm:^7.1.1": + version: 7.1.1 + resolution: "connect-redis@npm:7.1.1" + peerDependencies: + express-session: ">=1" + checksum: 10c0/eeb9e275176d1ef973c808df7c860c80300dfdecdee1a8ca20524fc4e37ccb2206923b07f17501fb7235cde73e9db9e06dff79ef17a54e5a57f35db247ec99fb + languageName: node + linkType: hard + "consola@npm:^2.15.0": version: 2.15.3 resolution: "consola@npm:2.15.3" @@ -22654,6 +22754,13 @@ __metadata: languageName: node linkType: hard +"cookie-signature@npm:1.0.7": + version: 1.0.7 + resolution: "cookie-signature@npm:1.0.7" + checksum: 10c0/e7731ad2995ae2efeed6435ec1e22cdd21afef29d300c27281438b1eab2bae04ef0d1a203928c0afec2cee72aa36540b8747406ebe308ad23c8e8cc3c26c9c51 + languageName: node + linkType: hard + "cookie@npm:0.5.0, cookie@npm:^0.5.0": version: 0.5.0 resolution: "cookie@npm:0.5.0" @@ -22668,6 +22775,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:0.7.2": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 + languageName: node + linkType: hard + "cookiejar@npm:^2.1.4": version: 2.1.4 resolution: "cookiejar@npm:2.1.4" @@ -23974,7 +24088,7 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0": +"depd@npm:2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: 10c0/58bd06ec20e19529b06f7ad07ddab60e504d9e0faca4bd23079fac2d279c3594334d736508dc350e06e510aba5e22e4594483b3a6562ce7c17dd797f4cc4ad2c @@ -25414,7 +25528,7 @@ __metadata: languageName: node linkType: hard -"escape-html@npm:~1.0.3": +"escape-html@npm:^1.0.3, escape-html@npm:~1.0.3": version: 1.0.3 resolution: "escape-html@npm:1.0.3" checksum: 10c0/524c739d776b36c3d29fa08a22e03e8824e3b2fd57500e5e44ecf3cc4707c34c60f9ca0781c0e33d191f2991161504c295e98f68c78fe7baa6e57081ec6ac0a3 @@ -26185,6 +26299,22 @@ __metadata: languageName: node linkType: hard +"express-session@npm:^1.18.1": + version: 1.18.1 + resolution: "express-session@npm:1.18.1" + dependencies: + cookie: "npm:0.7.2" + cookie-signature: "npm:1.0.7" + debug: "npm:2.6.9" + depd: "npm:~2.0.0" + on-headers: "npm:~1.0.2" + parseurl: "npm:~1.3.3" + safe-buffer: "npm:5.2.1" + uid-safe: "npm:~2.1.5" + checksum: 10c0/7999f128df1528430044c97bb1aac95093afaee86c5fa54b2890c4aad9898d79745301f8c90c2df057d6dfe7af7f8ee220340bf5eb53dca5eff37e52cc2fbec7 + languageName: node + linkType: hard + "express@npm:4.18.2": version: 4.18.2 resolution: "express@npm:4.18.2" @@ -31429,7 +31559,7 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.11.4": +"jose@npm:^4.11.4, jose@npm:^4.15.9": version: 4.15.9 resolution: "jose@npm:4.15.9" checksum: 10c0/4ed4ddf4a029db04bd167f2215f65d7245e4dc5f36d7ac3c0126aab38d66309a9e692f52df88975d99429e357e5fd8bab340ff20baab544d17684dd1d940a0f4 @@ -36389,6 +36519,13 @@ __metadata: languageName: node linkType: hard +"object-hash@npm:^2.2.0": + version: 2.2.0 + resolution: "object-hash@npm:2.2.0" + checksum: 10c0/1527de843926c5442ed61f8bdddfc7dc181b6497f725b0e89fcf50a55d9c803088763ed447cac85a5aa65345f1e99c2469ba679a54349ef3c4c0aeaa396a3eb9 + languageName: node + linkType: hard + "object-hash@npm:^3.0.0": version: 3.0.0 resolution: "object-hash@npm:3.0.0" @@ -36521,6 +36658,13 @@ __metadata: languageName: node linkType: hard +"oidc-token-hash@npm:^5.0.3": + version: 5.0.3 + resolution: "oidc-token-hash@npm:5.0.3" + checksum: 10c0/d0dc0551406f09577874155cc83cf69c39e4b826293d50bb6c37936698aeca17d4bcee356ab910c859e53e83f2728a2acbd041020165191353b29de51fbca615 + languageName: node + linkType: hard + "on-finished@npm:2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -36632,6 +36776,18 @@ __metadata: languageName: node linkType: hard +"openid-client@npm:*, openid-client@npm:^5.7.0": + version: 5.7.0 + resolution: "openid-client@npm:5.7.0" + dependencies: + jose: "npm:^4.15.9" + lru-cache: "npm:^6.0.0" + object-hash: "npm:^2.2.0" + oidc-token-hash: "npm:^5.0.3" + checksum: 10c0/02e42c66415581262c0372e178dba2bc958f1b5cfd2eb502b4f71b7718fc11dfac37b12117b1c73cff5dc80f5871cd830e175aae95ae212fbd353f3efa1de091 + languageName: node + linkType: hard + "optimism@npm:^0.18.0": version: 0.18.0 resolution: "optimism@npm:0.18.0" @@ -38910,6 +39066,13 @@ __metadata: languageName: node linkType: hard +"random-bytes@npm:~1.0.0": + version: 1.0.0 + resolution: "random-bytes@npm:1.0.0" + checksum: 10c0/71e7a600e0976e9ebc269793a0577d47b965fa678fcc9e9623e427f909d1b3669db5b3a178dbf61229f0724ea23dba64db389f0be0ba675c6a6b837c02f29b8f + languageName: node + linkType: hard + "randombytes@npm:^2.0.0, randombytes@npm:^2.0.1, randombytes@npm:^2.0.5, randombytes@npm:^2.1.0": version: 2.1.0 resolution: "randombytes@npm:2.1.0" @@ -39894,7 +40057,7 @@ __metadata: languageName: node linkType: hard -"redis@npm:^4.6.13": +"redis@npm:^4.6.13, redis@npm:^4.7.0": version: 4.7.0 resolution: "redis@npm:4.7.0" dependencies: @@ -41013,6 +41176,13 @@ __metadata: languageName: node linkType: hard +"sax@npm:>=0.6.0": + version: 1.4.1 + resolution: "sax@npm:1.4.1" + checksum: 10c0/6bf86318a254c5d898ede6bd3ded15daf68ae08a5495a2739564eb265cd13bcc64a07ab466fb204f67ce472bb534eb8612dac587435515169593f4fffa11de7c + languageName: node + linkType: hard + "saxes@npm:^6.0.0": version: 6.0.0 resolution: "saxes@npm:6.0.0" @@ -43934,10 +44104,12 @@ __metadata: "@nestjs/cli": "npm:10.3.0" "@nestjs/devtools-integration": "npm:^0.1.6" "@nestjs/graphql": "patch:@nestjs/graphql@12.1.1#./patches/@nestjs+graphql+12.1.1.patch" + "@node-saml/passport-saml": "npm:^5.0.0" "@nx/js": "npm:18.3.3" "@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch" "@revertdotdev/revert-react": "npm:^0.0.21" "@sentry/nestjs": "npm:^8.30.0" + "@types/express-session": "npm:^1.18.0" "@types/lodash.differencewith": "npm:^4.5.9" "@types/lodash.isempty": "npm:^4.4.7" "@types/lodash.isequal": "npm:^4.5.8" @@ -43949,11 +44121,14 @@ __metadata: "@types/lodash.uniq": "npm:^4.5.9" "@types/lodash.uniqby": "npm:^4.7.9" "@types/lodash.upperfirst": "npm:^4.3.7" + "@types/openid-client": "npm:^3.7.0" "@types/react": "npm:^18.2.39" "@types/unzipper": "npm:^0" cache-manager: "npm:^5.4.0" cache-manager-redis-yet: "npm:^4.1.2" class-validator: "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch" + connect-redis: "npm:^7.1.1" + express-session: "npm:^1.18.1" graphql-middleware: "npm:^6.1.35" handlebars: "npm:^4.7.8" jsdom: "npm:~22.1.0" @@ -43967,8 +44142,10 @@ __metadata: lodash.uniqby: "npm:^4.7.0" monaco-editor: "npm:^0.51.0" monaco-editor-auto-typings: "npm:^0.4.5" + openid-client: "npm:^5.7.0" passport: "npm:^0.7.0" psl: "npm:^1.9.0" + redis: "npm:^4.7.0" rimraf: "npm:^5.0.5" ts-morph: "npm:^24.0.0" tsconfig-paths: "npm:^4.2.0" @@ -44678,6 +44855,15 @@ __metadata: languageName: node linkType: hard +"uid-safe@npm:~2.1.5": + version: 2.1.5 + resolution: "uid-safe@npm:2.1.5" + dependencies: + random-bytes: "npm:~1.0.0" + checksum: 10c0/ec96862e859fd12175f3da7fda9d1359a2cf412fd521e10837cbdc6d554774079ce252f366981df9401283841c8924782f6dbee8f82a3a81f805ed8a8584595d + languageName: node + linkType: hard + "uid2@npm:0.0.x": version: 0.0.4 resolution: "uid2@npm:0.0.4" @@ -46752,6 +46938,28 @@ __metadata: languageName: node linkType: hard +"xml-crypto@npm:^6.0.0": + version: 6.0.0 + resolution: "xml-crypto@npm:6.0.0" + dependencies: + "@xmldom/is-dom-node": "npm:^1.0.1" + "@xmldom/xmldom": "npm:^0.8.10" + xpath: "npm:^0.0.33" + checksum: 10c0/1a9d8be4cc7a4c618fa413b8ef30f11cda9ae81f20bc03e84c51f6c61383168a9915f8c3a26061e2053e58807b76d3a13726338f7bc0d8c45285fbb1da296293 + languageName: node + linkType: hard + +"xml-encryption@npm:^3.0.2": + version: 3.0.2 + resolution: "xml-encryption@npm:3.0.2" + dependencies: + "@xmldom/xmldom": "npm:^0.8.5" + escape-html: "npm:^1.0.3" + xpath: "npm:0.0.32" + checksum: 10c0/fcad4244f76c9b849f4168e6712c96281badb25e5ebeaae3da1e837386440527f33f3452b529949794d16072d12b0f9fa0405052445c9ce52b9311f557eb0dcb + languageName: node + linkType: hard + "xml-formatter@npm:^2.6.1": version: 2.6.1 resolution: "xml-formatter@npm:2.6.1" @@ -46775,6 +46983,16 @@ __metadata: languageName: node linkType: hard +"xml2js@npm:^0.6.2": + version: 0.6.2 + resolution: "xml2js@npm:0.6.2" + dependencies: + sax: "npm:>=0.6.0" + xmlbuilder: "npm:~11.0.0" + checksum: 10c0/e98a84e9c172c556ee2c5afa0fc7161b46919e8b53ab20de140eedea19903ed82f7cd5b1576fb345c84f0a18da1982ddf65908129b58fc3d7cbc658ae232108f + languageName: node + linkType: hard + "xml@npm:^1.0.1": version: 1.0.1 resolution: "xml@npm:1.0.1" @@ -46782,6 +47000,20 @@ __metadata: languageName: node linkType: hard +"xmlbuilder@npm:^15.1.1": + version: 15.1.1 + resolution: "xmlbuilder@npm:15.1.1" + checksum: 10c0/665266a8916498ff8d82b3d46d3993913477a254b98149ff7cff060d9b7cc0db7cf5a3dae99aed92355254a808c0e2e3ec74ad1b04aa1061bdb8dfbea26c18b8 + languageName: node + linkType: hard + +"xmlbuilder@npm:~11.0.0": + version: 11.0.1 + resolution: "xmlbuilder@npm:11.0.1" + checksum: 10c0/74b979f89a0a129926bc786b913459bdbcefa809afaa551c5ab83f89b1915bdaea14c11c759284bb9b931e3b53004dbc2181e21d3ca9553eeb0b2a7b4e40c35b + languageName: node + linkType: hard + "xmlchars@npm:^2.2.0": version: 2.2.0 resolution: "xmlchars@npm:2.2.0" @@ -46789,6 +47021,27 @@ __metadata: languageName: node linkType: hard +"xpath@npm:0.0.32": + version: 0.0.32 + resolution: "xpath@npm:0.0.32" + checksum: 10c0/3743ab91a8ec1b5eac1f27ddf2fbf696fcde8ce487215becde1502b85a309dcd1b0baeaac1ee7a730aea4787d049b67ae89e8aedbe03a5a07a71e62ec296d9de + languageName: node + linkType: hard + +"xpath@npm:^0.0.33": + version: 0.0.33 + resolution: "xpath@npm:0.0.33" + checksum: 10c0/ac2c04142c0f38e75f0d899b6818b08a0e8163aab5d6fd8a292f31a6e925ab08ee48feb1f447049c5bbcb8926b7241c79d1d4a51386e6f6f2d76ac5784917b9d + languageName: node + linkType: hard + +"xpath@npm:^0.0.34": + version: 0.0.34 + resolution: "xpath@npm:0.0.34" + checksum: 10c0/88335108884ca164421f7fed048ef1a18ab3f7b1ae446b627fd3f51fc2396dcce798601c5e426de3bbd55d5940b84cf2326c75cd76620c1b49491283b85de17a + languageName: node + linkType: hard + "xss@npm:^1.0.8": version: 1.0.15 resolution: "xss@npm:1.0.15"