feat(sso): allow to use OIDC and SAML (#7246)

## What it does
### Backend
- [x] Add a mutation to create OIDC and SAML configuration
- [x] Add a mutation to delete an SSO config
- [x] Add a feature flag to toggle SSO
- [x] Add a mutation to activate/deactivate an SSO config
- [x] Add a mutation to delete an SSO config
- [x] Add strategy to use OIDC or SAML
- [ ] Improve error management

### Frontend
- [x] Add section "security" in settings
- [x] Add page to list SSO configurations
- [x] Add page and forms to create OIDC or SAML configuration
- [x] Add field to "connect with SSO" in the signin/signup process
- [x] Trigger auth when a user switch to a workspace with SSO enable
- [x] Add an option on the security page to activate/deactivate the
global invitation link
- [ ] Add new Icons for SSO Identity Providers (okta, Auth0, Azure,
Microsoft)

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Antoine Moreaux 2024-10-21 20:07:08 +02:00 committed by GitHub
parent 11c3f1c399
commit 0f0a7966b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
132 changed files with 5245 additions and 306 deletions

49
LICENSE
View File

@ -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
<https://www.gnu.org/licenses/>.
------------------------------------
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.

View File

@ -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": [
{

View File

@ -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<FindAvailableSsoidpOutput>;
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<FindAvailableSsoidpOutput>;
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<FindAvailableSsoidpOutput>;
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<Scalars['String']['output']>;
@ -1179,6 +1293,31 @@ export type SessionEntity = {
url?: Maybe<Scalars['String']['output']>;
};
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<Scalars['String']['input']>;
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<Scalars['String']['input']>;
domainName?: InputMaybe<Scalars['String']['input']>;
inviteHash?: InputMaybe<Scalars['String']['input']>;
isPublicInviteLinkEnabled?: InputMaybe<Scalars['Boolean']['input']>;
logo?: InputMaybe<Scalars['String']['input']>;
};
export type User = {
__typename?: 'User';
analyticsTinybirdJwt?: Maybe<Scalars['String']['output']>;
canImpersonate: Scalars['Boolean']['output'];
createdAt: Scalars['DateTime']['output'];
defaultAvatarUrl?: Maybe<Scalars['String']['output']>;
@ -1467,6 +1608,7 @@ export type Workspace = {
featureFlags?: Maybe<Array<FeatureFlag>>;
id: Scalars['UUID']['output'];
inviteHash?: Maybe<Scalars['String']['output']>;
isPublicInviteLinkEnabled: Scalars['Boolean']['output'];
logo?: Maybe<Scalars['String']['output']>;
metadataVersion: Scalars['Float']['output'];
updatedAt: Scalars['DateTime']['output'];
@ -1539,6 +1681,12 @@ export enum WorkspaceMemberTimeFormatEnum {
System = 'SYSTEM'
}
export type WorkspaceNameAndId = {
__typename?: 'WorkspaceNameAndId';
displayName?: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
};
export type Field = {
__typename?: 'field';
createdAt: Scalars['DateTime']['output'];

View File

@ -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<FindAvailableSsoidpOutput>;
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<FindAvailableSsoidpOutput>;
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<FindAvailableSsoidpOutput>;
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<Scalars['String']>;
@ -894,6 +1007,31 @@ export type SessionEntity = {
url?: Maybe<Scalars['String']>;
};
export type SetupOidcSsoInput = {
clientID: Scalars['String'];
clientSecret: Scalars['String'];
issuer: Scalars['String'];
name: Scalars['String'];
};
export type SetupSamlSsoInput = {
certificate: Scalars['String'];
fingerprint?: InputMaybe<Scalars['String']>;
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<Scalars['String']>;
domainName?: InputMaybe<Scalars['String']>;
inviteHash?: InputMaybe<Scalars['String']>;
isPublicInviteLinkEnabled?: InputMaybe<Scalars['Boolean']>;
logo?: InputMaybe<Scalars['String']>;
};
@ -1143,6 +1282,7 @@ export type Workspace = {
featureFlags?: Maybe<Array<FeatureFlag>>;
id: Scalars['UUID'];
inviteHash?: Maybe<Scalars['String']>;
isPublicInviteLinkEnabled: Scalars['Boolean'];
logo?: Maybe<Scalars['String']>;
metadataVersion: Scalars['Float'];
updatedAt: Scalars['DateTime'];
@ -1215,6 +1355,12 @@ export enum WorkspaceMemberTimeFormatEnum {
System = 'SYSTEM'
}
export type WorkspaceNameAndId = {
__typename?: 'WorkspaceNameAndId';
displayName?: Maybe<Scalars['String']>;
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<typeof useEmailPasswordResetLinkMutation>;
export type EmailPasswordResetLinkMutationResult = Apollo.MutationResult<EmailPasswordResetLinkMutation>;
export type EmailPasswordResetLinkMutationOptions = Apollo.BaseMutationOptions<EmailPasswordResetLinkMutation, EmailPasswordResetLinkMutationVariables>;
export const FindAvailableSsoIdentityProvidersDocument = gql`
mutation FindAvailableSSOIdentityProviders($input: FindAvailableSSOIDPInput!) {
findAvailableSSOIdentityProviders(input: $input) {
...AvailableSSOIdentityProvidersFragment
}
}
${AvailableSsoIdentityProvidersFragmentFragmentDoc}`;
export type FindAvailableSsoIdentityProvidersMutationFn = Apollo.MutationFunction<FindAvailableSsoIdentityProvidersMutation, FindAvailableSsoIdentityProvidersMutationVariables>;
/**
* __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<FindAvailableSsoIdentityProvidersMutation, FindAvailableSsoIdentityProvidersMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<FindAvailableSsoIdentityProvidersMutation, FindAvailableSsoIdentityProvidersMutationVariables>(FindAvailableSsoIdentityProvidersDocument, options);
}
export type FindAvailableSsoIdentityProvidersMutationHookResult = ReturnType<typeof useFindAvailableSsoIdentityProvidersMutation>;
export type FindAvailableSsoIdentityProvidersMutationResult = Apollo.MutationResult<FindAvailableSsoIdentityProvidersMutation>;
export type FindAvailableSsoIdentityProvidersMutationOptions = Apollo.BaseMutationOptions<FindAvailableSsoIdentityProvidersMutation, FindAvailableSsoIdentityProvidersMutationVariables>;
export const GenerateApiKeyTokenDocument = gql`
mutation GenerateApiKeyToken($apiKeyId: String!, $expiresAt: String!) {
generateApiKeyToken(apiKeyId: $apiKeyId, expiresAt: $expiresAt) {
@ -2275,12 +2516,26 @@ export type GenerateApiKeyTokenMutationOptions = Apollo.BaseMutationOptions<Gene
export const GenerateJwtDocument = gql`
mutation GenerateJWT($workspaceId: String!) {
generateJWT(workspaceId: $workspaceId) {
... on GenerateJWTOutputWithAuthTokens {
success
reason
authTokens {
tokens {
...AuthTokensFragment
}
}
}
... on GenerateJWTOutputWithSSOAUTH {
success
reason
availableSSOIDPs {
...AvailableSSOIdentityProvidersFragment
}
}
}
}
${AuthTokensFragmentFragmentDoc}`;
${AuthTokensFragmentFragmentDoc}
${AvailableSsoIdentityProvidersFragmentFragmentDoc}`;
export type GenerateJwtMutationFn = Apollo.MutationFunction<GenerateJwtMutation, GenerateJwtMutationVariables>;
/**
@ -2341,6 +2596,41 @@ export function useGenerateTransientTokenMutation(baseOptions?: Apollo.MutationH
export type GenerateTransientTokenMutationHookResult = ReturnType<typeof useGenerateTransientTokenMutation>;
export type GenerateTransientTokenMutationResult = Apollo.MutationResult<GenerateTransientTokenMutation>;
export type GenerateTransientTokenMutationOptions = Apollo.BaseMutationOptions<GenerateTransientTokenMutation, GenerateTransientTokenMutationVariables>;
export const GetAuthorizationUrlDocument = gql`
mutation GetAuthorizationUrl($input: GetAuthorizationUrlInput!) {
getAuthorizationUrl(input: $input) {
id
type
authorizationURL
}
}
`;
export type GetAuthorizationUrlMutationFn = Apollo.MutationFunction<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>;
/**
* __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<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>(GetAuthorizationUrlDocument, options);
}
export type GetAuthorizationUrlMutationHookResult = ReturnType<typeof useGetAuthorizationUrlMutation>;
export type GetAuthorizationUrlMutationResult = Apollo.MutationResult<GetAuthorizationUrlMutation>;
export type GetAuthorizationUrlMutationOptions = Apollo.BaseMutationOptions<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>;
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<typeof useSkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions<SkipSyncEmailOnboardingStepMutation, SkipSyncEmailOnboardingStepMutationVariables>;
export const CreateOidcIdentityProviderDocument = gql`
mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) {
createOIDCIdentityProvider(input: $input) {
id
type
issuer
name
status
}
}
`;
export type CreateOidcIdentityProviderMutationFn = Apollo.MutationFunction<CreateOidcIdentityProviderMutation, CreateOidcIdentityProviderMutationVariables>;
/**
* __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<CreateOidcIdentityProviderMutation, CreateOidcIdentityProviderMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateOidcIdentityProviderMutation, CreateOidcIdentityProviderMutationVariables>(CreateOidcIdentityProviderDocument, options);
}
export type CreateOidcIdentityProviderMutationHookResult = ReturnType<typeof useCreateOidcIdentityProviderMutation>;
export type CreateOidcIdentityProviderMutationResult = Apollo.MutationResult<CreateOidcIdentityProviderMutation>;
export type CreateOidcIdentityProviderMutationOptions = Apollo.BaseMutationOptions<CreateOidcIdentityProviderMutation, CreateOidcIdentityProviderMutationVariables>;
export const CreateSamlIdentityProviderDocument = gql`
mutation CreateSAMLIdentityProvider($input: SetupSAMLSsoInput!) {
createSAMLIdentityProvider(input: $input) {
id
type
issuer
name
status
}
}
`;
export type CreateSamlIdentityProviderMutationFn = Apollo.MutationFunction<CreateSamlIdentityProviderMutation, CreateSamlIdentityProviderMutationVariables>;
/**
* __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<CreateSamlIdentityProviderMutation, CreateSamlIdentityProviderMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateSamlIdentityProviderMutation, CreateSamlIdentityProviderMutationVariables>(CreateSamlIdentityProviderDocument, options);
}
export type CreateSamlIdentityProviderMutationHookResult = ReturnType<typeof useCreateSamlIdentityProviderMutation>;
export type CreateSamlIdentityProviderMutationResult = Apollo.MutationResult<CreateSamlIdentityProviderMutation>;
export type CreateSamlIdentityProviderMutationOptions = Apollo.BaseMutationOptions<CreateSamlIdentityProviderMutation, CreateSamlIdentityProviderMutationVariables>;
export const DeleteSsoIdentityProviderDocument = gql`
mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) {
deleteSSOIdentityProvider(input: $input) {
identityProviderId
}
}
`;
export type DeleteSsoIdentityProviderMutationFn = Apollo.MutationFunction<DeleteSsoIdentityProviderMutation, DeleteSsoIdentityProviderMutationVariables>;
/**
* __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<DeleteSsoIdentityProviderMutation, DeleteSsoIdentityProviderMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteSsoIdentityProviderMutation, DeleteSsoIdentityProviderMutationVariables>(DeleteSsoIdentityProviderDocument, options);
}
export type DeleteSsoIdentityProviderMutationHookResult = ReturnType<typeof useDeleteSsoIdentityProviderMutation>;
export type DeleteSsoIdentityProviderMutationResult = Apollo.MutationResult<DeleteSsoIdentityProviderMutation>;
export type DeleteSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions<DeleteSsoIdentityProviderMutation, DeleteSsoIdentityProviderMutationVariables>;
export const EditSsoIdentityProviderDocument = gql`
mutation EditSSOIdentityProvider($input: EditSsoInput!) {
editSSOIdentityProvider(input: $input) {
id
type
issuer
name
status
}
}
`;
export type EditSsoIdentityProviderMutationFn = Apollo.MutationFunction<EditSsoIdentityProviderMutation, EditSsoIdentityProviderMutationVariables>;
/**
* __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<EditSsoIdentityProviderMutation, EditSsoIdentityProviderMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<EditSsoIdentityProviderMutation, EditSsoIdentityProviderMutationVariables>(EditSsoIdentityProviderDocument, options);
}
export type EditSsoIdentityProviderMutationHookResult = ReturnType<typeof useEditSsoIdentityProviderMutation>;
export type EditSsoIdentityProviderMutationResult = Apollo.MutationResult<EditSsoIdentityProviderMutation>;
export type EditSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions<EditSsoIdentityProviderMutation, EditSsoIdentityProviderMutationVariables>;
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<ListSsoIdentityProvidersByWorkspaceIdQuery, ListSsoIdentityProvidersByWorkspaceIdQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ListSsoIdentityProvidersByWorkspaceIdQuery, ListSsoIdentityProvidersByWorkspaceIdQueryVariables>(ListSsoIdentityProvidersByWorkspaceIdDocument, options);
}
export function useListSsoIdentityProvidersByWorkspaceIdLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ListSsoIdentityProvidersByWorkspaceIdQuery, ListSsoIdentityProvidersByWorkspaceIdQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ListSsoIdentityProvidersByWorkspaceIdQuery, ListSsoIdentityProvidersByWorkspaceIdQueryVariables>(ListSsoIdentityProvidersByWorkspaceIdDocument, options);
}
export type ListSsoIdentityProvidersByWorkspaceIdQueryHookResult = ReturnType<typeof useListSsoIdentityProvidersByWorkspaceIdQuery>;
export type ListSsoIdentityProvidersByWorkspaceIdLazyQueryHookResult = ReturnType<typeof useListSsoIdentityProvidersByWorkspaceIdLazyQuery>;
export type ListSsoIdentityProvidersByWorkspaceIdQueryResult = Apollo.QueryResult<ListSsoIdentityProvidersByWorkspaceIdQuery, ListSsoIdentityProvidersByWorkspaceIdQueryVariables>;
export const DeleteUserAccountDocument = gql`
mutation DeleteUserAccount {
deleteUser {

View File

@ -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,
)}
/>
);

View File

@ -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) => (
<Suspense fallback={<SettingsSkeletonLoader />}>
<Routes>
@ -357,6 +373,15 @@ export const SettingsRoutes = ({
element={<SettingsObjectFieldEdit />}
/>
<Route path={SettingsPath.Releases} element={<Releases />} />
{isSSOEnabled && (
<>
<Route path={SettingsPath.Security} element={<SettingsSecurity />} />
<Route
path={SettingsPath.NewSSOIdentityProvider}
element={<SettingsSecuritySSOIdentifyProvider />}
/>
</>
)}
</Routes>
</Suspense>
);

View File

@ -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}
/>
}
/>

View File

@ -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
}
}
`;

View File

@ -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
}
}
`;

View File

@ -3,9 +3,22 @@ import { gql } from '@apollo/client';
export const GENERATE_JWT = gql`
mutation GenerateJWT($workspaceId: String!) {
generateJWT(workspaceId: $workspaceId) {
... on GenerateJWTOutputWithAuthTokens {
success
reason
authTokens {
tokens {
...AuthTokensFragment
}
}
}
... on GenerateJWTOutputWithSSOAUTH {
success
reason
availableSSOIDPs {
...AvailableSSOIdentityProvidersFragment
}
}
}
}
`;

View File

@ -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
}
}
`;

View File

@ -116,6 +116,7 @@ describe('useAuth', () => {
microsoft: false,
magicLink: false,
password: false,
sso: false,
});
expect(state.billing).toBeNull();
expect(state.isSignInPrefilled).toBe(false);

View File

@ -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) => (
<StyledContainer>{children}</StyledContainer>
export const FooterNote = () => (
<StyledContainer>
By using Twenty, you agree to the{' '}
<a
href="https://twenty.com/legal/terms"
target="_blank"
rel="noopener noreferrer"
>
Terms of Service
</a>{' '}
and{' '}
<a
href="https://twenty.com/legal/privacy"
target="_blank"
rel="noopener noreferrer"
>
Privacy Policy
</a>
.
</StyledContainer>
);

View File

@ -3,6 +3,7 @@ import styled from '@emotion/styled';
type HorizontalSeparatorProps = {
visible?: boolean;
text?: string;
};
const StyledSeparator = styled.div<HorizontalSeparatorProps>`
background-color: ${({ theme }) => theme.border.color.medium};
@ -12,8 +13,39 @@ const StyledSeparator = styled.div<HorizontalSeparatorProps>`
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<HorizontalSeparatorProps>`
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 ? (
<StyledSeparatorContainer>
<StyledLine visible={visible} />
{text && <StyledText>{text}</StyledText>}
<StyledLine visible={visible} />
</StyledSeparatorContainer>
) : (
<StyledSeparator visible={visible} />
)}
</>
);

View File

@ -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<HTMLInputElement>,
) => {
@ -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
/>
<HorizontalSeparator visible={!authProviders.microsoft} />
<HorizontalSeparator visible={false} />
</>
)}
@ -148,11 +161,31 @@ export const SignInUpForm = () => {
onClick={signInWithMicrosoft}
fullWidth
/>
<HorizontalSeparator visible={authProviders.password} />
<HorizontalSeparator visible={false} />
</>
)}
{authProviders.sso && (
<>
<MainButton
Icon={() => <IconKey size={theme.icon.size.lg} />}
title={
signInUpStep === SignInUpStep.SSOEmail
? 'Continue with email'
: 'Single sign-on (SSO)'
}
onClick={toggleSSOMode}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
)}
{authProviders.password && (
<HorizontalSeparator visible={true} />
{authProviders.password &&
(signInUpStep === SignInUpStep.Password ||
signInUpStep === SignInUpStep.Email ||
signInUpStep === SignInUpStep.Init) && (
<StyledForm
onSubmit={(event) => {
event.preventDefault();
@ -258,33 +291,67 @@ export const SignInUpForm = () => {
/>
</StyledForm>
)}
<StyledForm
onSubmit={(event) => {
event.preventDefault();
}}
>
{signInUpStep === SignInUpStep.SSOEmail && (
<>
<StyledFullWidthMotionDiv
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{
type: 'spring',
stiffness: 800,
damping: 35,
}}
>
<Controller
name="email"
control={form.control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<StyledInputContainer>
<TextInput
autoFocus
value={value}
placeholder="Email"
onBlur={onBlur}
onChange={onChange}
error={showErrors ? error?.message : undefined}
fullWidth
disableHotkeys
onKeyDown={handleKeyDown}
/>
</StyledInputContainer>
)}
/>
</StyledFullWidthMotionDiv>
<MainButton
variant="secondary"
title={buttonTitle}
type="submit"
onClick={async () => {
setShowErrors(true);
submitSSOEmail(form.getValues('email'));
}}
Icon={() => form.formState.isSubmitting && <Loader />}
disabled={isSubmitButtonDisabled}
fullWidth
/>
</>
)}
</StyledForm>
</StyledContentContainer>
{signInUpStep === SignInUpStep.Password && (
<ActionLink onClick={handleResetPassword(form.getValues('email'))}>
Forgot your password?
</ActionLink>
)}
{signInUpStep === SignInUpStep.Init && (
<FooterNote>
By using Twenty, you agree to the{' '}
<a
href="https://twenty.com/legal/terms"
target="_blank"
rel="noopener noreferrer"
>
Terms of Service
</a>{' '}
and{' '}
<a
href="https://twenty.com/legal/privacy"
target="_blank"
rel="noopener noreferrer"
>
Privacy Policy
</a>
.
</FooterNote>
)}
{signInUpStep === SignInUpStep.Init && <FooterNote />}
</>
);
};

View File

@ -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,
};
};

View File

@ -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<Form>) => {
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<Form>) => {
const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite));
const [signInUpStep, setSignInUpStep] = useState<SignInUpStep>(
SignInUpStep.Init,
);
const [signInUpMode, setSignInUpMode] = useState<SignInUpMode>(() => {
return isMatchingLocation(AppPath.SignInUp)
? SignInUpMode.SignIn
@ -62,7 +67,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
? 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<Form>) => {
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<Form> = useCallback(
async (data) => {
const token = await readCaptchaToken();
@ -144,6 +189,8 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
signInUpMode,
continueWithCredentials,
continueWithEmail,
continueWithSSO,
submitSSOEmail,
submitCredentials,
};
};

View File

@ -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: [],
});

View File

@ -13,6 +13,7 @@ export type CurrentWorkspace = Pick<
| 'activationStatus'
| 'currentBillingSubscription'
| 'workspaceMembersCount'
| 'isPublicInviteLinkEnabled'
| 'metadataVersion'
>;

View File

@ -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<SignInUpStep>({
key: 'signInUpStepState',
defaultValue: SignInUpStep.Init,
});

View File

@ -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);

View File

@ -7,6 +7,7 @@ export const GET_CLIENT_CONFIG = gql`
google
password
microsoft
sso
}
billing {
isBillingEnabled

View File

@ -9,5 +9,6 @@ export const authProvidersState = createState<AuthProviders>({
magicLink: false,
password: false,
microsoft: false,
sso: false,
},
});

View File

@ -17,6 +17,7 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({
allowImpersonation: false,
activationStatus: WorkspaceActivationStatus.Active,
metadataVersion: 1,
isPublicInviteLinkEnabled: false,
});
},
});

View File

@ -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."
/>
<SettingsAccountsToggleSettingCard
parameters={[
{
value: !!calendarChannel.isContactAutoCreationEnabled,
title: 'Auto-creation',
description: 'Automatically create contacts for people.',
onToggle: handleContactAutoCreationToggle,
},
]}
<Card>
<SettingsOptionCardContent
title="Auto-creation"
description="Automatically create contacts for people."
onClick={() =>
handleContactAutoCreationToggle(
!calendarChannel.isContactAutoCreationEnabled,
)
}
>
<StyledToggle
value={calendarChannel.isContactAutoCreationEnabled}
/>
</SettingsOptionCardContent>
</Card>
</Section>
</StyledDetailsContainer>
);

View File

@ -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 = ({
/>
</Section>
<Section>
<SettingsAccountsToggleSettingCard
parameters={[
{
title: 'Exclude non-professional emails',
description:
'Dont create contacts from/to Gmail, Outlook emails',
value: !!messageChannel.excludeNonProfessionalEmails,
onToggle: handleIsNonProfessionalEmailExcludedToggle,
},
{
title: 'Exclude group emails',
description: 'Dont sync emails from team@ support@ noreply@...',
value: !!messageChannel.excludeGroupEmails,
onToggle: handleIsGroupEmailExcludedToggle,
},
]}
/>
<Card>
<SettingsOptionCardContent
title="Exclude non-professional emails"
description="Dont create contacts from/to Gmail, Outlook emails"
divider
onClick={() =>
handleIsNonProfessionalEmailExcludedToggle(
!messageChannel.excludeNonProfessionalEmails,
)
}
>
<StyledToggle value={messageChannel.excludeNonProfessionalEmails} />
</SettingsOptionCardContent>
<SettingsOptionCardContent
title="Exclude group emails"
description="Dont sync emails from team@ support@ noreply@..."
onClick={() =>
handleIsGroupEmailExcludedToggle(
!messageChannel.excludeGroupEmails,
)
}
>
<StyledToggle value={messageChannel.excludeGroupEmails} />
</SettingsOptionCardContent>
</Card>
</Section>
</StyledDetailsContainer>
);

View File

@ -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) => (
<Card rounded>
{parameters.map((parameter, index) => (
<StyledCardContent
key={index}
divider={index < parameters.length - 1}
onClick={() => parameter.onToggle(!parameter.value)}
>
<div>
<StyledTitle>{parameter.title}</StyledTitle>
<StyledDescription>{parameter.description}</StyledDescription>
</div>
<StyledToggle value={parameter.value} onChange={parameter.onToggle} />
</StyledCardContent>
))}
</Card>
);

View File

@ -42,6 +42,7 @@ type SettingsListCardProps<ListItem extends { id: string }> = {
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) => (
<SettingsListItemCardContent
key={item.id}
LeftIcon={RowIcon}
LeftIcon={RowIconFn ? RowIconFn(item) : RowIcon}
label={getItemLabel(item)}
rightComponent={<RowRightComponent item={item} />}
divider={index < items.length - 1}

View File

@ -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 && (
<SettingsNavigationDrawerItem
label="Security"
path={SettingsPath.Security}
Icon={IconKey}
/>
)}
</NavigationDrawerSection>
<AnimatePresence>
{isAdvancedModeEnabled && (

View File

@ -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 (
<StyledCardContent onClick={onClick} divider={divider}>
{Icon && (
<StyledIcon>
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.md} />
</StyledIcon>
)}
<div>
<StyledTitle>{title}</StyledTitle>
<StyledDescription>{description}</StyledDescription>
</div>
{children}
</StyledCardContent>
);
};

View File

@ -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 (
<StyledRadioCardContent onClick={() => handleClick(value)}>
{Icon && <Icon size={theme.icon.size.xl} color={theme.color.gray50} />}
<span>
{title && <StyledTitle>{title}</StyledTitle>}
{description && <StyledDescription>{description}</StyledDescription>}
</span>
<StyledRadio value={value} checked={isSelected} />
</StyledRadioCardContent>
);
};

View File

@ -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 (
<StyledRadioCardContainer>
{options.map((option) => (
<SettingsRadioCard
key={option.value}
value={option.value}
isSelected={value === option.value}
handleClick={onChange}
title={option.title}
description={option.description}
Icon={option.Icon}
/>
))}
</StyledRadioCardContainer>
);
};

View File

@ -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<SettingSecurityNewSSOIdentityFormValues>();
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: <SettingsSSOOIDCForm />,
},
SAML: {
option: {
Icon: IconKey,
title: 'SAML',
value: 'SAML',
description: '',
},
form: <SettingsSSOSAMLForm />,
},
};
const getFormByType = (type: Uppercase<IdpType> | undefined) => {
switch (type) {
case IdpType.Oidc:
return IdpMap.OIDC.form;
case IdpType.Saml:
return IdpMap.SAML.form;
default:
return null;
}
};
return (
<SettingsPageContainer>
<Section>
<H2Title title="Name" description="The name of your connection" />
<StyledInputsContainer>
<Controller
name="name"
control={control}
render={({ field: { onChange, value } }) => (
<TextInput
autoComplete="off"
label="Name"
value={value}
onChange={onChange}
fullWidth
placeholder="Google OIDC"
/>
)}
/>
</StyledInputsContainer>
</Section>
<Section>
<H2Title
title="Type"
description="Choose between OIDC and SAML protocols"
/>
<StyledInputsContainer>
<Controller
name="type"
control={control}
render={({ field: { onChange, value } }) => (
<SettingsRadioCardContainer
value={value}
options={Object.values(IdpMap).map(
(identityProviderType) => identityProviderType.option,
)}
onChange={onChange}
/>
)}
/>
</StyledInputsContainer>
</Section>
{getFormByType(getValues().type)}
</SettingsPageContainer>
);
};
export default SettingsSSOIdentitiesProvidersForm;

View File

@ -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 ? (
<SettingsSSOIdentitiesProvidersListEmptyStateCard />
) : (
<SettingsListCard
items={SSOIdentitiesProviders}
getItemLabel={(SSOIdentityProvider) =>
`${SSOIdentityProvider.name} - ${SSOIdentityProvider.type}`
}
isLoading={loading}
RowIconFn={(SSOIdentityProvider) =>
guessSSOIdentityProviderIconByUrl(SSOIdentityProvider.issuer)
}
RowRightComponent={({ item: SSOIdp }) => (
<SettingsSSOIdentityProviderRowRightContainer SSOIdp={SSOIdp} />
)}
hasFooter
footerButtonLabel="Add SSO Identity Provider"
onFooterButtonClick={() =>
navigate(getSettingsPagePath(SettingsPath.NewSSOIdentityProvider))
}
/>
);
};

View File

@ -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 (
<Card>
<StyledHeader>{'No SSO Identity Providers Configured'}</StyledHeader>
<StyledBody>
<Button
Icon={IconKey}
title="Add SSO Identity Provider"
variant="secondary"
to={getSettingsPagePath(SettingsPath.NewSSOIdentityProvider)}
/>
</StyledBody>
</Card>
);
};

View File

@ -0,0 +1,31 @@
/* @license Enterprise */
import { SettingsSecuritySSORowDropdownMenu } from '@/settings/security/components/SettingsSecuritySSORowDropdownMenu';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { getColorBySSOIdentityProviderStatus } from '@/settings/security/utils/getColorBySSOIdentityProviderStatus';
import { Status } from '@/ui/display/status/components/Status';
import styled from '@emotion/styled';
import { UnwrapRecoilValue } from 'recoil';
const StyledRowRightContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsSSOIdentityProviderRowRightContainer = ({
SSOIdp,
}: {
SSOIdp: UnwrapRecoilValue<typeof SSOIdentitiesProvidersState>[0];
}) => {
return (
<StyledRowRightContainer>
<Status
color={getColorBySSOIdentityProviderStatus[SSOIdp.status]}
text={SSOIdp.status}
weight="medium"
/>
<SettingsSecuritySSORowDropdownMenu SSOIdp={SSOIdp} />
</StyledRowRightContainer>
);
};

View File

@ -0,0 +1,154 @@
/* @license Enterprise */
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { Section } from '@/ui/layout/section/components/Section';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Controller, useFormContext } from 'react-hook-form';
import { H2Title, IconCopy } from 'twenty-ui';
const StyledInputsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2, 4)};
width: 100%;
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
`;
const StyledLinkContainer = styled.div`
flex: 1;
margin-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledButtonCopy = styled.div`
align-items: end;
display: flex;
`;
export const SettingsSSOOIDCForm = () => {
const { control } = useFormContext();
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
const authorizedUrl = window.location.origin;
const redirectionUrl = `${window.location.origin}/auth/oidc/callback`;
return (
<>
<Section>
<H2Title
title="Client Settings"
description="Provide your OIDC provider details"
/>
<StyledInputsContainer>
<StyledContainer>
<StyledLinkContainer>
<TextInput
readOnly={true}
label="Authorized URI"
value={authorizedUrl}
fullWidth
/>
</StyledLinkContainer>
<StyledButtonCopy>
<Button
Icon={IconCopy}
title="Copy"
onClick={() => {
enqueueSnackBar('Authorized Url copied to clipboard', {
variant: SnackBarVariant.Success,
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(authorizedUrl);
}}
/>
</StyledButtonCopy>
</StyledContainer>
<StyledContainer>
<StyledLinkContainer>
<TextInput
readOnly={true}
label="Redirection URI"
value={redirectionUrl}
fullWidth
/>
</StyledLinkContainer>
<StyledButtonCopy>
<Button
Icon={IconCopy}
title="Copy"
onClick={() => {
enqueueSnackBar('Redirect Url copied to clipboard', {
variant: SnackBarVariant.Success,
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(redirectionUrl);
}}
/>
</StyledButtonCopy>
</StyledContainer>
</StyledInputsContainer>
</Section>
<Section>
<H2Title
title="Identity Provider"
description="Enter the credentials to set the connection"
/>
<StyledInputsContainer>
<Controller
name="clientID"
control={control}
render={({ field: { onChange, value } }) => (
<TextInput
autoComplete="off"
label="Client ID"
value={value}
onChange={onChange}
fullWidth
placeholder="900960562328-36306ohbk8e3.apps.googleusercontent.com"
/>
)}
/>
<Controller
name="clientSecret"
control={control}
render={({ field: { onChange, value } }) => (
<TextInput
autoComplete="off"
type="password"
label="Client Secret"
value={value}
onChange={onChange}
fullWidth
placeholder="****************************"
/>
)}
/>
<Controller
name="issuer"
control={control}
render={({ field: { onChange, value } }) => (
<TextInput
autoComplete="off"
label="Issuer URI"
value={value}
onChange={onChange}
fullWidth
placeholder="https://accounts.google.com"
/>
)}
/>
</StyledInputsContainer>
</Section>
</>
);
};

View File

@ -0,0 +1,212 @@
/* @license Enterprise */
import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator';
import { parseSAMLMetadataFromXMLFile } from '@/settings/security/utils/parseSAMLMetadataFromXMLFile';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { Section } from '@/ui/layout/section/components/Section';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ChangeEvent, useRef } from 'react';
import { useFormContext } from 'react-hook-form';
import {
H2Title,
IconCheck,
IconCopy,
IconDownload,
IconUpload,
} from 'twenty-ui';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { isDefined } from '~/utils/isDefined';
const StyledUploadFileContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledFileInput = styled.input`
display: none;
`;
const StyledInputsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2, 4)};
width: 100%;
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
`;
const StyledLinkContainer = styled.div`
flex: 1;
margin-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledButtonCopy = styled.div`
align-items: end;
display: flex;
`;
export const SettingsSSOSAMLForm = () => {
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
const { setValue, getValues, watch } = useFormContext();
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (isDefined(e.target.files)) {
const text = await e.target.files[0].text();
const samlMetadataParsed = parseSAMLMetadataFromXMLFile(text);
if (!samlMetadataParsed.success) {
enqueueSnackBar('Invalid File', {
variant: SnackBarVariant.Error,
duration: 2000,
});
return;
}
setValue('ssoURL', samlMetadataParsed.data.ssoUrl);
setValue('certificate', samlMetadataParsed.data.certificate);
setValue('issuer', samlMetadataParsed.data.entityID);
}
};
const entityID = `${REACT_APP_SERVER_BASE_URL}/auth/saml/login/${getValues('id')}`;
const acsUrl = `${REACT_APP_SERVER_BASE_URL}/auth/saml/callback`;
const inputFileRef = useRef<HTMLInputElement>(null);
const handleUploadFileClick = () => {
inputFileRef?.current?.click?.();
};
const ssoURL = watch('ssoURL');
const certificate = watch('certificate');
const issuer = watch('issuer');
const isXMLMetadataValid = () => {
return [ssoURL, certificate, issuer].every(
(field) => isDefined(field) && field.length > 0,
);
};
const downloadMetadata = async () => {
const response = await fetch(
`${REACT_APP_SERVER_BASE_URL}/auth/saml/metadata/${getValues('id')}`,
);
if (!response.ok) {
return enqueueSnackBar('Metadata file generation failed', {
variant: SnackBarVariant.Error,
duration: 2000,
});
}
const text = await response.text();
const blob = new Blob([text], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'metadata.xml';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<>
<Section>
<H2Title
title="Identity Provider Metadata XML"
description="Upload the XML file with your connection infos"
/>
<StyledUploadFileContainer>
<StyledFileInput
ref={inputFileRef}
onChange={handleFileChange}
type="file"
accept=".xml"
/>
<Button
Icon={IconUpload}
onClick={handleUploadFileClick}
title="Upload file"
></Button>
{isXMLMetadataValid() && (
<IconCheck
size={theme.icon.size.md}
stroke={theme.icon.stroke.lg}
color={theme.color.blue}
/>
)}
</StyledUploadFileContainer>
</Section>
<Section>
<H2Title
title="Service Provider Details"
description="Enter the infos to set the connection"
/>
<StyledInputsContainer>
<StyledContainer>
<Button
Icon={IconDownload}
onClick={downloadMetadata}
title="Download file"
></Button>
</StyledContainer>
<HorizontalSeparator visible={true} text={'Or'} />
<StyledContainer>
<StyledLinkContainer>
<TextInput
disabled={true}
label="ACS Url"
value={acsUrl}
fullWidth
/>
</StyledLinkContainer>
<StyledButtonCopy>
<Button
Icon={IconCopy}
title="Copy"
onClick={() => {
enqueueSnackBar('ACS Url copied to clipboard', {
variant: SnackBarVariant.Success,
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(acsUrl);
}}
/>
</StyledButtonCopy>
</StyledContainer>
<StyledContainer>
<StyledLinkContainer>
<TextInput
disabled={true}
label="Entity ID"
value={entityID}
fullWidth
/>
</StyledLinkContainer>
<StyledButtonCopy>
<Button
Icon={IconCopy}
title="Copy"
onClick={() => {
enqueueSnackBar('Entity ID copied to clipboard', {
variant: SnackBarVariant.Success,
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(entityID);
}}
/>
</StyledButtonCopy>
</StyledContainer>
</StyledInputsContainer>
</Section>
</>
);
};

View File

@ -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 (
<Card>
<SettingsOptionCardContent
Icon={IconLink}
title="Invite by Link"
description="Allow the invitation of new users by sharing an invite link."
onClick={() =>
handleChange(!currentWorkspace?.isPublicInviteLinkEnabled)
}
>
<StyledToggle value={currentWorkspace?.isPublicInviteLinkEnabled} />
</SettingsOptionCardContent>
</Card>
);
};

View File

@ -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<typeof SSOIdentitiesProvidersState>[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 (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
dropdownHotkeyScope={{ scope: dropdownId }}
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownMenu>
<DropdownMenuItemsContainer>
<MenuItem
accent="default"
LeftIcon={IconArchive}
text={SSOIdp.status === 'Active' ? 'Deactivate' : 'Activate'}
onClick={() => {
toggleSSOIdentityProviderStatus(SSOIdp.id);
closeDropdown();
}}
/>
<MenuItem
accent="danger"
LeftIcon={IconTrash}
text="Delete"
onClick={() => {
handleDeleteSSOIdentityProvider(SSOIdp.id);
closeDropdown();
}}
/>
</DropdownMenuItemsContainer>
</DropdownMenu>
}
/>
);
};

View File

@ -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
}
}
`;

View File

@ -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
}
}
`;

View File

@ -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
}
}
`;

View File

@ -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
}
}
`;

View File

@ -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
}
}
`;

View File

@ -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 }) => (
<RecoilRoot>{children}</RecoilRoot>
);
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 },
);
});
});

View File

@ -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 }) => (
<RecoilRoot>{children}</RecoilRoot>
);
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' },
},
});
});
});

View File

@ -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 }) => (
<RecoilRoot>{children}</RecoilRoot>
);
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,
},
});
});
});

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -0,0 +1,11 @@
/* @license Enterprise */
import { SSOIdentityProvider } from '@/settings/security/types/SSOIdentityProvider';
import { createState } from 'twenty-ui';
export const SSOIdentitiesProvidersState = createState<
Omit<SSOIdentityProvider, '__typename'>[]
>({
key: 'SSOIdentitiesProvidersState',
defaultValue: [],
});

View File

@ -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
>;

View File

@ -0,0 +1,39 @@
/* @license Enterprise */
import { parseSAMLMetadataFromXMLFile } from '../parseSAMLMetadataFromXMLFile';
describe('parseSAMLMetadataFromXMLFile', () => {
it('should parse SAML metadata from XML file', () => {
const xmlString = `<?xml version="1.0" encoding="UTF-8"?><md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://test.com" validUntil="2026-02-04T17:46:23.000Z">
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>test</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://test.com"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://test.com"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>`;
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'),
});
});
});

View File

@ -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',
};

View File

@ -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;
};

View File

@ -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<typeof validator> }
| { 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 };
}
};

View File

@ -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: '',
}),
};

View File

@ -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(),
);

View File

@ -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',

View File

@ -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 }) => {

View File

@ -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<HTMLDivElement>) => void;
color?: string;
toggleSize?: ToggleSize;
className?: string;

View File

@ -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;
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 };

View File

@ -24,6 +24,7 @@ export const USER_QUERY_FRAGMENT = gql`
inviteHash
allowImpersonation
activationStatus
isPublicInviteLinkEnabled
featureFlags {
id
key

View File

@ -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 }) => (
<RecoilRoot>{children}</RecoilRoot>
);
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,
});
});
});

View File

@ -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 }) => (
<RecoilRoot>{children}</RecoilRoot>
);
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,
});
});
});

View File

@ -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 }) => (
<RecoilRoot>{children}</RecoilRoot>
);
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,
});
});
});

View File

@ -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 = () => {

View File

@ -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';

View File

@ -91,25 +91,7 @@ export const Invite = () => {
fullWidth
/>
</StyledContentContainer>
<FooterNote>
By using Twenty, you agree to the{' '}
<a
href="https://twenty.com/legal/terms"
target="_blank"
rel="noopener noreferrer"
>
Terms of Service
</a>{' '}
and{' '}
<a
href="https://twenty.com/legal/privacy"
target="_blank"
rel="noopener noreferrer"
>
Privacy Policy
</a>
.
</FooterNote>
<FooterNote />
</>
) : (
<SignInUpForm />

View File

@ -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<string, typeof availableSSOIdentityProviders>,
);
return (
<>
<StyledContentContainer>
{Object.values(availableWorkspacesForSSOGroupByWorkspace).map(
(idps) => (
<>
<StyledTitle>
{idps[0].workspace.displayName ?? DEFAULT_WORKSPACE_NAME}
</StyledTitle>
<HorizontalSeparator visible={false} />
{idps.map((idp) => (
<>
<MainButton
title={idp.name}
onClick={() => redirectToSSOLoginPage(idp.id)}
Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
))}
</>
),
)}
</StyledContentContainer>
<FooterNote />
</>
);
};

View File

@ -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 (
<>
<AnimatedEaseIn>
{signInUpStep === SignInUpStep.SSOWorkspaceSelection ? (
<IconLockCustom size={40} />
) : (
<Logo />
)}
</AnimatedEaseIn>
<Title animate>{title}</Title>
{signInUpStep === SignInUpStep.SSOWorkspaceSelection ? (
<SSOWorkspaceSelection />
) : (
<SignInUpForm />
)}
</>
);
};

View File

@ -148,7 +148,8 @@ export const SettingsWorkspaceMembers = () => {
]}
>
<SettingsPageContainer>
{currentWorkspace?.inviteHash && (
{currentWorkspace?.inviteHash &&
currentWorkspace?.isPublicInviteLinkEnabled && (
<Section>
<H2Title
title="Invite by link"

View File

@ -0,0 +1,40 @@
import { H2Title } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsReadDocumentationButton } from '@/settings/developers/components/SettingsReadDocumentationButton';
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListCard';
import { SettingsSecurityOptionsList } from '@/settings/security/components/SettingsSecurityOptionsList';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
export const SettingsSecurity = () => {
return (
<SubMenuTopBarContainer
title="Security"
actionButton={<SettingsReadDocumentationButton />}
links={[
{
children: 'Workspace',
href: getSettingsPagePath(SettingsPath.Workspace),
},
{ children: 'Security' },
]}
>
<SettingsPageContainer>
<Section>
<H2Title title="SSO" description="Configure an SSO connection" />
<SettingsSSOIdentitiesProvidersListCard />
</Section>
<Section>
<H2Title
title="Other"
description="Customize your workspace security"
/>
<SettingsSecurityOptionsList />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -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<SettingSecurityNewSSOIdentityFormValues>({
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 (
<SubMenuTopBarContainer
title="New SSO Configuration"
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!formConfig.formState.isValid}
onCancel={() => navigate(getSettingsPagePath(SettingsPath.Security))}
onSave={handleSave}
/>
}
links={[
{
children: 'Workspace',
href: getSettingsPagePath(SettingsPath.Workspace),
},
{
children: 'Security',
href: getSettingsPagePath(SettingsPath.Security),
},
{ children: 'New' },
]}
>
<FormProvider
// eslint-disable-next-line react/jsx-props-no-spreading
{...formConfig}
>
<SettingsSSOIdentitiesProvidersForm />
</FormProvider>
</SubMenuTopBarContainer>
);
};

View File

@ -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,

View File

@ -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: [

View File

@ -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

View File

@ -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",

View File

@ -60,6 +60,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IsSSOEnabled,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IsGmailSendEmailScopeEnabled,
workspaceId: workspaceId,

View File

@ -0,0 +1,66 @@
/* @license Enterprise */
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddWorkspaceSSOIdentityProvider1727181198403
implements MigrationInterface
{
name = 'AddWorkspaceSSOIdentityProvider1727181198403';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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";
`);
}
}

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIsPublicInviteLinkEnabledOnWorkspace1728986317196
implements MigrationInterface
{
name = 'AddIsPublicInviteLinkEnabledOnWorkspace1728986317196';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "isPublicInviteLinkEnabled" boolean NOT NULL DEFAULT true`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "isPublicInviteLinkEnabled"`,
);
}
}

View File

@ -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')

View File

@ -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' })

View File

@ -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',
}

View File

@ -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,

View File

@ -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<AuthTokens> {
const token = await this.tokenService.generateSwitchWorkspaceToken(
): Promise<GenerateJWTOutputWithAuthTokens | GenerateJWTOutputWithSSOAUTH> {
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)

View File

@ -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<WorkspaceSSOIdentityProvider>,
) {}
@Get('saml/metadata/:identityProviderId')
@UseGuards(SSOProviderEnabledGuard)
async generateMetadata(@Req() req: any): Promise<string> {
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<string, string>;
}) {
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);
}
}

View File

@ -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<FindAvailableSSOIDPOutput>;
}
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;
},
});

View File

@ -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<boolean> {
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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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<boolean> | Observable<boolean> {
if (!this.environmentService.get('ENTERPRISE_KEY')) {
throw new AuthException(
'Enterprise key must be defined to use SSO',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return true;
}
}

View File

@ -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<Workspace>,

View File

@ -225,23 +225,45 @@ export class SignInUpService {
email,
}) {
if (!workspacePersonalInviteToken && !workspaceInviteHash) {
throw new Error('No invite token or hash provided');
}
if (!workspacePersonalInviteToken && workspaceInviteHash) {
return (
(await this.workspaceRepository.findOneBy({
inviteHash: workspaceInviteHash,
})) ?? undefined
throw new AuthException(
'No invite token or hash provided',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const appToken = await this.userWorkspaceService.validateInvitation(
const workspace = await this.workspaceRepository.findOneBy({
inviteHash: workspaceInviteHash,
});
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
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(

View File

@ -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);
}
};
}

View File

@ -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<string, string>;
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);
};
}

View File

@ -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: {

View File

@ -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<Workspace>,
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<AuthTokens> {
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<AuthTokens> {
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 {

View File

@ -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],

View File

@ -15,6 +15,9 @@ class AuthProviders {
@Field(() => Boolean)
microsoft: boolean;
@Field(() => Boolean)
sso: boolean;
}
@ObjectType()

Some files were not shown because too many files have changed in this diff Show More