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
132 changed files with 5245 additions and 306 deletions

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) {
tokens {
...AuthTokensFragment
... 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,8 +3,21 @@ import { gql } from '@apollo/client';
export const GENERATE_JWT = gql`
mutation GenerateJWT($workspaceId: String!) {
generateJWT(workspaceId: $workspaceId) {
tokens {
...AuthTokensFragment
... on GenerateJWTOutputWithAuthTokens {
success
reason
authTokens {
tokens {
...AuthTokensFragment
}
}
}
... on GenerateJWTOutputWithSSOAUTH {
success
reason
availableSSOIDPs {
...AvailableSSOIdentityProvidersFragment
}
}
}
}

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 => (
<StyledSeparator visible={visible} />
<>
{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,17 +161,143 @@ 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 && (
<StyledForm
onSubmit={(event) => {
event.preventDefault();
}}
>
{signInUpStep !== SignInUpStep.Init && (
<HorizontalSeparator visible={true} />
{authProviders.password &&
(signInUpStep === SignInUpStep.Password ||
signInUpStep === SignInUpStep.Email ||
signInUpStep === SignInUpStep.Init) && (
<StyledForm
onSubmit={(event) => {
event.preventDefault();
}}
>
{signInUpStep !== SignInUpStep.Init && (
<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={(value: string) => {
onChange(value);
if (signInUpStep === SignInUpStep.Password) {
continueWithEmail();
}
}}
error={showErrors ? error?.message : undefined}
fullWidth
disableHotkeys
onKeyDown={handleKeyDown}
/>
</StyledInputContainer>
)}
/>
</StyledFullWidthMotionDiv>
)}
{signInUpStep === SignInUpStep.Password && (
<StyledFullWidthMotionDiv
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{
type: 'spring',
stiffness: 800,
damping: 35,
}}
>
<Controller
name="password"
control={form.control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<StyledInputContainer>
<TextInput
autoFocus
value={value}
type="password"
placeholder="Password"
onBlur={onBlur}
onChange={onChange}
error={showErrors ? error?.message : undefined}
fullWidth
disableHotkeys
onKeyDown={handleKeyDown}
/>
</StyledInputContainer>
)}
/>
</StyledFullWidthMotionDiv>
)}
<MainButton
variant="secondary"
title={buttonTitle}
type="submit"
onClick={async () => {
if (signInUpStep === SignInUpStep.Init) {
continueWithEmail();
return;
}
if (signInUpStep === SignInUpStep.Email) {
if (isDefined(form?.formState?.errors?.email)) {
setShowErrors(true);
return;
}
continueWithCredentials();
return;
}
setShowErrors(true);
form.handleSubmit(submitCredentials)();
}}
Icon={() => form.formState.isSubmitting && <Loader />}
disabled={isSubmitButtonDisabled}
fullWidth
/>
</StyledForm>
)}
<StyledForm
onSubmit={(event) => {
event.preventDefault();
}}
>
{signInUpStep === SignInUpStep.SSOEmail && (
<>
<StyledFullWidthMotionDiv
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
@ -181,46 +320,6 @@ export const SignInUpForm = () => {
value={value}
placeholder="Email"
onBlur={onBlur}
onChange={(value: string) => {
onChange(value);
if (signInUpStep === SignInUpStep.Password) {
continueWithEmail();
}
}}
error={showErrors ? error?.message : undefined}
fullWidth
disableHotkeys
onKeyDown={handleKeyDown}
/>
</StyledInputContainer>
)}
/>
</StyledFullWidthMotionDiv>
)}
{signInUpStep === SignInUpStep.Password && (
<StyledFullWidthMotionDiv
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{
type: 'spring',
stiffness: 800,
damping: 35,
}}
>
<Controller
name="password"
control={form.control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<StyledInputContainer>
<TextInput
autoFocus
value={value}
type="password"
placeholder="Password"
onBlur={onBlur}
onChange={onChange}
error={showErrors ? error?.message : undefined}
fullWidth
@ -231,60 +330,28 @@ export const SignInUpForm = () => {
)}
/>
</StyledFullWidthMotionDiv>
)}
<MainButton
variant="secondary"
title={buttonTitle}
type="submit"
onClick={async () => {
if (signInUpStep === SignInUpStep.Init) {
continueWithEmail();
return;
}
if (signInUpStep === SignInUpStep.Email) {
if (isDefined(form?.formState?.errors?.email)) {
setShowErrors(true);
return;
}
continueWithCredentials();
return;
}
setShowErrors(true);
form.handleSubmit(submitCredentials)();
}}
Icon={() => form.formState.isSubmitting && <Loader />}
disabled={isSubmitButtonDisabled}
fullWidth
/>
</StyledForm>
)}
<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;
setTokenPair(tokens);
await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly.
window.location.href = AppPath.Index;
if (
jwt.data.generateJWT.reason === 'WORKSPACE_USE_SSO_AUTH' &&
'availableSSOIDPs' in jwt.data.generateJWT
) {
if (jwt.data.generateJWT.availableSSOIDPs.length === 1) {
redirectToSSOLoginPage(jwt.data.generateJWT.availableSSOIDPs[0].id);
}
if (jwt.data.generateJWT.availableSSOIDPs.length > 1) {
await signOut();
setAvailableWorkspacesForSSOState(
jwt.data.generateJWT.availableSSOIDPs,
);
setSignInUpStep(SignInUpStep.SSOWorkspaceSelection);
}
return;
}
if (
jwt.data.generateJWT.reason !== 'WORKSPACE_USE_SSO_AUTH' &&
'authTokens' in jwt.data.generateJWT
) {
const { tokens } = jwt.data.generateJWT.authTokens;
setTokenPair(tokens);
await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly.
window.location.href = AppPath.Index;
}
};
return { switchWorkspace };

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

View File

@ -148,17 +148,18 @@ export const SettingsWorkspaceMembers = () => {
]}
>
<SettingsPageContainer>
{currentWorkspace?.inviteHash && (
<Section>
<H2Title
title="Invite by link"
description="Share this link to invite users to join your workspace"
/>
<WorkspaceInviteLink
inviteLink={`${window.location.origin}/invite/${currentWorkspace?.inviteHash}`}
/>
</Section>
)}
{currentWorkspace?.inviteHash &&
currentWorkspace?.isPublicInviteLinkEnabled && (
<Section>
<H2Title
title="Invite by link"
description="Share this link to invite users to join your workspace"
/>
<WorkspaceInviteLink
inviteLink={`${window.location.origin}/invite/${currentWorkspace?.inviteHash}`}
/>
</Section>
)}
<Section>
<H2Title
title="Members"

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(
workspacePersonalInviteToken,
email,
);
const workspace = await this.workspaceRepository.findOneBy({
inviteHash: workspaceInviteHash,
});
return appToken?.workspace;
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 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()

View File

@ -16,6 +16,7 @@ export class ClientConfigResolver {
magicLink: false,
password: this.environmentService.get('AUTH_PASSWORD_ENABLED'),
microsoft: this.environmentService.get('AUTH_MICROSOFT_ENABLED'),
sso: this.environmentService.get('AUTH_SSO_ENABLED'),
},
billing: {
isBillingEnabled: this.environmentService.get('IS_BILLING_ENABLED'),

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