mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-22 19:41:53 +03:00
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:
parent
11c3f1c399
commit
0f0a7966b1
49
LICENSE
49
LICENSE
@ -1,3 +1,8 @@
|
||||
|
||||
This project is mostly licensed under the GNU General Public License (GPL) as described below. However, certain files within this project are licensed under a different commercial license. These files are clearly marked with the following comment at the top of the file: /* @license Enterprise */
|
||||
Files with this comment are not licensed under the aGPL v3, but instead are subject to the commercial license terms defined later in this file.
|
||||
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
@ -659,3 +664,47 @@ specific requirements.
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
|
||||
------------------------------------
|
||||
|
||||
|
||||
|
||||
The Twenty.com Commercial License (the “Commercial License”)
|
||||
Copyright (c) 2023-present Twenty.com, PBC
|
||||
|
||||
With regard to Twenty's Software:
|
||||
|
||||
This part of the software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Terms available
|
||||
at https://twenty.com/legal/terms, or other agreements governing
|
||||
the use of the Software, as mutually agreed by you and Twenty.com, PBC ("Twenty"),
|
||||
and otherwise have a valid Twenty Enterprise Edition subscription
|
||||
for the correct number of hosts and seats as defined in the Commercial Terms.
|
||||
Subject to the foregoing sentence,
|
||||
you are free to modify this Software and publish patches to the Software. You agree
|
||||
that Twenty and/or its licensors (as applicable) retain all right, title and interest in
|
||||
and to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Commercial Subscription for the correct number of hosts and seats.
|
||||
Notwithstanding the foregoing, you may copy and modify the Software for development
|
||||
and testing purposes, without requiring a subscription. You agree that Twenty.Com and/or
|
||||
its licensors (as applicable) retain all right, title and interest in and to all such
|
||||
modifications. You are not granted any other rights beyond what is expressly stated herein.
|
||||
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Twenty Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
@ -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": [
|
||||
{
|
||||
|
@ -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'];
|
||||
|
@ -64,6 +64,7 @@ export type AuthProviders = {
|
||||
magicLink: Scalars['Boolean'];
|
||||
microsoft: Scalars['Boolean'];
|
||||
password: Scalars['Boolean'];
|
||||
sso: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type AuthToken = {
|
||||
@ -180,6 +181,15 @@ export type DeleteServerlessFunctionInput = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
||||
export type DeleteSsoInput = {
|
||||
identityProviderId: Scalars['String'];
|
||||
};
|
||||
|
||||
export type DeleteSsoOutput = {
|
||||
__typename?: 'DeleteSsoOutput';
|
||||
identityProviderId: Scalars['String'];
|
||||
};
|
||||
|
||||
/** Schema update on a table */
|
||||
export enum DistantTableUpdate {
|
||||
ColumnsAdded = 'COLUMNS_ADDED',
|
||||
@ -188,6 +198,20 @@ export enum DistantTableUpdate {
|
||||
TableDeleted = 'TABLE_DELETED'
|
||||
}
|
||||
|
||||
export type EditSsoInput = {
|
||||
id: Scalars['String'];
|
||||
status: SsoIdentityProviderStatus;
|
||||
};
|
||||
|
||||
export type EditSsoOutput = {
|
||||
__typename?: 'EditSsoOutput';
|
||||
id: Scalars['String'];
|
||||
issuer: Scalars['String'];
|
||||
name: Scalars['String'];
|
||||
status: SsoIdentityProviderStatus;
|
||||
type: IdpType;
|
||||
};
|
||||
|
||||
export type EmailPasswordResetLink = {
|
||||
__typename?: 'EmailPasswordResetLink';
|
||||
/** Boolean that confirms query was dispatched */
|
||||
@ -277,12 +301,53 @@ export enum FileFolder {
|
||||
WorkspaceLogo = 'WorkspaceLogo'
|
||||
}
|
||||
|
||||
export type FindAvailableSsoidpInput = {
|
||||
email: Scalars['String'];
|
||||
};
|
||||
|
||||
export type FindAvailableSsoidpOutput = {
|
||||
__typename?: 'FindAvailableSSOIDPOutput';
|
||||
id: Scalars['String'];
|
||||
issuer: Scalars['String'];
|
||||
name: Scalars['String'];
|
||||
status: SsoIdentityProviderStatus;
|
||||
type: IdpType;
|
||||
workspace: WorkspaceNameAndId;
|
||||
};
|
||||
|
||||
export type FullName = {
|
||||
__typename?: 'FullName';
|
||||
firstName: Scalars['String'];
|
||||
lastName: Scalars['String'];
|
||||
};
|
||||
|
||||
export type GenerateJwt = GenerateJwtOutputWithAuthTokens | GenerateJwtOutputWithSsoauth;
|
||||
|
||||
export type GenerateJwtOutputWithAuthTokens = {
|
||||
__typename?: 'GenerateJWTOutputWithAuthTokens';
|
||||
authTokens: AuthTokens;
|
||||
reason: Scalars['String'];
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type GenerateJwtOutputWithSsoauth = {
|
||||
__typename?: 'GenerateJWTOutputWithSSOAUTH';
|
||||
availableSSOIDPs: Array<FindAvailableSsoidpOutput>;
|
||||
reason: Scalars['String'];
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type GetAuthorizationUrlInput = {
|
||||
identityProviderId: Scalars['String'];
|
||||
};
|
||||
|
||||
export type GetAuthorizationUrlOutput = {
|
||||
__typename?: 'GetAuthorizationUrlOutput';
|
||||
authorizationURL: Scalars['String'];
|
||||
id: Scalars['String'];
|
||||
type: Scalars['String'];
|
||||
};
|
||||
|
||||
export type GetServerlessFunctionSourceCodeInput = {
|
||||
/** The id of the function. */
|
||||
id: Scalars['ID'];
|
||||
@ -290,6 +355,11 @@ export type GetServerlessFunctionSourceCodeInput = {
|
||||
version?: Scalars['String'];
|
||||
};
|
||||
|
||||
export enum IdpType {
|
||||
Oidc = 'OIDC',
|
||||
Saml = 'SAML'
|
||||
}
|
||||
|
||||
export type IndexConnection = {
|
||||
__typename?: 'IndexConnection';
|
||||
/** Array of edges. */
|
||||
@ -359,23 +429,29 @@ export type Mutation = {
|
||||
authorizeApp: AuthorizeApp;
|
||||
challenge: LoginToken;
|
||||
checkoutSession: SessionEntity;
|
||||
createOIDCIdentityProvider: SetupSsoOutput;
|
||||
createOneAppToken: AppToken;
|
||||
createOneObject: Object;
|
||||
createOneServerlessFunction: ServerlessFunction;
|
||||
createSAMLIdentityProvider: SetupSsoOutput;
|
||||
deactivateWorkflowVersion: Scalars['Boolean'];
|
||||
deleteCurrentWorkspace: Workspace;
|
||||
deleteOneObject: Object;
|
||||
deleteOneServerlessFunction: ServerlessFunction;
|
||||
deleteSSOIdentityProvider: DeleteSsoOutput;
|
||||
deleteUser: User;
|
||||
deleteWorkspaceInvitation: Scalars['String'];
|
||||
disablePostgresProxy: PostgresCredentials;
|
||||
editSSOIdentityProvider: EditSsoOutput;
|
||||
emailPasswordResetLink: EmailPasswordResetLink;
|
||||
enablePostgresProxy: PostgresCredentials;
|
||||
exchangeAuthorizationCode: ExchangeAuthCode;
|
||||
executeOneServerlessFunction: ServerlessFunctionExecutionResult;
|
||||
findAvailableSSOIdentityProviders: Array<FindAvailableSsoidpOutput>;
|
||||
generateApiKeyToken: ApiKeyToken;
|
||||
generateJWT: AuthTokens;
|
||||
generateJWT: GenerateJwt;
|
||||
generateTransientToken: TransientToken;
|
||||
getAuthorizationUrl: GetAuthorizationUrlOutput;
|
||||
impersonate: Verify;
|
||||
publishServerlessFunction: ServerlessFunction;
|
||||
renewToken: AuthTokens;
|
||||
@ -438,11 +514,21 @@ export type MutationCheckoutSessionArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateOidcIdentityProviderArgs = {
|
||||
input: SetupOidcSsoInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateOneServerlessFunctionArgs = {
|
||||
input: CreateServerlessFunctionInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateSamlIdentityProviderArgs = {
|
||||
input: SetupSamlSsoInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeactivateWorkflowVersionArgs = {
|
||||
workflowVersionId: Scalars['String'];
|
||||
};
|
||||
@ -458,11 +544,21 @@ export type MutationDeleteOneServerlessFunctionArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteSsoIdentityProviderArgs = {
|
||||
input: DeleteSsoInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteWorkspaceInvitationArgs = {
|
||||
appTokenId: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationEditSsoIdentityProviderArgs = {
|
||||
input: EditSsoInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationEmailPasswordResetLinkArgs = {
|
||||
email: Scalars['String'];
|
||||
};
|
||||
@ -480,6 +576,11 @@ export type MutationExecuteOneServerlessFunctionArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationFindAvailableSsoIdentityProvidersArgs = {
|
||||
input: FindAvailableSsoidpInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationGenerateApiKeyTokenArgs = {
|
||||
apiKeyId: Scalars['String'];
|
||||
expiresAt: Scalars['String'];
|
||||
@ -491,6 +592,11 @@ export type MutationGenerateJwtArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationGetAuthorizationUrlArgs = {
|
||||
input: GetAuthorizationUrlInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationImpersonateArgs = {
|
||||
userId: Scalars['String'];
|
||||
};
|
||||
@ -682,6 +788,7 @@ export type Query = {
|
||||
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
|
||||
index: Index;
|
||||
indexMetadatas: IndexConnection;
|
||||
listSSOIdentityProvidersByWorkspaceId: Array<FindAvailableSsoidpOutput>;
|
||||
object: Object;
|
||||
objects: ObjectConnection;
|
||||
serverlessFunction: ServerlessFunction;
|
||||
@ -822,6 +929,12 @@ export type RunWorkflowVersionInput = {
|
||||
workflowVersionId: Scalars['String'];
|
||||
};
|
||||
|
||||
export enum SsoIdentityProviderStatus {
|
||||
Active = 'Active',
|
||||
Error = 'Error',
|
||||
Inactive = 'Inactive'
|
||||
}
|
||||
|
||||
export type SendInvitationsOutput = {
|
||||
__typename?: 'SendInvitationsOutput';
|
||||
errors: Array<Scalars['String']>;
|
||||
@ -894,6 +1007,31 @@ export type SessionEntity = {
|
||||
url?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type SetupOidcSsoInput = {
|
||||
clientID: Scalars['String'];
|
||||
clientSecret: Scalars['String'];
|
||||
issuer: Scalars['String'];
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
export type SetupSamlSsoInput = {
|
||||
certificate: Scalars['String'];
|
||||
fingerprint?: InputMaybe<Scalars['String']>;
|
||||
id: Scalars['String'];
|
||||
issuer: Scalars['String'];
|
||||
name: Scalars['String'];
|
||||
ssoURL: Scalars['String'];
|
||||
};
|
||||
|
||||
export type SetupSsoOutput = {
|
||||
__typename?: 'SetupSsoOutput';
|
||||
id: Scalars['String'];
|
||||
issuer: Scalars['String'];
|
||||
name: Scalars['String'];
|
||||
status: SsoIdentityProviderStatus;
|
||||
type: IdpType;
|
||||
};
|
||||
|
||||
/** Sort Directions */
|
||||
export enum SortDirection {
|
||||
Asc = 'ASC',
|
||||
@ -1053,6 +1191,7 @@ export type UpdateWorkspaceInput = {
|
||||
displayName?: InputMaybe<Scalars['String']>;
|
||||
domainName?: InputMaybe<Scalars['String']>;
|
||||
inviteHash?: InputMaybe<Scalars['String']>;
|
||||
isPublicInviteLinkEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
logo?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
@ -1143,6 +1282,7 @@ export type Workspace = {
|
||||
featureFlags?: Maybe<Array<FeatureFlag>>;
|
||||
id: Scalars['UUID'];
|
||||
inviteHash?: Maybe<Scalars['String']>;
|
||||
isPublicInviteLinkEnabled: Scalars['Boolean'];
|
||||
logo?: Maybe<Scalars['String']>;
|
||||
metadataVersion: Scalars['Float'];
|
||||
updatedAt: Scalars['DateTime'];
|
||||
@ -1215,6 +1355,12 @@ export enum WorkspaceMemberTimeFormatEnum {
|
||||
System = 'SYSTEM'
|
||||
}
|
||||
|
||||
export type WorkspaceNameAndId = {
|
||||
__typename?: 'WorkspaceNameAndId';
|
||||
displayName?: Maybe<Scalars['String']>;
|
||||
id: Scalars['String'];
|
||||
};
|
||||
|
||||
export type Field = {
|
||||
__typename?: 'field';
|
||||
createdAt: Scalars['DateTime'];
|
||||
@ -1471,6 +1617,8 @@ export type AuthTokenFragmentFragment = { __typename?: 'AuthToken', token: strin
|
||||
|
||||
export type AuthTokensFragmentFragment = { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } };
|
||||
|
||||
export type AvailableSsoIdentityProvidersFragmentFragment = { __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } };
|
||||
|
||||
export type AuthorizeAppMutationVariables = Exact<{
|
||||
clientId: Scalars['String'];
|
||||
codeChallenge: Scalars['String'];
|
||||
@ -1496,6 +1644,13 @@ export type EmailPasswordResetLinkMutationVariables = Exact<{
|
||||
|
||||
export type EmailPasswordResetLinkMutation = { __typename?: 'Mutation', emailPasswordResetLink: { __typename?: 'EmailPasswordResetLink', success: boolean } };
|
||||
|
||||
export type FindAvailableSsoIdentityProvidersMutationVariables = Exact<{
|
||||
input: FindAvailableSsoidpInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type FindAvailableSsoIdentityProvidersMutation = { __typename?: 'Mutation', findAvailableSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }> };
|
||||
|
||||
export type GenerateApiKeyTokenMutationVariables = Exact<{
|
||||
apiKeyId: Scalars['String'];
|
||||
expiresAt: Scalars['String'];
|
||||
@ -1509,19 +1664,26 @@ export type GenerateJwtMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GenerateJwtMutation = { __typename?: 'Mutation', generateJWT: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
|
||||
export type GenerateJwtMutation = { __typename?: 'Mutation', generateJWT: { __typename?: 'GenerateJWTOutputWithAuthTokens', success: boolean, reason: string, authTokens: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } } | { __typename?: 'GenerateJWTOutputWithSSOAUTH', success: boolean, reason: string, availableSSOIDPs: Array<{ __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }> } };
|
||||
|
||||
export type GenerateTransientTokenMutationVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GenerateTransientTokenMutation = { __typename?: 'Mutation', generateTransientToken: { __typename?: 'TransientToken', transientToken: { __typename?: 'AuthToken', token: string } } };
|
||||
|
||||
export type GetAuthorizationUrlMutationVariables = Exact<{
|
||||
input: GetAuthorizationUrlInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type GetAuthorizationUrlMutation = { __typename?: 'Mutation', getAuthorizationUrl: { __typename?: 'GetAuthorizationUrlOutput', id: string, type: string, authorizationURL: string } };
|
||||
|
||||
export type ImpersonateMutationVariables = Exact<{
|
||||
userId: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
|
||||
export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
|
||||
|
||||
export type RenewTokenMutationVariables = Exact<{
|
||||
appToken: Scalars['String'];
|
||||
@ -1554,7 +1716,7 @@ export type VerifyMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
|
||||
export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
|
||||
|
||||
export type CheckUserExistsQueryVariables = Exact<{
|
||||
email: Scalars['String'];
|
||||
@ -1601,14 +1763,47 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat
|
||||
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } };
|
||||
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } };
|
||||
|
||||
export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } };
|
||||
|
||||
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> };
|
||||
export type CreateOidcIdentityProviderMutationVariables = Exact<{
|
||||
input: SetupOidcSsoInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateOidcIdentityProviderMutation = { __typename?: 'Mutation', createOIDCIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } };
|
||||
|
||||
export type CreateSamlIdentityProviderMutationVariables = Exact<{
|
||||
input: SetupSamlSsoInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateSamlIdentityProviderMutation = { __typename?: 'Mutation', createSAMLIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } };
|
||||
|
||||
export type DeleteSsoIdentityProviderMutationVariables = Exact<{
|
||||
input: DeleteSsoInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type DeleteSsoIdentityProviderMutation = { __typename?: 'Mutation', deleteSSOIdentityProvider: { __typename?: 'DeleteSsoOutput', identityProviderId: string } };
|
||||
|
||||
export type EditSsoIdentityProviderMutationVariables = Exact<{
|
||||
input: EditSsoInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } };
|
||||
|
||||
export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdpType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
|
||||
|
||||
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> };
|
||||
|
||||
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@ -1625,7 +1820,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
|
||||
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } };
|
||||
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } };
|
||||
|
||||
export type ActivateWorkflowVersionMutationVariables = Exact<{
|
||||
workflowVersionId: Scalars['String'];
|
||||
@ -1803,6 +1998,18 @@ export const AuthTokensFragmentFragmentDoc = gql`
|
||||
}
|
||||
}
|
||||
${AuthTokenFragmentFragmentDoc}`;
|
||||
export const AvailableSsoIdentityProvidersFragmentFragmentDoc = gql`
|
||||
fragment AvailableSSOIdentityProvidersFragment on FindAvailableSSOIDPOutput {
|
||||
id
|
||||
issuer
|
||||
name
|
||||
status
|
||||
workspace {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const WorkspaceMemberQueryFragmentFragmentDoc = gql`
|
||||
fragment WorkspaceMemberQueryFragment on WorkspaceMember {
|
||||
id
|
||||
@ -1842,6 +2049,7 @@ export const UserQueryFragmentFragmentDoc = gql`
|
||||
inviteHash
|
||||
allowImpersonation
|
||||
activationStatus
|
||||
isPublicInviteLinkEnabled
|
||||
featureFlags {
|
||||
id
|
||||
key
|
||||
@ -2238,6 +2446,39 @@ export function useEmailPasswordResetLinkMutation(baseOptions?: Apollo.MutationH
|
||||
export type EmailPasswordResetLinkMutationHookResult = ReturnType<typeof useEmailPasswordResetLinkMutation>;
|
||||
export type EmailPasswordResetLinkMutationResult = Apollo.MutationResult<EmailPasswordResetLinkMutation>;
|
||||
export type EmailPasswordResetLinkMutationOptions = Apollo.BaseMutationOptions<EmailPasswordResetLinkMutation, EmailPasswordResetLinkMutationVariables>;
|
||||
export const FindAvailableSsoIdentityProvidersDocument = gql`
|
||||
mutation FindAvailableSSOIdentityProviders($input: FindAvailableSSOIDPInput!) {
|
||||
findAvailableSSOIdentityProviders(input: $input) {
|
||||
...AvailableSSOIdentityProvidersFragment
|
||||
}
|
||||
}
|
||||
${AvailableSsoIdentityProvidersFragmentFragmentDoc}`;
|
||||
export type FindAvailableSsoIdentityProvidersMutationFn = Apollo.MutationFunction<FindAvailableSsoIdentityProvidersMutation, FindAvailableSsoIdentityProvidersMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useFindAvailableSsoIdentityProvidersMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useFindAvailableSsoIdentityProvidersMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useFindAvailableSsoIdentityProvidersMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [findAvailableSsoIdentityProvidersMutation, { data, loading, error }] = useFindAvailableSsoIdentityProvidersMutation({
|
||||
* variables: {
|
||||
* input: // value for 'input'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useFindAvailableSsoIdentityProvidersMutation(baseOptions?: Apollo.MutationHookOptions<FindAvailableSsoIdentityProvidersMutation, FindAvailableSsoIdentityProvidersMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<FindAvailableSsoIdentityProvidersMutation, FindAvailableSsoIdentityProvidersMutationVariables>(FindAvailableSsoIdentityProvidersDocument, options);
|
||||
}
|
||||
export type FindAvailableSsoIdentityProvidersMutationHookResult = ReturnType<typeof useFindAvailableSsoIdentityProvidersMutation>;
|
||||
export type FindAvailableSsoIdentityProvidersMutationResult = Apollo.MutationResult<FindAvailableSsoIdentityProvidersMutation>;
|
||||
export type FindAvailableSsoIdentityProvidersMutationOptions = Apollo.BaseMutationOptions<FindAvailableSsoIdentityProvidersMutation, FindAvailableSsoIdentityProvidersMutationVariables>;
|
||||
export const GenerateApiKeyTokenDocument = gql`
|
||||
mutation GenerateApiKeyToken($apiKeyId: String!, $expiresAt: String!) {
|
||||
generateApiKeyToken(apiKeyId: $apiKeyId, expiresAt: $expiresAt) {
|
||||
@ -2275,12 +2516,26 @@ export type GenerateApiKeyTokenMutationOptions = Apollo.BaseMutationOptions<Gene
|
||||
export const GenerateJwtDocument = gql`
|
||||
mutation GenerateJWT($workspaceId: String!) {
|
||||
generateJWT(workspaceId: $workspaceId) {
|
||||
... on GenerateJWTOutputWithAuthTokens {
|
||||
success
|
||||
reason
|
||||
authTokens {
|
||||
tokens {
|
||||
...AuthTokensFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
... on GenerateJWTOutputWithSSOAUTH {
|
||||
success
|
||||
reason
|
||||
availableSSOIDPs {
|
||||
...AvailableSSOIdentityProvidersFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${AuthTokensFragmentFragmentDoc}`;
|
||||
${AuthTokensFragmentFragmentDoc}
|
||||
${AvailableSsoIdentityProvidersFragmentFragmentDoc}`;
|
||||
export type GenerateJwtMutationFn = Apollo.MutationFunction<GenerateJwtMutation, GenerateJwtMutationVariables>;
|
||||
|
||||
/**
|
||||
@ -2341,6 +2596,41 @@ export function useGenerateTransientTokenMutation(baseOptions?: Apollo.MutationH
|
||||
export type GenerateTransientTokenMutationHookResult = ReturnType<typeof useGenerateTransientTokenMutation>;
|
||||
export type GenerateTransientTokenMutationResult = Apollo.MutationResult<GenerateTransientTokenMutation>;
|
||||
export type GenerateTransientTokenMutationOptions = Apollo.BaseMutationOptions<GenerateTransientTokenMutation, GenerateTransientTokenMutationVariables>;
|
||||
export const GetAuthorizationUrlDocument = gql`
|
||||
mutation GetAuthorizationUrl($input: GetAuthorizationUrlInput!) {
|
||||
getAuthorizationUrl(input: $input) {
|
||||
id
|
||||
type
|
||||
authorizationURL
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type GetAuthorizationUrlMutationFn = Apollo.MutationFunction<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useGetAuthorizationUrlMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useGetAuthorizationUrlMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetAuthorizationUrlMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [getAuthorizationUrlMutation, { data, loading, error }] = useGetAuthorizationUrlMutation({
|
||||
* variables: {
|
||||
* input: // value for 'input'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetAuthorizationUrlMutation(baseOptions?: Apollo.MutationHookOptions<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>(GetAuthorizationUrlDocument, options);
|
||||
}
|
||||
export type GetAuthorizationUrlMutationHookResult = ReturnType<typeof useGetAuthorizationUrlMutation>;
|
||||
export type GetAuthorizationUrlMutationResult = Apollo.MutationResult<GetAuthorizationUrlMutation>;
|
||||
export type GetAuthorizationUrlMutationOptions = Apollo.BaseMutationOptions<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>;
|
||||
export const ImpersonateDocument = gql`
|
||||
mutation Impersonate($userId: String!) {
|
||||
impersonate(userId: $userId) {
|
||||
@ -2759,6 +3049,7 @@ export const GetClientConfigDocument = gql`
|
||||
google
|
||||
password
|
||||
microsoft
|
||||
sso
|
||||
}
|
||||
billing {
|
||||
isBillingEnabled
|
||||
@ -2848,6 +3139,188 @@ export function useSkipSyncEmailOnboardingStepMutation(baseOptions?: Apollo.Muta
|
||||
export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType<typeof useSkipSyncEmailOnboardingStepMutation>;
|
||||
export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>;
|
||||
export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions<SkipSyncEmailOnboardingStepMutation, SkipSyncEmailOnboardingStepMutationVariables>;
|
||||
export const CreateOidcIdentityProviderDocument = gql`
|
||||
mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) {
|
||||
createOIDCIdentityProvider(input: $input) {
|
||||
id
|
||||
type
|
||||
issuer
|
||||
name
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type CreateOidcIdentityProviderMutationFn = Apollo.MutationFunction<CreateOidcIdentityProviderMutation, CreateOidcIdentityProviderMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useCreateOidcIdentityProviderMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useCreateOidcIdentityProviderMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useCreateOidcIdentityProviderMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [createOidcIdentityProviderMutation, { data, loading, error }] = useCreateOidcIdentityProviderMutation({
|
||||
* variables: {
|
||||
* input: // value for 'input'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useCreateOidcIdentityProviderMutation(baseOptions?: Apollo.MutationHookOptions<CreateOidcIdentityProviderMutation, CreateOidcIdentityProviderMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<CreateOidcIdentityProviderMutation, CreateOidcIdentityProviderMutationVariables>(CreateOidcIdentityProviderDocument, options);
|
||||
}
|
||||
export type CreateOidcIdentityProviderMutationHookResult = ReturnType<typeof useCreateOidcIdentityProviderMutation>;
|
||||
export type CreateOidcIdentityProviderMutationResult = Apollo.MutationResult<CreateOidcIdentityProviderMutation>;
|
||||
export type CreateOidcIdentityProviderMutationOptions = Apollo.BaseMutationOptions<CreateOidcIdentityProviderMutation, CreateOidcIdentityProviderMutationVariables>;
|
||||
export const CreateSamlIdentityProviderDocument = gql`
|
||||
mutation CreateSAMLIdentityProvider($input: SetupSAMLSsoInput!) {
|
||||
createSAMLIdentityProvider(input: $input) {
|
||||
id
|
||||
type
|
||||
issuer
|
||||
name
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type CreateSamlIdentityProviderMutationFn = Apollo.MutationFunction<CreateSamlIdentityProviderMutation, CreateSamlIdentityProviderMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useCreateSamlIdentityProviderMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useCreateSamlIdentityProviderMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useCreateSamlIdentityProviderMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [createSamlIdentityProviderMutation, { data, loading, error }] = useCreateSamlIdentityProviderMutation({
|
||||
* variables: {
|
||||
* input: // value for 'input'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useCreateSamlIdentityProviderMutation(baseOptions?: Apollo.MutationHookOptions<CreateSamlIdentityProviderMutation, CreateSamlIdentityProviderMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<CreateSamlIdentityProviderMutation, CreateSamlIdentityProviderMutationVariables>(CreateSamlIdentityProviderDocument, options);
|
||||
}
|
||||
export type CreateSamlIdentityProviderMutationHookResult = ReturnType<typeof useCreateSamlIdentityProviderMutation>;
|
||||
export type CreateSamlIdentityProviderMutationResult = Apollo.MutationResult<CreateSamlIdentityProviderMutation>;
|
||||
export type CreateSamlIdentityProviderMutationOptions = Apollo.BaseMutationOptions<CreateSamlIdentityProviderMutation, CreateSamlIdentityProviderMutationVariables>;
|
||||
export const DeleteSsoIdentityProviderDocument = gql`
|
||||
mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) {
|
||||
deleteSSOIdentityProvider(input: $input) {
|
||||
identityProviderId
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type DeleteSsoIdentityProviderMutationFn = Apollo.MutationFunction<DeleteSsoIdentityProviderMutation, DeleteSsoIdentityProviderMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useDeleteSsoIdentityProviderMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useDeleteSsoIdentityProviderMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useDeleteSsoIdentityProviderMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [deleteSsoIdentityProviderMutation, { data, loading, error }] = useDeleteSsoIdentityProviderMutation({
|
||||
* variables: {
|
||||
* input: // value for 'input'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useDeleteSsoIdentityProviderMutation(baseOptions?: Apollo.MutationHookOptions<DeleteSsoIdentityProviderMutation, DeleteSsoIdentityProviderMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<DeleteSsoIdentityProviderMutation, DeleteSsoIdentityProviderMutationVariables>(DeleteSsoIdentityProviderDocument, options);
|
||||
}
|
||||
export type DeleteSsoIdentityProviderMutationHookResult = ReturnType<typeof useDeleteSsoIdentityProviderMutation>;
|
||||
export type DeleteSsoIdentityProviderMutationResult = Apollo.MutationResult<DeleteSsoIdentityProviderMutation>;
|
||||
export type DeleteSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions<DeleteSsoIdentityProviderMutation, DeleteSsoIdentityProviderMutationVariables>;
|
||||
export const EditSsoIdentityProviderDocument = gql`
|
||||
mutation EditSSOIdentityProvider($input: EditSsoInput!) {
|
||||
editSSOIdentityProvider(input: $input) {
|
||||
id
|
||||
type
|
||||
issuer
|
||||
name
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type EditSsoIdentityProviderMutationFn = Apollo.MutationFunction<EditSsoIdentityProviderMutation, EditSsoIdentityProviderMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useEditSsoIdentityProviderMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useEditSsoIdentityProviderMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useEditSsoIdentityProviderMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [editSsoIdentityProviderMutation, { data, loading, error }] = useEditSsoIdentityProviderMutation({
|
||||
* variables: {
|
||||
* input: // value for 'input'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useEditSsoIdentityProviderMutation(baseOptions?: Apollo.MutationHookOptions<EditSsoIdentityProviderMutation, EditSsoIdentityProviderMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<EditSsoIdentityProviderMutation, EditSsoIdentityProviderMutationVariables>(EditSsoIdentityProviderDocument, options);
|
||||
}
|
||||
export type EditSsoIdentityProviderMutationHookResult = ReturnType<typeof useEditSsoIdentityProviderMutation>;
|
||||
export type EditSsoIdentityProviderMutationResult = Apollo.MutationResult<EditSsoIdentityProviderMutation>;
|
||||
export type EditSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions<EditSsoIdentityProviderMutation, EditSsoIdentityProviderMutationVariables>;
|
||||
export const ListSsoIdentityProvidersByWorkspaceIdDocument = gql`
|
||||
query ListSSOIdentityProvidersByWorkspaceId {
|
||||
listSSOIdentityProvidersByWorkspaceId {
|
||||
type
|
||||
id
|
||||
name
|
||||
issuer
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useListSsoIdentityProvidersByWorkspaceIdQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useListSsoIdentityProvidersByWorkspaceIdQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useListSsoIdentityProvidersByWorkspaceIdQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useListSsoIdentityProvidersByWorkspaceIdQuery({
|
||||
* variables: {
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useListSsoIdentityProvidersByWorkspaceIdQuery(baseOptions?: Apollo.QueryHookOptions<ListSsoIdentityProvidersByWorkspaceIdQuery, ListSsoIdentityProvidersByWorkspaceIdQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<ListSsoIdentityProvidersByWorkspaceIdQuery, ListSsoIdentityProvidersByWorkspaceIdQueryVariables>(ListSsoIdentityProvidersByWorkspaceIdDocument, options);
|
||||
}
|
||||
export function useListSsoIdentityProvidersByWorkspaceIdLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ListSsoIdentityProvidersByWorkspaceIdQuery, ListSsoIdentityProvidersByWorkspaceIdQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<ListSsoIdentityProvidersByWorkspaceIdQuery, ListSsoIdentityProvidersByWorkspaceIdQueryVariables>(ListSsoIdentityProvidersByWorkspaceIdDocument, options);
|
||||
}
|
||||
export type ListSsoIdentityProvidersByWorkspaceIdQueryHookResult = ReturnType<typeof useListSsoIdentityProvidersByWorkspaceIdQuery>;
|
||||
export type ListSsoIdentityProvidersByWorkspaceIdLazyQueryHookResult = ReturnType<typeof useListSsoIdentityProvidersByWorkspaceIdLazyQuery>;
|
||||
export type ListSsoIdentityProvidersByWorkspaceIdQueryResult = Apollo.QueryResult<ListSsoIdentityProvidersByWorkspaceIdQuery, ListSsoIdentityProvidersByWorkspaceIdQueryVariables>;
|
||||
export const DeleteUserAccountDocument = gql`
|
||||
mutation DeleteUserAccount {
|
||||
deleteUser {
|
||||
|
@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
@ -3,9 +3,22 @@ import { gql } from '@apollo/client';
|
||||
export const GENERATE_JWT = gql`
|
||||
mutation GenerateJWT($workspaceId: String!) {
|
||||
generateJWT(workspaceId: $workspaceId) {
|
||||
... on GenerateJWTOutputWithAuthTokens {
|
||||
success
|
||||
reason
|
||||
authTokens {
|
||||
tokens {
|
||||
...AuthTokensFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
... on GenerateJWTOutputWithSSOAUTH {
|
||||
success
|
||||
reason
|
||||
availableSSOIDPs {
|
||||
...AvailableSSOIdentityProvidersFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
@ -116,6 +116,7 @@ describe('useAuth', () => {
|
||||
microsoft: false,
|
||||
magicLink: false,
|
||||
password: false,
|
||||
sso: false,
|
||||
});
|
||||
expect(state.billing).toBeNull();
|
||||
expect(state.isSignInPrefilled).toBe(false);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -3,6 +3,7 @@ import styled from '@emotion/styled';
|
||||
|
||||
type HorizontalSeparatorProps = {
|
||||
visible?: boolean;
|
||||
text?: string;
|
||||
};
|
||||
const StyledSeparator = styled.div<HorizontalSeparatorProps>`
|
||||
background-color: ${({ theme }) => theme.border.color.medium};
|
||||
@ -12,8 +13,39 @@ const StyledSeparator = styled.div<HorizontalSeparatorProps>`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledSeparatorContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
margin-top: ${({ theme }) => theme.spacing(3)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledLine = styled.div<HorizontalSeparatorProps>`
|
||||
background-color: ${({ theme }) => theme.border.color.medium};
|
||||
height: ${({ visible }) => (visible ? '1px' : 0)};
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const StyledText = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
margin: 0 ${({ theme }) => theme.spacing(2)};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const HorizontalSeparator = ({
|
||||
visible = true,
|
||||
text = '',
|
||||
}: HorizontalSeparatorProps): JSX.Element => (
|
||||
<>
|
||||
{text ? (
|
||||
<StyledSeparatorContainer>
|
||||
<StyledLine visible={visible} />
|
||||
{text && <StyledText>{text}</StyledText>}
|
||||
<StyledLine visible={visible} />
|
||||
</StyledSeparatorContainer>
|
||||
) : (
|
||||
<StyledSeparator visible={visible} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -5,16 +5,12 @@ import { useMemo, useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { IconGoogle, IconMicrosoft } from 'twenty-ui';
|
||||
import { IconGoogle, IconMicrosoft, IconKey } from 'twenty-ui';
|
||||
|
||||
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
|
||||
import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator';
|
||||
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
|
||||
import {
|
||||
SignInUpMode,
|
||||
SignInUpStep,
|
||||
useSignInUp,
|
||||
} from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
|
||||
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
|
||||
@ -26,6 +22,7 @@ import { MainButton } from '@/ui/input/button/components/MainButton';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { ActionLink } from '@/ui/navigation/link/components/ActionLink';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { SignInUpStep } from '@/auth/states/signInUpStepState';
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
@ -64,9 +61,19 @@ export const SignInUpForm = () => {
|
||||
signInUpMode,
|
||||
continueWithCredentials,
|
||||
continueWithEmail,
|
||||
continueWithSSO,
|
||||
submitCredentials,
|
||||
submitSSOEmail,
|
||||
} = useSignInUp(form);
|
||||
|
||||
const toggleSSOMode = () => {
|
||||
if (signInUpStep === SignInUpStep.SSOEmail) {
|
||||
continueWithEmail();
|
||||
} else {
|
||||
continueWithSSO();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = async (
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
@ -86,6 +93,8 @@ export const SignInUpForm = () => {
|
||||
setShowErrors(true);
|
||||
form.handleSubmit(submitCredentials)();
|
||||
}
|
||||
} else if (signInUpStep === SignInUpStep.SSOEmail) {
|
||||
submitSSOEmail(form.getValues('email'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -99,6 +108,10 @@ export const SignInUpForm = () => {
|
||||
return 'Continue';
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.SSOEmail) {
|
||||
return 'Continue with SSO';
|
||||
}
|
||||
|
||||
return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up';
|
||||
}, [signInUpMode, signInUpStep]);
|
||||
|
||||
@ -136,7 +149,7 @@ export const SignInUpForm = () => {
|
||||
onClick={signInWithGoogle}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={!authProviders.microsoft} />
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -148,11 +161,31 @@ export const SignInUpForm = () => {
|
||||
onClick={signInWithMicrosoft}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={authProviders.password} />
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
)}
|
||||
{authProviders.sso && (
|
||||
<>
|
||||
<MainButton
|
||||
Icon={() => <IconKey size={theme.icon.size.lg} />}
|
||||
title={
|
||||
signInUpStep === SignInUpStep.SSOEmail
|
||||
? 'Continue with email'
|
||||
: 'Single sign-on (SSO)'
|
||||
}
|
||||
onClick={toggleSSOMode}
|
||||
fullWidth
|
||||
/>
|
||||
<HorizontalSeparator visible={false} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{authProviders.password && (
|
||||
<HorizontalSeparator visible={true} />
|
||||
|
||||
{authProviders.password &&
|
||||
(signInUpStep === SignInUpStep.Password ||
|
||||
signInUpStep === SignInUpStep.Email ||
|
||||
signInUpStep === SignInUpStep.Init) && (
|
||||
<StyledForm
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
@ -258,33 +291,67 @@ export const SignInUpForm = () => {
|
||||
/>
|
||||
</StyledForm>
|
||||
)}
|
||||
<StyledForm
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{signInUpStep === SignInUpStep.SSOEmail && (
|
||||
<>
|
||||
<StyledFullWidthMotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 800,
|
||||
damping: 35,
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<StyledInputContainer>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="Email"
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
error={showErrors ? error?.message : undefined}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
)}
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
<MainButton
|
||||
variant="secondary"
|
||||
title={buttonTitle}
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
setShowErrors(true);
|
||||
submitSSOEmail(form.getValues('email'));
|
||||
}}
|
||||
Icon={() => form.formState.isSubmitting && <Loader />}
|
||||
disabled={isSubmitButtonDisabled}
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StyledForm>
|
||||
</StyledContentContainer>
|
||||
{signInUpStep === SignInUpStep.Password && (
|
||||
<ActionLink onClick={handleResetPassword(form.getValues('email'))}>
|
||||
Forgot your password?
|
||||
</ActionLink>
|
||||
)}
|
||||
{signInUpStep === SignInUpStep.Init && (
|
||||
<FooterNote>
|
||||
By using Twenty, you agree to the{' '}
|
||||
<a
|
||||
href="https://twenty.com/legal/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href="https://twenty.com/legal/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</FooterNote>
|
||||
)}
|
||||
{signInUpStep === SignInUpStep.Init && <FooterNote />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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: [],
|
||||
});
|
@ -13,6 +13,7 @@ export type CurrentWorkspace = Pick<
|
||||
| 'activationStatus'
|
||||
| 'currentBillingSubscription'
|
||||
| 'workspaceMembersCount'
|
||||
| 'isPublicInviteLinkEnabled'
|
||||
| 'metadataVersion'
|
||||
>;
|
||||
|
||||
|
@ -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,
|
||||
});
|
@ -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);
|
||||
|
@ -7,6 +7,7 @@ export const GET_CLIENT_CONFIG = gql`
|
||||
google
|
||||
password
|
||||
microsoft
|
||||
sso
|
||||
}
|
||||
billing {
|
||||
isBillingEnabled
|
||||
|
@ -9,5 +9,6 @@ export const authProvidersState = createState<AuthProviders>({
|
||||
magicLink: false,
|
||||
password: false,
|
||||
microsoft: false,
|
||||
sso: false,
|
||||
},
|
||||
});
|
||||
|
@ -17,6 +17,7 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({
|
||||
allowImpersonation: false,
|
||||
activationStatus: WorkspaceActivationStatus.Active,
|
||||
metadataVersion: 1,
|
||||
isPublicInviteLinkEnabled: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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:
|
||||
'Don’t create contacts from/to Gmail, Outlook emails',
|
||||
value: !!messageChannel.excludeNonProfessionalEmails,
|
||||
onToggle: handleIsNonProfessionalEmailExcludedToggle,
|
||||
},
|
||||
{
|
||||
title: 'Exclude group emails',
|
||||
description: 'Don’t sync emails from team@ support@ noreply@...',
|
||||
value: !!messageChannel.excludeGroupEmails,
|
||||
onToggle: handleIsGroupEmailExcludedToggle,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Card>
|
||||
<SettingsOptionCardContent
|
||||
title="Exclude non-professional emails"
|
||||
description="Don’t create contacts from/to Gmail, Outlook emails"
|
||||
divider
|
||||
onClick={() =>
|
||||
handleIsNonProfessionalEmailExcludedToggle(
|
||||
!messageChannel.excludeNonProfessionalEmails,
|
||||
)
|
||||
}
|
||||
>
|
||||
<StyledToggle value={messageChannel.excludeNonProfessionalEmails} />
|
||||
</SettingsOptionCardContent>
|
||||
<SettingsOptionCardContent
|
||||
title="Exclude group emails"
|
||||
description="Don’t sync emails from team@ support@ noreply@..."
|
||||
onClick={() =>
|
||||
handleIsGroupEmailExcludedToggle(
|
||||
!messageChannel.excludeGroupEmails,
|
||||
)
|
||||
}
|
||||
>
|
||||
<StyledToggle value={messageChannel.excludeGroupEmails} />
|
||||
</SettingsOptionCardContent>
|
||||
</Card>
|
||||
</Section>
|
||||
</StyledDetailsContainer>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
@ -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}
|
||||
|
@ -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 && (
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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))
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
@ -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' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
};
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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: [],
|
||||
});
|
@ -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
|
||||
>;
|
@ -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'),
|
||||
});
|
||||
});
|
||||
});
|
@ -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',
|
||||
};
|
@ -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;
|
||||
};
|
@ -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 };
|
||||
}
|
||||
};
|
@ -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: '',
|
||||
}),
|
||||
};
|
@ -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(),
|
||||
);
|
@ -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',
|
||||
|
@ -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 }) => {
|
||||
|
@ -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;
|
||||
|
@ -6,11 +6,24 @@ import { AppPath } from '@/types/AppPath';
|
||||
import { useGenerateJwtMutation } from '~/generated/graphql';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO';
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
|
||||
export const useWorkspaceSwitching = () => {
|
||||
const setTokenPair = useSetRecoilState(tokenPairState);
|
||||
const [generateJWT] = useGenerateJwtMutation();
|
||||
const { redirectToSSOLoginPage } = useSSO();
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const setAvailableWorkspacesForSSOState = useSetRecoilState(
|
||||
availableSSOIdentityProvidersState,
|
||||
);
|
||||
const setSignInUpStep = useSetRecoilState(signInUpStepState);
|
||||
const { signOut } = useAuth();
|
||||
|
||||
const switchWorkspace = async (workspaceId: string) => {
|
||||
if (currentWorkspace?.id === workspaceId) return;
|
||||
@ -28,10 +41,34 @@ export const useWorkspaceSwitching = () => {
|
||||
throw new Error('could not create token');
|
||||
}
|
||||
|
||||
const { tokens } = jwt.data.generateJWT;
|
||||
if (
|
||||
jwt.data.generateJWT.reason === 'WORKSPACE_USE_SSO_AUTH' &&
|
||||
'availableSSOIDPs' in jwt.data.generateJWT
|
||||
) {
|
||||
if (jwt.data.generateJWT.availableSSOIDPs.length === 1) {
|
||||
redirectToSSOLoginPage(jwt.data.generateJWT.availableSSOIDPs[0].id);
|
||||
}
|
||||
|
||||
if (jwt.data.generateJWT.availableSSOIDPs.length > 1) {
|
||||
await signOut();
|
||||
setAvailableWorkspacesForSSOState(
|
||||
jwt.data.generateJWT.availableSSOIDPs,
|
||||
);
|
||||
setSignInUpStep(SignInUpStep.SSOWorkspaceSelection);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
jwt.data.generateJWT.reason !== 'WORKSPACE_USE_SSO_AUTH' &&
|
||||
'authTokens' in jwt.data.generateJWT
|
||||
) {
|
||||
const { tokens } = jwt.data.generateJWT.authTokens;
|
||||
setTokenPair(tokens);
|
||||
await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly.
|
||||
window.location.href = AppPath.Index;
|
||||
}
|
||||
};
|
||||
|
||||
return { switchWorkspace };
|
||||
|
@ -24,6 +24,7 @@ export const USER_QUERY_FRAGMENT = gql`
|
||||
inviteHash
|
||||
allowImpersonation
|
||||
activationStatus
|
||||
isPublicInviteLinkEnabled
|
||||
featureFlags {
|
||||
id
|
||||
key
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
@ -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 = () => {
|
||||
|
@ -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';
|
||||
|
@ -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 />
|
||||
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
@ -4,15 +4,14 @@ import { useRecoilValue } from 'recoil';
|
||||
import { Logo } from '@/auth/components/Logo';
|
||||
import { Title } from '@/auth/components/Title';
|
||||
import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm';
|
||||
import {
|
||||
SignInUpMode,
|
||||
SignInUpStep,
|
||||
useSignInUp,
|
||||
} from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
|
||||
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { SignInUpStep } from '@/auth/states/signInUpStepState';
|
||||
import { IconLockCustom } from '@ui/display/icon/components/IconLock';
|
||||
import { SSOWorkspaceSelection } from './SSOWorkspaceSelection';
|
||||
|
||||
export const SignInUp = () => {
|
||||
const { form } = useSignInUpForm();
|
||||
@ -27,6 +26,9 @@ export const SignInUp = () => {
|
||||
) {
|
||||
return 'Welcome to Twenty';
|
||||
}
|
||||
if (signInUpStep === SignInUpStep.SSOWorkspaceSelection) {
|
||||
return 'Choose SSO connection';
|
||||
}
|
||||
return signInUpMode === SignInUpMode.SignIn
|
||||
? 'Sign in to Twenty'
|
||||
: 'Sign up to Twenty';
|
||||
@ -39,10 +41,18 @@ export const SignInUp = () => {
|
||||
return (
|
||||
<>
|
||||
<AnimatedEaseIn>
|
||||
{signInUpStep === SignInUpStep.SSOWorkspaceSelection ? (
|
||||
<IconLockCustom size={40} />
|
||||
) : (
|
||||
<Logo />
|
||||
)}
|
||||
</AnimatedEaseIn>
|
||||
<Title animate>{title}</Title>
|
||||
{signInUpStep === SignInUpStep.SSOWorkspaceSelection ? (
|
||||
<SSOWorkspaceSelection />
|
||||
) : (
|
||||
<SignInUpForm />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -148,7 +148,8 @@ export const SettingsWorkspaceMembers = () => {
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
{currentWorkspace?.inviteHash && (
|
||||
{currentWorkspace?.inviteHash &&
|
||||
currentWorkspace?.isPublicInviteLinkEnabled && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title="Invite by link"
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
|
@ -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: [
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -60,6 +60,11 @@ export const seedFeatureFlags = async (
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IsSSOEnabled,
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IsGmailSendEmailScopeEnabled,
|
||||
workspaceId: workspaceId,
|
||||
|
@ -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";
|
||||
`);
|
||||
}
|
||||
}
|
@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
@ -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')
|
||||
|
@ -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' })
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
},
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>,
|
||||
|
@ -225,23 +225,45 @@ export class SignInUpService {
|
||||
email,
|
||||
}) {
|
||||
if (!workspacePersonalInviteToken && !workspaceInviteHash) {
|
||||
throw new Error('No invite token or hash provided');
|
||||
}
|
||||
|
||||
if (!workspacePersonalInviteToken && workspaceInviteHash) {
|
||||
return (
|
||||
(await this.workspaceRepository.findOneBy({
|
||||
inviteHash: workspaceInviteHash,
|
||||
})) ?? undefined
|
||||
throw new AuthException(
|
||||
'No invite token or hash provided',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const appToken = await this.userWorkspaceService.validateInvitation(
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
inviteHash: workspaceInviteHash,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
throw new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspacePersonalInviteToken && !workspace.isPublicInviteLinkEnabled) {
|
||||
throw new AuthException(
|
||||
'Workspace does not allow public invites',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
if (workspacePersonalInviteToken && workspace.isPublicInviteLinkEnabled) {
|
||||
try {
|
||||
await this.userWorkspaceService.validateInvitation(
|
||||
workspacePersonalInviteToken,
|
||||
email,
|
||||
);
|
||||
} catch (err) {
|
||||
throw new AuthException(
|
||||
err.message,
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return appToken?.workspace;
|
||||
return workspace;
|
||||
}
|
||||
|
||||
private async activateOnboardingForNewUser(
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
@ -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);
|
||||
};
|
||||
}
|
@ -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: {
|
||||
|
@ -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 {
|
||||
|
@ -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],
|
||||
|
@ -15,6 +15,9 @@ class AuthProviders {
|
||||
|
||||
@Field(() => Boolean)
|
||||
microsoft: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
sso: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user