feat(*): allow to select auth providers + add multiworkspace with subdomain management (#8656)

## Summary
Add support for multi-workspace feature and adjust configurations and
states accordingly.
- Introduced new state isMultiWorkspaceEnabledState.
- Updated ClientConfigProviderEffect component to handle
multi-workspace.
- Modified GraphQL schema and queries to include multi-workspace related
configurations.
- Adjusted server environment variables and their respective
documentation to support multi-workspace toggle.
- Updated server-side logic to handle new multi-workspace configurations
and conditions.
This commit is contained in:
Antoine Moreaux 2024-12-03 19:06:28 +01:00 committed by GitHub
parent 9a65e80566
commit 7943141d03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
167 changed files with 5180 additions and 1901 deletions

View File

@ -4,7 +4,6 @@ import path from 'path';
export const envVariables = (variables: string) => {
let payload = `
PG_DATABASE_URL=postgres://postgres:postgres@localhost:5432/default
FRONT_BASE_URL=http://localhost:3001
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login
REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh

View File

@ -32,6 +32,12 @@ export type ActivateWorkspaceInput = {
displayName?: InputMaybe<Scalars['String']['input']>;
};
export type ActivateWorkspaceOutput = {
__typename?: 'ActivateWorkspaceOutput';
loginToken: AuthToken;
workspace: Workspace;
};
export type Analytics = {
__typename?: 'Analytics';
/** Boolean that confirms query was dispatched */
@ -81,7 +87,7 @@ export type AuthProviders = {
magicLink: Scalars['Boolean']['output'];
microsoft: Scalars['Boolean']['output'];
password: Scalars['Boolean']['output'];
sso: Scalars['Boolean']['output'];
sso: Array<SsoIdentityProvider>;
};
export type AuthToken = {
@ -106,6 +112,15 @@ export type AuthorizeApp = {
redirectUrl: Scalars['String']['output'];
};
export type AvailableWorkspaceOutput = {
__typename?: 'AvailableWorkspaceOutput';
displayName?: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
logo?: Maybe<Scalars['String']['output']>;
sso: Array<SsoConnection>;
subdomain: Scalars['String']['output'];
};
export type Billing = {
__typename?: 'Billing';
billingFreeTrialDurationInDays?: Maybe<Scalars['Float']['output']>;
@ -161,14 +176,16 @@ export type ClientConfig = {
__typename?: 'ClientConfig';
analyticsEnabled: Scalars['Boolean']['output'];
api: ApiConfig;
authProviders: AuthProviders;
billing: Billing;
captcha: Captcha;
chromeExtensionId?: Maybe<Scalars['String']['output']>;
debugMode: Scalars['Boolean']['output'];
defaultSubdomain?: Maybe<Scalars['String']['output']>;
frontDomain: Scalars['String']['output'];
isMultiWorkspaceEnabled: Scalars['Boolean']['output'];
isSSOEnabled: Scalars['Boolean']['output'];
sentry: Sentry;
signInPrefilled: Scalars['Boolean']['output'];
signUpDisabled: Scalars['Boolean']['output'];
support: Support;
};
@ -332,7 +349,7 @@ export type EditSsoOutput = {
issuer: Scalars['String']['output'];
name: Scalars['String']['output'];
status: SsoIdentityProviderStatus;
type: IdpType;
type: IdentityProviderType;
};
export type EmailPasswordResetLink = {
@ -424,17 +441,13 @@ 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;
type: IdentityProviderType;
workspace: WorkspaceNameAndId;
};
@ -451,22 +464,6 @@ 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'];
};
@ -485,7 +482,7 @@ export type GetServerlessFunctionSourceCodeInput = {
version?: Scalars['String']['input'];
};
export enum IdpType {
export enum IdentityProviderType {
Oidc = 'OIDC',
Saml = 'SAML'
}
@ -553,7 +550,7 @@ export enum MessageChannelVisibility {
export type Mutation = {
__typename?: 'Mutation';
activateWorkflowVersion: Scalars['Boolean']['output'];
activateWorkspace: Workspace;
activateWorkspace: ActivateWorkspaceOutput;
addUserToWorkspace: User;
addUserToWorkspaceByInviteToken: User;
authorizeApp: AuthorizeApp;
@ -578,7 +575,7 @@ export type Mutation = {
deleteOneServerlessFunction: ServerlessFunction;
deleteSSOIdentityProvider: DeleteSsoOutput;
deleteUser: User;
deleteWorkflowVersionStep: Scalars['Boolean']['output'];
deleteWorkflowVersionStep: WorkflowAction;
deleteWorkspaceInvitation: Scalars['String']['output'];
disablePostgresProxy: PostgresCredentials;
editSSOIdentityProvider: EditSsoOutput;
@ -586,9 +583,7 @@ export type Mutation = {
enablePostgresProxy: PostgresCredentials;
exchangeAuthorizationCode: ExchangeAuthCode;
executeOneServerlessFunction: ServerlessFunctionExecutionResult;
findAvailableSSOIdentityProviders: Array<FindAvailableSsoidpOutput>;
generateApiKeyToken: ApiKeyToken;
generateJWT: GenerateJwt;
generateTransientToken: TransientToken;
getAuthorizationUrl: GetAuthorizationUrlOutput;
impersonate: Verify;
@ -599,6 +594,7 @@ export type Mutation = {
sendInvitations: SendInvitationsOutput;
signUp: LoginToken;
skipSyncEmailOnboardingStep: OnboardingStepSuccess;
switchWorkspace: PublicWorkspaceDataOutput;
syncRemoteTable: RemoteTable;
syncRemoteTableSchemaChanges: RemoteTable;
track: Analytics;
@ -609,7 +605,7 @@ export type Mutation = {
updateOneRemoteServer: RemoteServer;
updateOneServerlessFunction: ServerlessFunction;
updatePasswordViaResetToken: InvalidatePassword;
updateWorkflowVersionStep: Scalars['Boolean']['output'];
updateWorkflowVersionStep: WorkflowAction;
updateWorkspace: Workspace;
updateWorkspaceFeatureFlag: Scalars['Boolean']['output'];
uploadFile: Scalars['String']['output'];
@ -778,22 +774,12 @@ export type MutationExecuteOneServerlessFunctionArgs = {
};
export type MutationFindAvailableSsoIdentityProvidersArgs = {
input: FindAvailableSsoidpInput;
};
export type MutationGenerateApiKeyTokenArgs = {
apiKeyId: Scalars['String']['input'];
expiresAt: Scalars['String']['input'];
};
export type MutationGenerateJwtArgs = {
workspaceId: Scalars['String']['input'];
};
export type MutationGetAuthorizationUrlArgs = {
input: GetAuthorizationUrlInput;
};
@ -838,6 +824,11 @@ export type MutationSignUpArgs = {
};
export type MutationSwitchWorkspaceArgs = {
workspaceId: Scalars['String']['input'];
};
export type MutationSyncRemoteTableArgs = {
input: RemoteTableInput;
};
@ -1007,6 +998,15 @@ export type ProductPricesEntity = {
totalNumberOfPrices: Scalars['Int']['output'];
};
export type PublicWorkspaceDataOutput = {
__typename?: 'PublicWorkspaceDataOutput';
authProviders: AuthProviders;
displayName?: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
logo?: Maybe<Scalars['String']['output']>;
subdomain: Scalars['String']['output'];
};
export type PublishServerlessFunctionInput = {
/** The id of the function. */
id: Scalars['ID']['input'];
@ -1015,13 +1015,14 @@ export type PublishServerlessFunctionInput = {
export type Query = {
__typename?: 'Query';
billingPortalSession: SessionEntity;
checkUserExists: UserExists;
checkUserExists: UserExistsOutput;
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
clientConfig: ClientConfig;
currentUser: User;
currentWorkspace: Workspace;
field: Field;
fields: FieldConnection;
findAvailableWorkspacesByEmail: Array<AvailableWorkspaceOutput>;
findDistantTablesWithStatus: Array<RemoteTable>;
findManyRemoteServersByType: Array<RemoteServer>;
findManyServerlessFunctions: Array<ServerlessFunction>;
@ -1032,6 +1033,7 @@ export type Query = {
getAvailablePackages: Scalars['JSON']['output'];
getPostgresCredentials?: Maybe<PostgresCredentials>;
getProductPrices: ProductPricesEntity;
getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput;
getServerlessFunctionSourceCode?: Maybe<Scalars['JSON']['output']>;
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal;
@ -1075,6 +1077,11 @@ export type QueryFieldsArgs = {
};
export type QueryFindAvailableWorkspacesByEmailArgs = {
email: Scalars['String']['input'];
};
export type QueryFindDistantTablesWithStatusArgs = {
input: FindManyRemoteTablesInput;
};
@ -1262,6 +1269,24 @@ export type RunWorkflowVersionInput = {
workflowVersionId: Scalars['String']['input'];
};
export type SsoConnection = {
__typename?: 'SSOConnection';
id: Scalars['String']['output'];
issuer: Scalars['String']['output'];
name: Scalars['String']['output'];
status: SsoIdentityProviderStatus;
type: IdentityProviderType;
};
export type SsoIdentityProvider = {
__typename?: 'SSOIdentityProvider';
id: Scalars['String']['output'];
issuer: Scalars['String']['output'];
name: Scalars['String']['output'];
status: SsoIdentityProviderStatus;
type: IdentityProviderType;
};
export enum SsoIdentityProviderStatus {
Active = 'Active',
Error = 'Error',
@ -1353,7 +1378,7 @@ export type SetupSsoOutput = {
issuer: Scalars['String']['output'];
name: Scalars['String']['output'];
status: SsoIdentityProviderStatus;
type: IdpType;
type: IdentityProviderType;
};
/** Sort Directions */
@ -1555,8 +1580,12 @@ export type UpdateWorkspaceInput = {
displayName?: InputMaybe<Scalars['String']['input']>;
domainName?: InputMaybe<Scalars['String']['input']>;
inviteHash?: InputMaybe<Scalars['String']['input']>;
isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']['input']>;
isMicrosoftAuthEnabled?: InputMaybe<Scalars['Boolean']['input']>;
isPasswordAuthEnabled?: InputMaybe<Scalars['Boolean']['input']>;
isPublicInviteLinkEnabled?: InputMaybe<Scalars['Boolean']['input']>;
logo?: InputMaybe<Scalars['String']['input']>;
subdomain?: InputMaybe<Scalars['String']['input']>;
};
export type User = {
@ -1594,9 +1623,12 @@ export type UserEdge = {
export type UserExists = {
__typename?: 'UserExists';
availableWorkspaces: Array<AvailableWorkspaceOutput>;
exists: Scalars['Boolean']['output'];
};
export type UserExistsOutput = UserExists | UserNotExists;
export type UserInfo = {
__typename?: 'UserInfo';
email: Scalars['String']['output'];
@ -1626,6 +1658,11 @@ export type UserMappingOptionsUser = {
user?: Maybe<Scalars['String']['output']>;
};
export type UserNotExists = {
__typename?: 'UserNotExists';
exists: Scalars['Boolean']['output'];
};
export type UserWorkspace = {
__typename?: 'UserWorkspace';
createdAt: Scalars['DateTime']['output'];
@ -1653,6 +1690,10 @@ export type Verify = {
export type WorkflowAction = {
__typename?: 'WorkflowAction';
id: Scalars['UUID']['output'];
name: Scalars['String']['output'];
settings: Scalars['JSON']['output'];
type: Scalars['String']['output'];
valid: Scalars['Boolean']['output'];
};
export type WorkflowRun = {
@ -1677,9 +1718,13 @@ export type Workspace = {
hasValidEntrepriseKey: Scalars['Boolean']['output'];
id: Scalars['UUID']['output'];
inviteHash?: Maybe<Scalars['String']['output']>;
isGoogleAuthEnabled: Scalars['Boolean']['output'];
isMicrosoftAuthEnabled: Scalars['Boolean']['output'];
isPasswordAuthEnabled: Scalars['Boolean']['output'];
isPublicInviteLinkEnabled: Scalars['Boolean']['output'];
logo?: Maybe<Scalars['String']['output']>;
metadataVersion: Scalars['Float']['output'];
subdomain: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
workspaceMembersCount?: Maybe<Scalars['Float']['output']>;
};

View File

@ -1,5 +1,5 @@
import * as Apollo from '@apollo/client';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
@ -25,6 +25,12 @@ export type ActivateWorkspaceInput = {
displayName?: InputMaybe<Scalars['String']>;
};
export type ActivateWorkspaceOutput = {
__typename?: 'ActivateWorkspaceOutput';
loginToken: AuthToken;
workspace: Workspace;
};
export type Analytics = {
__typename?: 'Analytics';
/** Boolean that confirms query was dispatched */
@ -74,7 +80,7 @@ export type AuthProviders = {
magicLink: Scalars['Boolean'];
microsoft: Scalars['Boolean'];
password: Scalars['Boolean'];
sso: Scalars['Boolean'];
sso: Array<SsoIdentityProvider>;
};
export type AuthToken = {
@ -99,6 +105,15 @@ export type AuthorizeApp = {
redirectUrl: Scalars['String'];
};
export type AvailableWorkspaceOutput = {
__typename?: 'AvailableWorkspaceOutput';
displayName?: Maybe<Scalars['String']>;
id: Scalars['String'];
logo?: Maybe<Scalars['String']>;
sso: Array<SsoConnection>;
subdomain: Scalars['String'];
};
export type Billing = {
__typename?: 'Billing';
billingFreeTrialDurationInDays?: Maybe<Scalars['Float']>;
@ -154,14 +169,16 @@ export type ClientConfig = {
__typename?: 'ClientConfig';
analyticsEnabled: Scalars['Boolean'];
api: ApiConfig;
authProviders: AuthProviders;
billing: Billing;
captcha: Captcha;
chromeExtensionId?: Maybe<Scalars['String']>;
debugMode: Scalars['Boolean'];
defaultSubdomain?: Maybe<Scalars['String']>;
frontDomain: Scalars['String'];
isMultiWorkspaceEnabled: Scalars['Boolean'];
isSSOEnabled: Scalars['Boolean'];
sentry: Sentry;
signInPrefilled: Scalars['Boolean'];
signUpDisabled: Scalars['Boolean'];
support: Support;
};
@ -233,7 +250,7 @@ export type EditSsoOutput = {
issuer: Scalars['String'];
name: Scalars['String'];
status: SsoIdentityProviderStatus;
type: IdpType;
type: IdentityProviderType;
};
export type EmailPasswordResetLink = {
@ -325,17 +342,13 @@ 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;
type: IdentityProviderType;
workspace: WorkspaceNameAndId;
};
@ -345,22 +358,6 @@ export type FullName = {
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'];
};
@ -379,7 +376,7 @@ export type GetServerlessFunctionSourceCodeInput = {
version?: Scalars['String'];
};
export enum IdpType {
export enum IdentityProviderType {
Oidc = 'OIDC',
Saml = 'SAML'
}
@ -447,7 +444,7 @@ export enum MessageChannelVisibility {
export type Mutation = {
__typename?: 'Mutation';
activateWorkflowVersion: Scalars['Boolean'];
activateWorkspace: Workspace;
activateWorkspace: ActivateWorkspaceOutput;
addUserToWorkspace: User;
addUserToWorkspaceByInviteToken: User;
authorizeApp: AuthorizeApp;
@ -474,9 +471,7 @@ export type Mutation = {
enablePostgresProxy: PostgresCredentials;
exchangeAuthorizationCode: ExchangeAuthCode;
executeOneServerlessFunction: ServerlessFunctionExecutionResult;
findAvailableSSOIdentityProviders: Array<FindAvailableSsoidpOutput>;
generateApiKeyToken: ApiKeyToken;
generateJWT: GenerateJwt;
generateTransientToken: TransientToken;
getAuthorizationUrl: GetAuthorizationUrlOutput;
impersonate: Verify;
@ -487,6 +482,7 @@ export type Mutation = {
sendInvitations: SendInvitationsOutput;
signUp: LoginToken;
skipSyncEmailOnboardingStep: OnboardingStepSuccess;
switchWorkspace: PublicWorkspaceDataOutput;
track: Analytics;
updateBillingSubscription: UpdateBillingEntity;
updateOneObject: Object;
@ -621,22 +617,12 @@ export type MutationExecuteOneServerlessFunctionArgs = {
};
export type MutationFindAvailableSsoIdentityProvidersArgs = {
input: FindAvailableSsoidpInput;
};
export type MutationGenerateApiKeyTokenArgs = {
apiKeyId: Scalars['String'];
expiresAt: Scalars['String'];
};
export type MutationGenerateJwtArgs = {
workspaceId: Scalars['String'];
};
export type MutationGetAuthorizationUrlArgs = {
input: GetAuthorizationUrlInput;
};
@ -681,6 +667,11 @@ export type MutationSignUpArgs = {
};
export type MutationSwitchWorkspaceArgs = {
workspaceId: Scalars['String'];
};
export type MutationTrackArgs = {
action: Scalars['String'];
payload: Scalars['JSON'];
@ -825,6 +816,15 @@ export type ProductPricesEntity = {
totalNumberOfPrices: Scalars['Int'];
};
export type PublicWorkspaceDataOutput = {
__typename?: 'PublicWorkspaceDataOutput';
authProviders: AuthProviders;
displayName?: Maybe<Scalars['String']>;
id: Scalars['String'];
logo?: Maybe<Scalars['String']>;
subdomain: Scalars['String'];
};
export type PublishServerlessFunctionInput = {
/** The id of the function. */
id: Scalars['ID'];
@ -833,11 +833,12 @@ export type PublishServerlessFunctionInput = {
export type Query = {
__typename?: 'Query';
billingPortalSession: SessionEntity;
checkUserExists: UserExists;
checkUserExists: UserExistsOutput;
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
clientConfig: ClientConfig;
currentUser: User;
currentWorkspace: Workspace;
findAvailableWorkspacesByEmail: Array<AvailableWorkspaceOutput>;
findManyServerlessFunctions: Array<ServerlessFunction>;
findOneServerlessFunction: ServerlessFunction;
findWorkspaceFromInviteHash: Workspace;
@ -845,6 +846,7 @@ export type Query = {
getAvailablePackages: Scalars['JSON'];
getPostgresCredentials?: Maybe<PostgresCredentials>;
getProductPrices: ProductPricesEntity;
getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput;
getServerlessFunctionSourceCode?: Maybe<Scalars['JSON']>;
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal;
@ -875,6 +877,11 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = {
};
export type QueryFindAvailableWorkspacesByEmailArgs = {
email: Scalars['String'];
};
export type QueryFindOneServerlessFunctionArgs = {
input: ServerlessFunctionIdInput;
};
@ -1001,6 +1008,24 @@ export type RunWorkflowVersionInput = {
workflowVersionId: Scalars['String'];
};
export type SsoConnection = {
__typename?: 'SSOConnection';
id: Scalars['String'];
issuer: Scalars['String'];
name: Scalars['String'];
status: SsoIdentityProviderStatus;
type: IdentityProviderType;
};
export type SsoIdentityProvider = {
__typename?: 'SSOIdentityProvider';
id: Scalars['String'];
issuer: Scalars['String'];
name: Scalars['String'];
status: SsoIdentityProviderStatus;
type: IdentityProviderType;
};
export enum SsoIdentityProviderStatus {
Active = 'Active',
Error = 'Error',
@ -1092,7 +1117,7 @@ export type SetupSsoOutput = {
issuer: Scalars['String'];
name: Scalars['String'];
status: SsoIdentityProviderStatus;
type: IdpType;
type: IdentityProviderType;
};
/** Sort Directions */
@ -1263,8 +1288,12 @@ export type UpdateWorkspaceInput = {
displayName?: InputMaybe<Scalars['String']>;
domainName?: InputMaybe<Scalars['String']>;
inviteHash?: InputMaybe<Scalars['String']>;
isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']>;
isMicrosoftAuthEnabled?: InputMaybe<Scalars['Boolean']>;
isPasswordAuthEnabled?: InputMaybe<Scalars['Boolean']>;
isPublicInviteLinkEnabled?: InputMaybe<Scalars['Boolean']>;
logo?: InputMaybe<Scalars['String']>;
subdomain?: InputMaybe<Scalars['String']>;
};
export type User = {
@ -1302,9 +1331,12 @@ export type UserEdge = {
export type UserExists = {
__typename?: 'UserExists';
availableWorkspaces: Array<AvailableWorkspaceOutput>;
exists: Scalars['Boolean'];
};
export type UserExistsOutput = UserExists | UserNotExists;
export type UserInfo = {
__typename?: 'UserInfo';
email: Scalars['String'];
@ -1324,6 +1356,11 @@ export type UserMappingOptionsUser = {
user?: Maybe<Scalars['String']>;
};
export type UserNotExists = {
__typename?: 'UserNotExists';
exists: Scalars['Boolean'];
};
export type UserWorkspace = {
__typename?: 'UserWorkspace';
createdAt: Scalars['DateTime'];
@ -1379,9 +1416,13 @@ export type Workspace = {
hasValidEntrepriseKey: Scalars['Boolean'];
id: Scalars['UUID'];
inviteHash?: Maybe<Scalars['String']>;
isGoogleAuthEnabled: Scalars['Boolean'];
isMicrosoftAuthEnabled: Scalars['Boolean'];
isPasswordAuthEnabled: Scalars['Boolean'];
isPublicInviteLinkEnabled: Scalars['Boolean'];
logo?: Maybe<Scalars['String']>;
metadataVersion: Scalars['Float'];
subdomain: Scalars['String'];
updatedAt: Scalars['DateTime'];
workspaceMembersCount?: Maybe<Scalars['Float']>;
};
@ -1784,13 +1825,6 @@ 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'];
@ -1799,13 +1833,6 @@ export type GenerateApiKeyTokenMutationVariables = Exact<{
export type GenerateApiKeyTokenMutation = { __typename?: 'Mutation', generateApiKeyToken: { __typename?: 'ApiKeyToken', token: string } };
export type GenerateJwtMutationVariables = Exact<{
workspaceId: Scalars['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; }>;
@ -1823,7 +1850,7 @@ export type ImpersonateMutationVariables = Exact<{
}>;
export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, 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, hasValidEntrepriseKey: 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 ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, 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, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: 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, subdomain: string } | 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'];
@ -1843,6 +1870,13 @@ export type SignUpMutationVariables = Exact<{
export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } };
export type SwitchWorkspaceMutationVariables = Exact<{
workspaceId: Scalars['String'];
}>;
export type SwitchWorkspaceMutation = { __typename?: 'Mutation', switchWorkspace: { __typename?: 'PublicWorkspaceDataOutput', id: string, subdomain: string, authProviders: { __typename?: 'AuthProviders', google: boolean, magicLink: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> } } };
export type UpdatePasswordViaResetTokenMutationVariables = Exact<{
token: Scalars['String'];
newPassword: Scalars['String'];
@ -1856,7 +1890,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, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, 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, hasValidEntrepriseKey: 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 VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, 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, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: 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, subdomain: string } | 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'];
@ -1864,7 +1898,12 @@ export type CheckUserExistsQueryVariables = Exact<{
}>;
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename?: 'UserExists', exists: boolean } };
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } };
export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ [key: string]: never; }>;
export type GetPublicWorkspaceDataBySubdomainQuery = { __typename?: 'Query', getPublicWorkspaceDataBySubdomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, subdomain: string, authProviders: { __typename?: 'AuthProviders', google: boolean, magicLink: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> } } };
export type ValidatePasswordResetTokenQueryVariables = Exact<{
token: Scalars['String'];
@ -1903,7 +1942,7 @@ 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, 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 GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isSSOEnabled: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, 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; }>;
@ -1931,14 +1970,14 @@ export type CreateOidcIdentityProviderMutationVariables = Exact<{
}>;
export type CreateOidcIdentityProviderMutation = { __typename?: 'Mutation', createOIDCIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } };
export type CreateOidcIdentityProviderMutation = { __typename?: 'Mutation', createOIDCIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdentityProviderType, 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 CreateSamlIdentityProviderMutation = { __typename?: 'Mutation', createSAMLIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } };
export type DeleteSsoIdentityProviderMutationVariables = Exact<{
input: DeleteSsoInput;
@ -1952,14 +1991,14 @@ export type EditSsoIdentityProviderMutationVariables = Exact<{
}>;
export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } };
export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdentityProviderType, 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 ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, 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, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, 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, hasValidEntrepriseKey: 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 UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, 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, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: 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, subdomain: string } | null }> };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@ -1976,7 +2015,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, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, 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, hasValidEntrepriseKey: 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 GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, 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, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: 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, subdomain: string } | null }> } };
export type ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String'];
@ -2074,7 +2113,7 @@ export type ActivateWorkspaceMutationVariables = Exact<{
}>;
export type ActivateWorkspaceMutation = { __typename?: 'Mutation', activateWorkspace: { __typename?: 'Workspace', id: any } };
export type ActivateWorkspaceMutation = { __typename?: 'Mutation', activateWorkspace: { __typename?: 'ActivateWorkspaceOutput', workspace: { __typename?: 'Workspace', id: any, subdomain: string }, loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } };
export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: never; }>;
@ -2086,7 +2125,7 @@ export type UpdateWorkspaceMutationVariables = Exact<{
}>;
export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: any, domainName?: string | null, displayName?: string | null, logo?: string | null, allowImpersonation: boolean } };
export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: any, domainName?: string | null, subdomain: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean } };
export type UploadWorkspaceLogoMutationVariables = Exact<{
file: Scalars['Upload'];
@ -2248,6 +2287,10 @@ export const UserQueryFragmentFragmentDoc = gql`
allowImpersonation
activationStatus
isPublicInviteLinkEnabled
isGoogleAuthEnabled
isMicrosoftAuthEnabled
isPasswordAuthEnabled
subdomain
hasValidEntrepriseKey
featureFlags {
id
@ -2269,6 +2312,7 @@ export const UserQueryFragmentFragmentDoc = gql`
logo
displayName
domainName
subdomain
}
}
userVars
@ -2645,39 +2689,6 @@ 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) {
@ -2712,55 +2723,6 @@ export function useGenerateApiKeyTokenMutation(baseOptions?: Apollo.MutationHook
export type GenerateApiKeyTokenMutationHookResult = ReturnType<typeof useGenerateApiKeyTokenMutation>;
export type GenerateApiKeyTokenMutationResult = Apollo.MutationResult<GenerateApiKeyTokenMutation>;
export type GenerateApiKeyTokenMutationOptions = Apollo.BaseMutationOptions<GenerateApiKeyTokenMutation, GenerateApiKeyTokenMutationVariables>;
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}
${AvailableSsoIdentityProvidersFragmentFragmentDoc}`;
export type GenerateJwtMutationFn = Apollo.MutationFunction<GenerateJwtMutation, GenerateJwtMutationVariables>;
/**
* __useGenerateJwtMutation__
*
* To run a mutation, you first call `useGenerateJwtMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useGenerateJwtMutation` 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 [generateJwtMutation, { data, loading, error }] = useGenerateJwtMutation({
* variables: {
* workspaceId: // value for 'workspaceId'
* },
* });
*/
export function useGenerateJwtMutation(baseOptions?: Apollo.MutationHookOptions<GenerateJwtMutation, GenerateJwtMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<GenerateJwtMutation, GenerateJwtMutationVariables>(GenerateJwtDocument, options);
}
export type GenerateJwtMutationHookResult = ReturnType<typeof useGenerateJwtMutation>;
export type GenerateJwtMutationResult = Apollo.MutationResult<GenerateJwtMutation>;
export type GenerateJwtMutationOptions = Apollo.BaseMutationOptions<GenerateJwtMutation, GenerateJwtMutationVariables>;
export const GenerateTransientTokenDocument = gql`
mutation generateTransientToken {
generateTransientToken {
@ -2949,6 +2911,53 @@ export function useSignUpMutation(baseOptions?: Apollo.MutationHookOptions<SignU
export type SignUpMutationHookResult = ReturnType<typeof useSignUpMutation>;
export type SignUpMutationResult = Apollo.MutationResult<SignUpMutation>;
export type SignUpMutationOptions = Apollo.BaseMutationOptions<SignUpMutation, SignUpMutationVariables>;
export const SwitchWorkspaceDocument = gql`
mutation SwitchWorkspace($workspaceId: String!) {
switchWorkspace(workspaceId: $workspaceId) {
id
subdomain
authProviders {
sso {
id
name
type
status
issuer
}
google
magicLink
password
microsoft
}
}
}
`;
export type SwitchWorkspaceMutationFn = Apollo.MutationFunction<SwitchWorkspaceMutation, SwitchWorkspaceMutationVariables>;
/**
* __useSwitchWorkspaceMutation__
*
* To run a mutation, you first call `useSwitchWorkspaceMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSwitchWorkspaceMutation` 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 [switchWorkspaceMutation, { data, loading, error }] = useSwitchWorkspaceMutation({
* variables: {
* workspaceId: // value for 'workspaceId'
* },
* });
*/
export function useSwitchWorkspaceMutation(baseOptions?: Apollo.MutationHookOptions<SwitchWorkspaceMutation, SwitchWorkspaceMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SwitchWorkspaceMutation, SwitchWorkspaceMutationVariables>(SwitchWorkspaceDocument, options);
}
export type SwitchWorkspaceMutationHookResult = ReturnType<typeof useSwitchWorkspaceMutation>;
export type SwitchWorkspaceMutationResult = Apollo.MutationResult<SwitchWorkspaceMutation>;
export type SwitchWorkspaceMutationOptions = Apollo.BaseMutationOptions<SwitchWorkspaceMutation, SwitchWorkspaceMutationVariables>;
export const UpdatePasswordViaResetTokenDocument = gql`
mutation UpdatePasswordViaResetToken($token: String!, $newPassword: String!) {
updatePasswordViaResetToken(
@ -3028,7 +3037,26 @@ export type VerifyMutationOptions = Apollo.BaseMutationOptions<VerifyMutation, V
export const CheckUserExistsDocument = gql`
query CheckUserExists($email: String!, $captchaToken: String) {
checkUserExists(email: $email, captchaToken: $captchaToken) {
exists
__typename
... on UserExists {
exists
availableWorkspaces {
id
displayName
subdomain
logo
sso {
type
id
issuer
name
status
}
}
}
... on UserNotExists {
exists
}
}
}
`;
@ -3061,6 +3089,56 @@ export function useCheckUserExistsLazyQuery(baseOptions?: Apollo.LazyQueryHookOp
export type CheckUserExistsQueryHookResult = ReturnType<typeof useCheckUserExistsQuery>;
export type CheckUserExistsLazyQueryHookResult = ReturnType<typeof useCheckUserExistsLazyQuery>;
export type CheckUserExistsQueryResult = Apollo.QueryResult<CheckUserExistsQuery, CheckUserExistsQueryVariables>;
export const GetPublicWorkspaceDataBySubdomainDocument = gql`
query GetPublicWorkspaceDataBySubdomain {
getPublicWorkspaceDataBySubdomain {
id
logo
displayName
subdomain
authProviders {
sso {
id
name
type
status
issuer
}
google
magicLink
password
microsoft
}
}
}
`;
/**
* __useGetPublicWorkspaceDataBySubdomainQuery__
*
* To run a query within a React component, call `useGetPublicWorkspaceDataBySubdomainQuery` and pass it any options that fit your needs.
* When your component renders, `useGetPublicWorkspaceDataBySubdomainQuery` 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 } = useGetPublicWorkspaceDataBySubdomainQuery({
* variables: {
* },
* });
*/
export function useGetPublicWorkspaceDataBySubdomainQuery(baseOptions?: Apollo.QueryHookOptions<GetPublicWorkspaceDataBySubdomainQuery, GetPublicWorkspaceDataBySubdomainQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetPublicWorkspaceDataBySubdomainQuery, GetPublicWorkspaceDataBySubdomainQueryVariables>(GetPublicWorkspaceDataBySubdomainDocument, options);
}
export function useGetPublicWorkspaceDataBySubdomainLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetPublicWorkspaceDataBySubdomainQuery, GetPublicWorkspaceDataBySubdomainQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetPublicWorkspaceDataBySubdomainQuery, GetPublicWorkspaceDataBySubdomainQueryVariables>(GetPublicWorkspaceDataBySubdomainDocument, options);
}
export type GetPublicWorkspaceDataBySubdomainQueryHookResult = ReturnType<typeof useGetPublicWorkspaceDataBySubdomainQuery>;
export type GetPublicWorkspaceDataBySubdomainLazyQueryHookResult = ReturnType<typeof useGetPublicWorkspaceDataBySubdomainLazyQuery>;
export type GetPublicWorkspaceDataBySubdomainQueryResult = Apollo.QueryResult<GetPublicWorkspaceDataBySubdomainQuery, GetPublicWorkspaceDataBySubdomainQueryVariables>;
export const ValidatePasswordResetTokenDocument = gql`
query ValidatePasswordResetToken($token: String!) {
validatePasswordResetToken(passwordResetToken: $token) {
@ -3244,19 +3322,16 @@ export type UpdateBillingSubscriptionMutationOptions = Apollo.BaseMutationOption
export const GetClientConfigDocument = gql`
query GetClientConfig {
clientConfig {
authProviders {
google
password
microsoft
sso
}
billing {
isBillingEnabled
billingUrl
billingFreeTrialDurationInDays
}
signInPrefilled
signUpDisabled
isMultiWorkspaceEnabled
isSSOEnabled
defaultSubdomain
frontDomain
debugMode
analyticsEnabled
support {
@ -4163,10 +4238,16 @@ export type AddUserToWorkspaceByInviteTokenMutationOptions = Apollo.BaseMutation
export const ActivateWorkspaceDocument = gql`
mutation ActivateWorkspace($input: ActivateWorkspaceInput!) {
activateWorkspace(data: $input) {
id
workspace {
id
subdomain
}
loginToken {
...AuthTokenFragment
}
}
}
`;
${AuthTokenFragmentFragmentDoc}`;
export type ActivateWorkspaceMutationFn = Apollo.MutationFunction<ActivateWorkspaceMutation, ActivateWorkspaceMutationVariables>;
/**
@ -4230,9 +4311,14 @@ export const UpdateWorkspaceDocument = gql`
updateWorkspace(data: $input) {
id
domainName
subdomain
displayName
logo
allowImpersonation
isPublicInviteLinkEnabled
isGoogleAuthEnabled
isMicrosoftAuthEnabled
isPasswordAuthEnabled
}
}
`;

View File

@ -18,6 +18,7 @@ import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { UserProvider } from '@/users/components/UserProvider';
import { UserProviderEffect } from '@/users/components/UserProviderEffect';
import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect';
import { StrictMode } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { getPageTitleFromPath } from '~/utils/title-utils';
@ -34,6 +35,7 @@ export const AppRouterProviders = () => {
<ChromeExtensionSidecarEffect />
<ChromeExtensionSidecarProvider>
<UserProviderEffect />
<WorkspaceProviderEffect />
<UserProvider>
<AuthProvider>
<ApolloMetadataClientProvider>

View File

@ -105,6 +105,12 @@ const SettingsWorkspace = lazy(() =>
})),
);
const SettingsDomain = lazy(() =>
import('~/pages/settings/workspace/SettingsDomain').then((module) => ({
default: module.SettingsDomain,
})),
);
const SettingsWorkspaceMembers = lazy(() =>
import('~/pages/settings/SettingsWorkspaceMembers').then((module) => ({
default: module.SettingsWorkspaceMembers,
@ -288,6 +294,8 @@ export const SettingsRoutes = ({
{isBillingEnabled && (
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
)}
<Route path={SettingsPath.Workspace} element={<SettingsWorkspace />} />
<Route path={SettingsPath.Domain} element={<SettingsDomain />} />
<Route
path={SettingsPath.WorkspaceMembersPage}
element={<SettingsWorkspaceMembers />}
@ -382,14 +390,12 @@ export const SettingsRoutes = ({
element={<SettingsObjectFieldEdit />}
/>
<Route path={SettingsPath.Releases} element={<Releases />} />
<Route path={SettingsPath.Security} element={<SettingsSecurity />} />
{isSSOEnabled && (
<>
<Route path={SettingsPath.Security} element={<SettingsSecurity />} />
<Route
path={SettingsPath.NewSSOIdentityProvider}
element={<SettingsSecuritySSOIdentifyProvider />}
/>
</>
<Route
path={SettingsPath.NewSSOIdentityProvider}
element={<SettingsSecuritySSOIdentifyProvider />}
/>
)}
{isAdminPageEnabled && (
<>

View File

@ -1,13 +0,0 @@
/* @license Enterprise */
import { gql } from '@apollo/client';
export const FIND_AVAILABLE_SSO_IDENTITY_PROVIDERS = gql`
mutation FindAvailableSSOIdentityProviders(
$input: FindAvailableSSOIDPInput!
) {
findAvailableSSOIdentityProviders(input: $input) {
...AvailableSSOIdentityProvidersFragment
}
}
`;

View File

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

View File

@ -0,0 +1,23 @@
import { gql } from '@apollo/client';
export const SWITCH_WORKSPACE = gql`
mutation SwitchWorkspace($workspaceId: String!) {
switchWorkspace(workspaceId: $workspaceId) {
id
subdomain
authProviders {
sso {
id
name
type
status
issuer
}
google
magicLink
password
microsoft
}
}
}
`;

View File

@ -3,7 +3,26 @@ import { gql } from '@apollo/client';
export const CHECK_USER_EXISTS = gql`
query CheckUserExists($email: String!, $captchaToken: String) {
checkUserExists(email: $email, captchaToken: $captchaToken) {
exists
__typename
... on UserExists {
exists
availableWorkspaces {
id
displayName
subdomain
logo
sso {
type
id
issuer
name
status
}
}
}
... on UserNotExists {
exists
}
}
}
`;

View File

@ -0,0 +1,25 @@
import { gql } from '@apollo/client';
export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql`
query GetPublicWorkspaceDataBySubdomain {
getPublicWorkspaceDataBySubdomain {
id
logo
displayName
subdomain
authProviders {
sso {
id
name
type
status
issuer
}
google
magicLink
password
microsoft
}
}
}
`;

View File

@ -114,11 +114,11 @@ describe('useAuth', () => {
expect(state.icons).toEqual({});
expect(state.authProviders).toEqual({
google: false,
google: true,
microsoft: false,
magicLink: false,
password: false,
sso: false,
password: true,
sso: [],
});
expect(state.billing).toBeNull();
expect(state.isDeveloperDefaultSignInPrefilled).toBe(false);

View File

@ -4,7 +4,7 @@ import {
snapshot_UNSTABLE,
useGotoRecoilSnapshot,
useRecoilCallback,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
import { iconsState } from 'twenty-ui';
@ -42,10 +42,18 @@ import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDa
import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat';
import { currentUserState } from '../states/currentUserState';
import { tokenPairState } from '../states/tokenPairState';
import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState';
import { urlManagerState } from '@/url-manager/states/url-manager.state';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
export const useAuth = () => {
const [, setTokenPair] = useRecoilState(tokenPairState);
const setTokenPair = useSetRecoilState(tokenPairState);
const setCurrentUser = useSetRecoilState(currentUserState);
const urlManager = useRecoilValue(urlManagerState);
const setLastAuthenticateWorkspaceState = useSetRecoilState(
lastAuthenticateWorkspaceState,
);
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
@ -60,6 +68,7 @@ export const useAuth = () => {
const [challenge] = useChallengeMutation();
const [signUp] = useSignUpMutation();
const [verify] = useVerifyMutation();
const { isTwentyWorkspaceSubdomain, getWorkspaceSubdomain } = useUrlManager();
const [checkUserExistsQuery, { data: checkUserExistsData }] =
useCheckUserExistsLazyQuery();
@ -203,6 +212,15 @@ export const useAuth = () => {
const workspace = user.defaultWorkspace ?? null;
setCurrentWorkspace(workspace);
if (isDefined(workspace) && isTwentyWorkspaceSubdomain) {
setLastAuthenticateWorkspaceState({
id: workspace.id,
subdomain: workspace.subdomain,
cookieAttributes: {
domain: `.${urlManager.frontDomain}`,
},
});
}
if (isDefined(verifyResult.data?.verify.user.workspaces)) {
const validWorkspaces = verifyResult.data?.verify.user.workspaces
@ -227,9 +245,12 @@ export const useAuth = () => {
setTokenPair,
setCurrentUser,
setCurrentWorkspace,
isTwentyWorkspaceSubdomain,
setCurrentWorkspaceMembers,
setCurrentWorkspaceMember,
setDateTimeFormat,
setLastAuthenticateWorkspaceState,
urlManager.frontDomain,
setWorkspaces,
],
);
@ -301,23 +322,34 @@ export const useAuth = () => {
[setIsVerifyPendingState, signUp, handleVerify],
);
const buildRedirectUrl = (
path: string,
params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
const buildRedirectUrl = useCallback(
(
path: string,
params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
},
) => {
const url = new URL(`${REACT_APP_SERVER_BASE_URL}${path}`);
if (isDefined(params.workspaceInviteHash)) {
url.searchParams.set('inviteHash', params.workspaceInviteHash);
}
if (isDefined(params.workspacePersonalInviteToken)) {
url.searchParams.set(
'inviteToken',
params.workspacePersonalInviteToken,
);
}
const subdomain = getWorkspaceSubdomain;
if (isDefined(subdomain)) {
url.searchParams.set('workspaceSubdomain', subdomain);
}
return url.toString();
},
) => {
const authServerUrl = REACT_APP_SERVER_BASE_URL;
const url = new URL(`${authServerUrl}${path}`);
if (isDefined(params.workspaceInviteHash)) {
url.searchParams.set('inviteHash', params.workspaceInviteHash);
}
if (isDefined(params.workspacePersonalInviteToken)) {
url.searchParams.set('inviteToken', params.workspacePersonalInviteToken);
}
return url.toString();
};
[getWorkspaceSubdomain],
);
const handleGoogleLogin = useCallback(
(params: {
@ -326,7 +358,7 @@ export const useAuth = () => {
}) => {
window.location.href = buildRedirectUrl('/auth/google', params);
},
[],
[buildRedirectUrl],
);
const handleMicrosoftLogin = useCallback(
@ -336,7 +368,7 @@ export const useAuth = () => {
}) => {
window.location.href = buildRedirectUrl('/auth/microsoft', params);
},
[],
[buildRedirectUrl],
);
return {

View File

@ -0,0 +1,60 @@
import { TextInput } from '@/ui/input/components/TextInput';
import { Controller, useFormContext } from 'react-hook-form';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { isDefined } from '~/utils/isDefined';
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm';
const StyledFullWidthMotionDiv = styled(motion.div)`
width: 100%;
`;
const StyledInputContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(3)};
`;
export const SignInUpEmailField = ({
showErrors,
onChange: onChangeFromProps,
}: {
showErrors: boolean;
onChange?: (value: string) => void;
}) => {
const form = useFormContext<Form>();
return (
<StyledFullWidthMotionDiv
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{
type: 'spring',
stiffness: 800,
damping: 35,
}}
>
<Controller
name="email"
control={form.control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<StyledInputContainer>
<TextInput
autoFocus
value={value}
placeholder="Email"
onBlur={onBlur}
onChange={(value: string) => {
onChange(value);
if (isDefined(onChangeFromProps)) onChangeFromProps(value);
}}
error={showErrors ? error?.message : undefined}
fullWidth
/>
</StyledInputContainer>
)}
/>
</StyledFullWidthMotionDiv>
);
};

View File

@ -1,393 +0,0 @@
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import {
useSignInUpForm,
validationSchema,
} 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';
import { SignInUpStep } from '@/auth/states/signInUpStepState';
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
import { TextInput } from '@/ui/input/components/TextInput';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useMemo, useState } from 'react';
import { Controller } from 'react-hook-form';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import {
ActionLink,
HorizontalSeparator,
IconGoogle,
IconKey,
IconMicrosoft,
Loader,
MainButton,
StyledText,
} from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
const StyledContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
margin-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledForm = styled.form`
align-items: center;
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledFullWidthMotionDiv = styled(motion.div)`
width: 100%;
`;
const StyledInputContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(3)};
`;
export const SignInUpForm = () => {
const captchaProvider = useRecoilValue(captchaProviderState);
const isRequestingCaptchaToken = useRecoilValue(
isRequestingCaptchaTokenState,
);
const [authProviders] = useRecoilState(authProvidersState);
const [showErrors, setShowErrors] = useState(false);
const { signInWithGoogle } = useSignInWithGoogle();
const { signInWithMicrosoft } = useSignInWithMicrosoft();
const { form } = useSignInUpForm();
const { handleResetPassword } = useHandleResetPassword();
const {
signInUpStep,
signInUpMode,
continueWithCredentials,
continueWithEmail,
continueWithSSO,
submitCredentials,
submitSSOEmail,
} = useSignInUp(form);
if (
signInUpStep === SignInUpStep.Init &&
!authProviders.google &&
!authProviders.microsoft &&
!authProviders.sso
) {
continueWithEmail();
}
const toggleSSOMode = () => {
if (signInUpStep === SignInUpStep.SSOEmail) {
continueWithEmail();
} else {
continueWithSSO();
}
};
const handleKeyDown = async (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (event.key === Key.Enter) {
event.preventDefault();
if (signInUpStep === SignInUpStep.Init) {
continueWithEmail();
} else if (signInUpStep === SignInUpStep.Email) {
if (isDefined(form?.formState?.errors?.email)) {
setShowErrors(true);
return;
}
continueWithCredentials();
} else if (signInUpStep === SignInUpStep.Password) {
if (!form.formState.isSubmitting) {
setShowErrors(true);
form.handleSubmit(submitCredentials)();
}
} else if (signInUpStep === SignInUpStep.SSOEmail) {
submitSSOEmail(form.getValues('email'));
}
}
};
const buttonTitle = useMemo(() => {
if (signInUpStep === SignInUpStep.Init) {
return 'Continue With Email';
}
if (signInUpStep === SignInUpStep.Email) {
return 'Continue';
}
if (signInUpStep === SignInUpStep.SSOEmail) {
return 'Continue with SSO';
}
return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up';
}, [signInUpMode, signInUpStep]);
const theme = useTheme();
const shouldWaitForCaptchaToken =
signInUpStep !== SignInUpStep.Init &&
isDefined(captchaProvider?.provider) &&
isRequestingCaptchaToken;
const isEmailStepSubmitButtonDisabledCondition =
signInUpStep === SignInUpStep.Email &&
(!validationSchema.shape.email.safeParse(form.watch('email')).success ||
shouldWaitForCaptchaToken);
// TODO: isValid is actually a proxy function. If it is not rendered the first time, react might not trigger re-renders
// We make the isValid check synchronous and update a reactState to make sure this does not happen
const isPasswordStepSubmitButtonDisabledCondition =
signInUpStep === SignInUpStep.Password &&
(!form.formState.isValid ||
form.formState.isSubmitting ||
shouldWaitForCaptchaToken);
const isSubmitButtonDisabled =
isEmailStepSubmitButtonDisabledCondition ||
isPasswordStepSubmitButtonDisabledCondition;
return (
<>
<StyledContentContainer>
{authProviders.google && (
<>
<MainButton
Icon={() => <IconGoogle size={theme.icon.size.lg} />}
title="Continue with Google"
onClick={signInWithGoogle}
variant={
signInUpStep === SignInUpStep.Init ? undefined : 'secondary'
}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
)}
{authProviders.microsoft && (
<>
<MainButton
Icon={() => <IconMicrosoft size={theme.icon.size.lg} />}
title="Continue with Microsoft"
onClick={signInWithMicrosoft}
variant={
signInUpStep === SignInUpStep.Init ? undefined : 'secondary'
}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
)}
{authProviders.sso && (
<>
<MainButton
Icon={() => <IconKey size={theme.icon.size.lg} />}
variant={
signInUpStep === SignInUpStep.Init ? undefined : 'secondary'
}
title={
signInUpStep === SignInUpStep.SSOEmail
? 'Continue with email'
: 'Single sign-on (SSO)'
}
onClick={toggleSSOMode}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
)}
{(authProviders.google ||
authProviders.microsoft ||
authProviders.sso) && <HorizontalSeparator visible />}
{authProviders.password &&
(signInUpStep === SignInUpStep.Password ||
signInUpStep === SignInUpStep.Email ||
signInUpStep === SignInUpStep.Init) && (
<StyledForm
onSubmit={(event) => {
event.preventDefault();
}}
>
{signInUpStep !== SignInUpStep.Init && (
<StyledFullWidthMotionDiv
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{
type: 'spring',
stiffness: 800,
damping: 35,
}}
>
<Controller
name="email"
control={form.control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<StyledInputContainer>
<TextInput
autoFocus
value={value}
placeholder="Email"
onBlur={onBlur}
onChange={(value: string) => {
onChange(value);
if (signInUpStep === SignInUpStep.Password) {
continueWithEmail();
}
}}
error={showErrors ? error?.message : undefined}
fullWidth
disableHotkeys
onKeyDown={handleKeyDown}
/>
</StyledInputContainer>
)}
/>
</StyledFullWidthMotionDiv>
)}
{signInUpStep === SignInUpStep.Password && (
<StyledFullWidthMotionDiv
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{
type: 'spring',
stiffness: 800,
damping: 35,
}}
>
<Controller
name="password"
control={form.control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<StyledInputContainer>
<TextInput
autoFocus
value={value}
type="password"
placeholder="Password"
onBlur={onBlur}
onChange={onChange}
error={showErrors ? error?.message : undefined}
fullWidth
disableHotkeys
onKeyDown={handleKeyDown}
/>
{signInUpMode === SignInUpMode.SignUp && (
<StyledText
text={'At least 8 characters long.'}
color={theme.font.color.secondary}
/>
)}
</StyledInputContainer>
)}
/>
</StyledFullWidthMotionDiv>
)}
<MainButton
title={buttonTitle}
type="submit"
variant={
signInUpStep === SignInUpStep.Init ? 'secondary' : 'primary'
}
onClick={async () => {
if (signInUpStep === SignInUpStep.Init) {
continueWithEmail();
return;
}
if (signInUpStep === SignInUpStep.Email) {
if (isDefined(form?.formState?.errors?.email)) {
setShowErrors(true);
return;
}
continueWithCredentials();
return;
}
setShowErrors(true);
form.handleSubmit(submitCredentials)();
}}
Icon={() => (form.formState.isSubmitting ? <Loader /> : null)}
disabled={isSubmitButtonDisabled}
fullWidth
/>
</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 />}
</>
);
};

View File

@ -0,0 +1,164 @@
import styled from '@emotion/styled';
import {
IconGoogle,
IconMicrosoft,
Loader,
MainButton,
HorizontalSeparator,
} from 'twenty-ui';
import { useTheme } from '@emotion/react';
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
import { FormProvider } from 'react-hook-form';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useLocation } from 'react-router-dom';
import { isDefined } from '~/utils/isDefined';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField';
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField';
import { useAuth } from '@/auth/hooks/useAuth';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { signInUpModeState } from '@/auth/states/signInUpModeState';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
const StyledContentContainer = styled(motion.div)`
margin-bottom: ${({ theme }) => theme.spacing(8)};
margin-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledForm = styled.form`
align-items: center;
display: flex;
flex-direction: column;
width: 100%;
`;
export const SignInUpGlobalScopeForm = () => {
const theme = useTheme();
const signInUpStep = useRecoilValue(signInUpStepState);
const { signInWithGoogle } = useSignInWithGoogle();
const { signInWithMicrosoft } = useSignInWithMicrosoft();
const { checkUserExists } = useAuth();
const { readCaptchaToken } = useReadCaptchaToken();
const { redirectToWorkspace } = useUrlManager();
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
const { enqueueSnackBar } = useSnackBar();
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const [showErrors, setShowErrors] = useState(false);
const { form } = useSignInUpForm();
const { pathname } = useLocation();
const { submitCredentials } = useSignInUp(form);
const handleSubmit = async () => {
if (isDefined(form?.formState?.errors?.email)) {
setShowErrors(true);
return;
}
if (signInUpStep === SignInUpStep.Password) {
await submitCredentials(form.getValues());
return;
}
const token = await readCaptchaToken();
await checkUserExists.checkUserExistsQuery({
variables: {
email: form.getValues('email'),
captchaToken: token,
},
onError: (error) => {
enqueueSnackBar(`${error.message}`, {
variant: SnackBarVariant.Error,
});
},
onCompleted: (data) => {
requestFreshCaptchaToken();
if (data.checkUserExists.__typename === 'UserExists') {
if (
isDefined(data?.checkUserExists.availableWorkspaces) &&
data.checkUserExists.availableWorkspaces.length >= 1
) {
return redirectToWorkspace(
data?.checkUserExists.availableWorkspaces[0].subdomain,
pathname,
{
email: form.getValues('email'),
},
);
}
}
if (data.checkUserExists.__typename === 'UserNotExists') {
setSignInUpMode(SignInUpMode.SignUp);
setSignInUpStep(SignInUpStep.Password);
}
},
});
};
return (
<>
<StyledContentContainer>
<>
<MainButton
Icon={() => <IconGoogle size={theme.icon.size.lg} />}
title="Continue with Google"
onClick={signInWithGoogle}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
<>
<MainButton
Icon={() => <IconMicrosoft size={theme.icon.size.lg} />}
title="Continue with Microsoft"
onClick={signInWithMicrosoft}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
<HorizontalSeparator visible />
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
<StyledForm onSubmit={form.handleSubmit(handleSubmit)}>
<SignInUpEmailField showErrors={showErrors} />
{signInUpStep === SignInUpStep.Password && (
<SignInUpPasswordField
showErrors={showErrors}
signInUpMode={signInUpMode}
/>
)}
<MainButton
title={
signInUpStep === SignInUpStep.Password ? 'Sign Up' : 'Continue'
}
type="submit"
variant="secondary"
Icon={() => (form.formState.isSubmitting ? <Loader /> : null)}
fullWidth
/>
</StyledForm>
</FormProvider>
</StyledContentContainer>
</>
);
};

View File

@ -0,0 +1,67 @@
import { TextInput } from '@/ui/input/components/TextInput';
import { Controller, useFormContext } from 'react-hook-form';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { StyledText } from 'twenty-ui';
import { useTheme } from '@emotion/react';
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
const StyledFullWidthMotionDiv = styled(motion.div)`
width: 100%;
`;
const StyledInputContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(3)};
`;
export const SignInUpPasswordField = ({
showErrors,
signInUpMode,
}: {
showErrors: boolean;
signInUpMode: SignInUpMode;
}) => {
const theme = useTheme();
const form = useFormContext<Form>();
return (
<StyledFullWidthMotionDiv
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{
type: 'spring',
stiffness: 800,
damping: 35,
}}
>
<Controller
name="password"
control={form.control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<StyledInputContainer>
<TextInput
autoFocus
value={value}
type="password"
placeholder="Password"
onBlur={onBlur}
onChange={onChange}
error={showErrors ? error?.message : undefined}
fullWidth
/>
{signInUpMode === SignInUpMode.SignUp && (
<StyledText
text={'At least 8 characters long.'}
color={theme.font.color.secondary}
/>
)}
</StyledInputContainer>
)}
/>
</StyledFullWidthMotionDiv>
);
};

View File

@ -0,0 +1,41 @@
/* @license Enterprise */
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { MainButton, HorizontalSeparator } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
import { authProvidersState } from '@/client-config/states/authProvidersState';
const StyledContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
margin-top: ${({ theme }) => theme.spacing(4)};
`;
export const SignInUpSSOIdentityProviderSelection = () => {
const authProviders = useRecoilValue(authProvidersState);
const { redirectToSSOLoginPage } = useSSO();
return (
<>
<StyledContentContainer>
{isDefined(authProviders?.sso) &&
authProviders?.sso.map((idp) => (
<>
<MainButton
key={idp.id}
title={idp.name}
onClick={() => redirectToSSOLoginPage(idp.id)}
Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
))}
</StyledContentContainer>
</>
);
};

View File

@ -0,0 +1,142 @@
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { Loader, MainButton } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { useRecoilValue } from 'recoil';
import styled from '@emotion/styled';
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField';
import { useState, useMemo } from 'react';
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
import { FormProvider } from 'react-hook-form';
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
const StyledForm = styled.form`
align-items: center;
display: flex;
flex-direction: column;
width: 100%;
`;
export const SignInUpWithCredentials = () => {
const { form, validationSchema } = useSignInUpForm();
const signInUpStep = useRecoilValue(signInUpStepState);
const [showErrors, setShowErrors] = useState(false);
const captchaProvider = useRecoilValue(captchaProviderState);
const isRequestingCaptchaToken = useRecoilValue(
isRequestingCaptchaTokenState,
);
const {
signInUpMode,
continueWithEmail,
continueWithCredentials,
submitCredentials,
} = useSignInUp(form);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitButtonDisabled) return;
if (signInUpStep === SignInUpStep.Init) {
continueWithEmail();
} else if (signInUpStep === SignInUpStep.Email) {
if (isDefined(form?.formState?.errors?.email)) {
setShowErrors(true);
return;
}
continueWithCredentials();
} else if (signInUpStep === SignInUpStep.Password) {
if (!form.formState.isSubmitting) {
setShowErrors(true);
form.handleSubmit(submitCredentials)();
}
}
};
const buttonTitle = useMemo(() => {
if (signInUpStep === SignInUpStep.Init) {
return 'Continue With Email';
}
if (
signInUpMode === SignInUpMode.SignIn &&
signInUpStep === SignInUpStep.Password
) {
return 'Sign in';
}
if (
signInUpMode === SignInUpMode.SignUp &&
signInUpStep === SignInUpStep.Password
) {
return 'Sign up';
}
return 'Continue';
}, [signInUpMode, signInUpStep]);
const shouldWaitForCaptchaToken =
signInUpStep !== SignInUpStep.Init &&
isDefined(captchaProvider?.provider) &&
isRequestingCaptchaToken;
const isEmailStepSubmitButtonDisabledCondition =
signInUpStep === SignInUpStep.Email &&
(!validationSchema.shape.email.safeParse(form.watch('email')).success ||
shouldWaitForCaptchaToken);
// TODO: isValid is actually a proxy function. If it is not rendered the first time, react might not trigger re-renders
// We make the isValid check synchronous and update a reactState to make sure this does not happen
const isPasswordStepSubmitButtonDisabledCondition =
signInUpStep === SignInUpStep.Password &&
(!form.formState.isValid ||
form.formState.isSubmitting ||
shouldWaitForCaptchaToken);
const isSubmitButtonDisabled =
isEmailStepSubmitButtonDisabledCondition ||
isPasswordStepSubmitButtonDisabledCondition;
return (
<>
{(signInUpStep === SignInUpStep.Password ||
signInUpStep === SignInUpStep.Email ||
signInUpStep === SignInUpStep.Init) && (
<>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
<StyledForm onSubmit={handleSubmit}>
{signInUpStep !== SignInUpStep.Init && (
<SignInUpEmailField showErrors={showErrors} />
)}
{signInUpStep === SignInUpStep.Password && (
<SignInUpPasswordField
showErrors={showErrors}
signInUpMode={signInUpMode}
/>
)}
<MainButton
title={buttonTitle}
type="submit"
variant={
signInUpStep === SignInUpStep.Init ? 'secondary' : 'primary'
}
Icon={() => (form.formState.isSubmitting ? <Loader /> : null)}
disabled={isSubmitButtonDisabled}
fullWidth
/>
</StyledForm>
</FormProvider>
</>
)}
</>
);
};

View File

@ -0,0 +1,32 @@
import { IconGoogle, MainButton, HorizontalSeparator } from 'twenty-ui';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { useTheme } from '@emotion/react';
import { useRecoilValue } from 'recoil';
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
import { memo } from 'react';
const GoogleIcon = memo(() => {
const theme = useTheme();
return <IconGoogle size={theme.icon.size.md} />;
});
export const SignInUpWithGoogle = () => {
const signInUpStep = useRecoilValue(signInUpStepState);
const { signInWithGoogle } = useSignInWithGoogle();
return (
<>
<MainButton
Icon={GoogleIcon}
title="Continue with Google"
onClick={signInWithGoogle}
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
);
};

View File

@ -0,0 +1,27 @@
import { IconMicrosoft, MainButton, HorizontalSeparator } from 'twenty-ui';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { useTheme } from '@emotion/react';
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
import { useRecoilValue } from 'recoil';
export const SignInUpWithMicrosoft = () => {
const theme = useTheme();
const signInUpStep = useRecoilValue(signInUpStepState);
const { signInWithMicrosoft } = useSignInWithMicrosoft();
return (
<>
<MainButton
Icon={() => <IconMicrosoft size={theme.icon.size.md} />}
title="Continue with Microsoft"
onClick={signInWithMicrosoft}
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
);
};

View File

@ -0,0 +1,40 @@
import { IconLock, MainButton, HorizontalSeparator } from 'twenty-ui';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { useTheme } from '@emotion/react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { authProvidersState } from '@/client-config/states/authProvidersState';
export const SignInUpWithSSO = () => {
const theme = useTheme();
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const authProviders = useRecoilValue(authProvidersState);
const signInUpStep = useRecoilValue(signInUpStepState);
const { redirectToSSOLoginPage } = useSSO();
const signInWithSSO = () => {
if (authProviders.sso.length === 1) {
return redirectToSSOLoginPage(authProviders.sso[0].id);
}
setSignInUpStep(SignInUpStep.SSOIdentityProviderSelection);
};
return (
<>
<MainButton
Icon={() => <IconLock size={theme.icon.size.md} />}
title="Single sign-on (SSO)"
onClick={signInWithSSO}
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
);
};

View File

@ -0,0 +1,86 @@
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { SignInUpStep } from '@/auth/states/signInUpStepState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import styled from '@emotion/styled';
import { useCallback, useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { ActionLink, HorizontalSeparator } from 'twenty-ui';
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoogle';
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/SignInUpWithMicrosoft';
import { SignInUpWithSSO } from '@/auth/sign-in-up/components/SignInUpWithSSO';
import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/SignInUpWithCredentials';
import { useLocation } from 'react-router-dom';
import { isDefined } from '~/utils/isDefined';
const StyledContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
margin-top: ${({ theme }) => theme.spacing(4)};
`;
export const SignInUpWorkspaceScopeForm = () => {
const [authProviders] = useRecoilState(authProvidersState);
const { form } = useSignInUpForm();
const { handleResetPassword } = useHandleResetPassword();
const { signInUpStep, continueWithEmail, continueWithCredentials } =
useSignInUp(form);
const location = useLocation();
const checkAuthProviders = useCallback(() => {
if (
signInUpStep === SignInUpStep.Init &&
!authProviders.google &&
!authProviders.microsoft &&
!authProviders.sso
) {
return continueWithEmail();
}
const searchParams = new URLSearchParams(location.search);
const email = searchParams.get('email');
if (isDefined(email) && authProviders.password) {
return continueWithCredentials();
}
}, [
continueWithCredentials,
location.search,
authProviders.google,
authProviders.microsoft,
authProviders.password,
authProviders.sso,
continueWithEmail,
signInUpStep,
]);
useEffect(() => {
checkAuthProviders();
}, [checkAuthProviders]);
return (
<>
<StyledContentContainer>
{authProviders.google && <SignInUpWithGoogle />}
{authProviders.microsoft && <SignInUpWithMicrosoft />}
{authProviders.sso.length > 0 && <SignInUpWithSSO />}
{(authProviders.google ||
authProviders.microsoft ||
authProviders.sso.length > 0) &&
authProviders.password ? (
<HorizontalSeparator visible />
) : null}
{authProviders.password && <SignInUpWithCredentials />}
</StyledContentContainer>
{signInUpStep === SignInUpStep.Password && (
<ActionLink onClick={handleResetPassword(form.getValues('email'))}>
Forgot your password?
</ActionLink>
)}
</>
);
};

View File

@ -3,9 +3,7 @@
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';
@ -13,20 +11,8 @@ 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']) => {
@ -63,6 +49,5 @@ export const useSSO = () => {
return {
redirectToSSOLoginPage,
getAuthorizationUrlForSSO,
findAvailableSSOProviderByEmail,
};
};

View File

@ -7,36 +7,25 @@ import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useRecoilState } from 'recoil';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { isDefined } from '~/utils/isDefined';
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { AppPath } from '@/types/AppPath';
import { useAuth } from '../../hooks/useAuth';
export enum SignInUpMode {
SignIn = 'sign-in',
SignUp = 'sign-up',
}
import { signInUpModeState } from '@/auth/states/signInUpModeState';
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
export const useSignInUp = (form: UseFormReturn<Form>) => {
const { enqueueSnackBar } = useSnackBar();
const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState);
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
const isMatchingLocation = useIsMatchingLocation();
const { redirectToSSOLoginPage, findAvailableSSOProviderByEmail } = useSSO();
const setAvailableWorkspacesForSSOState = useSetRecoilState(
availableSSOIdentityProvidersState,
);
const workspaceInviteHash = useParams().workspaceInviteHash;
const [searchParams] = useSearchParams();
const workspacePersonalInviteToken =
@ -44,12 +33,6 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite));
const [signInUpMode, setSignInUpMode] = useState<SignInUpMode>(() => {
return isMatchingLocation(AppPath.SignInUp)
? SignInUpMode.SignIn
: SignInUpMode.SignUp;
});
const {
signInWithCredentials,
signUpWithCredentials,
@ -67,7 +50,12 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
? SignInUpMode.SignIn
: SignInUpMode.SignUp,
);
}, [isMatchingLocation, requestFreshCaptchaToken, setSignInUpStep]);
}, [
isMatchingLocation,
requestFreshCaptchaToken,
setSignInUpMode,
setSignInUpStep,
]);
const continueWithCredentials = useCallback(async () => {
const token = await readCaptchaToken();
@ -101,47 +89,9 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
enqueueSnackBar,
requestFreshCaptchaToken,
setSignInUpStep,
setSignInUpMode,
]);
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();
@ -150,19 +100,21 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
throw new Error('Email and password are required');
}
signInUpMode === SignInUpMode.SignIn && !isInviteMode
? await signInWithCredentials(
data.email.toLowerCase().trim(),
data.password,
token,
)
: await signUpWithCredentials(
data.email.toLowerCase().trim(),
data.password,
workspaceInviteHash,
workspacePersonalInviteToken,
token,
);
if (signInUpMode === SignInUpMode.SignIn && !isInviteMode) {
await signInWithCredentials(
data.email.toLowerCase().trim(),
data.password,
token,
);
} else {
await signUpWithCredentials(
data.email.toLowerCase().trim(),
data.password,
workspaceInviteHash,
workspacePersonalInviteToken,
token,
);
}
} catch (err: any) {
enqueueSnackBar(err?.message, {
variant: SnackBarVariant.Error,
@ -189,8 +141,6 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
signInUpMode,
continueWithCredentials,
continueWithEmail,
continueWithSSO,
submitSSOEmail,
submitCredentials,
};
};

View File

@ -3,33 +3,50 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useRecoilValue } from 'recoil';
import { z } from 'zod';
import { useLocation } from 'react-router-dom';
import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { useSearchParams } from 'react-router-dom';
import { isDefined } from '~/utils/isDefined';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
export const validationSchema = z
.object({
exist: z.boolean(),
email: z.string().trim().email('Email must be a valid email'),
password: z
.string()
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
captchaToken: z.string().default(''),
})
.required();
const makeValidationSchema = (signInUpStep: SignInUpStep) =>
z
.object({
exist: z.boolean(),
email: z.string().trim().email('Email must be a valid email'),
password:
signInUpStep === SignInUpStep.Password
? z
.string()
.regex(
PASSWORD_REGEX,
'Password must contain at least 8 characters',
)
: z.string().optional(),
captchaToken: z.string().default(''),
})
.required();
export type Form = z.infer<typeof validationSchema>;
export type Form = z.infer<ReturnType<typeof makeValidationSchema>>;
export const useSignInUpForm = () => {
const location = useLocation();
const signInUpStep = useRecoilValue(signInUpStepState);
const validationSchema = makeValidationSchema(signInUpStep); // Create schema based on the current step
const isDeveloperDefaultSignInPrefilled = useRecoilValue(
isDeveloperDefaultSignInPrefilledState,
);
const [searchParams] = useSearchParams();
const invitationPrefilledEmail = searchParams.get('email');
const prefilledEmail = searchParams.get('email');
const form = useForm<Form>({
mode: 'onChange',
mode: 'onSubmit',
defaultValues: {
exist: false,
email: '',
@ -40,12 +57,12 @@ export const useSignInUpForm = () => {
});
useEffect(() => {
if (isDefined(invitationPrefilledEmail)) {
form.setValue('email', invitationPrefilledEmail);
if (isDefined(prefilledEmail)) {
form.setValue('email', prefilledEmail);
} else if (isDeveloperDefaultSignInPrefilled === true) {
form.setValue('email', 'tim@apple.dev');
form.setValue('password', 'Applecar2025');
}
}, [form, isDeveloperDefaultSignInPrefilled, invitationPrefilledEmail]);
}, [form, isDeveloperDefaultSignInPrefilled, prefilledEmail, location.search]);
return { form: form };
};

View File

@ -0,0 +1,9 @@
import { createState } from 'twenty-ui';
import { UserExists } from '~/generated/graphql';
export const availableSSOIdentityProvidersForAuthState = createState<
NonNullable<UserExists['availableWorkspaces']>[0]['sso']
>({
key: 'availableSSOIdentityProvidersForAuth',
defaultValue: [],
});

View File

@ -1,11 +0,0 @@
import { createState } from 'twenty-ui';
import { FindAvailableSsoIdentityProvidersMutationResult } from '~/generated/graphql';
export const availableSSOIdentityProvidersState = createState<
NonNullable<
FindAvailableSsoIdentityProvidersMutationResult['data']
>['findAvailableSSOIdentityProviders']
>({
key: 'availableSSOIdentityProviders',
defaultValue: [],
});

View File

@ -14,7 +14,11 @@ export type CurrentWorkspace = Pick<
| 'currentBillingSubscription'
| 'workspaceMembersCount'
| 'isPublicInviteLinkEnabled'
| 'isGoogleAuthEnabled'
| 'isMicrosoftAuthEnabled'
| 'isPasswordAuthEnabled'
| 'hasValidEntrepriseKey'
| 'subdomain'
| 'metadataVersion'
>;

View File

@ -0,0 +1,18 @@
import { cookieStorageEffect } from '~/utils/recoil-effects';
import { Workspace } from '~/generated/graphql';
import { createState } from 'twenty-ui';
export const lastAuthenticateWorkspaceState = createState<
| (Pick<Workspace, 'id' | 'subdomain'> & {
cookieAttributes?: Cookies.CookieAttributes;
})
| null
>({
key: 'lastAuthenticateWorkspaceState',
defaultValue: null,
effects: [
cookieStorageEffect('lastAuthenticateWorkspace', {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year
}),
],
});

View File

@ -0,0 +1,7 @@
import { createState } from 'twenty-ui';
import { SignInUpMode } from '@/auth/types/signInUpMode.type';
export const signInUpModeState = createState<SignInUpMode>({
key: 'signInUpModeState',
defaultValue: SignInUpMode.SignIn,
});

View File

@ -4,8 +4,8 @@ export enum SignInUpStep {
Init = 'init',
Email = 'email',
Password = 'password',
SSOEmail = 'SSOEmail',
SSOWorkspaceSelection = 'SSOWorkspaceSelection',
WorkspaceSelection = 'workspaceSelection',
SSOIdentityProviderSelection = 'SSOIdentityProviderSelection',
}
export const signInUpStepState = createState<SignInUpStep>({

View File

@ -2,9 +2,17 @@ import { createState } from 'twenty-ui';
import { AuthTokenPair } from '~/generated/graphql';
import { cookieStorageEffect } from '~/utils/recoil-effects';
export const tokenPairState = createState<AuthTokenPair | null>({
key: 'tokenPairState',
defaultValue: null,
effects: [cookieStorageEffect('tokenPair')],
effects: [
cookieStorageEffect(
'tokenPair',
{},
{
validateInitFn: (payload: AuthTokenPair) =>
Boolean(payload['accessToken']),
},
),
],
});

View File

@ -0,0 +1,8 @@
import { createState } from 'twenty-ui';
import { PublicWorkspaceDataOutput } from '~/generated/graphql';
export const workspacePublicDataState =
createState<PublicWorkspaceDataOutput | null>({
key: 'workspacePublicDataState',
defaultValue: null,
});

View File

@ -2,7 +2,10 @@ import { createState } from 'twenty-ui';
import { Workspace } from '~/generated/graphql';
export type Workspaces = Pick<Workspace, 'id' | 'logo' | 'displayName'>;
export type Workspaces = Pick<
Workspace,
'id' | 'logo' | 'displayName' | 'subdomain'
>;
export const workspacesState = createState<Workspaces[] | null>({
key: 'workspacesState',

View File

@ -0,0 +1,4 @@
export enum SignInUpMode {
SignIn = 'sign-in',
SignUp = 'sign-up',
}

View File

@ -1,5 +1,4 @@
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState';
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState';
@ -7,23 +6,28 @@ import { clientConfigApiStatusState } from '@/client-config/states/clientConfigA
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { useEffect } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useGetClientConfigQuery } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { urlManagerState } from '@/url-manager/states/url-manager.state';
import { isSSOEnabledState } from '@/client-config/states/isSSOEnabledState';
export const ClientConfigProviderEffect = () => {
const setAuthProviders = useSetRecoilState(authProvidersState);
const setIsDebugMode = useSetRecoilState(isDebugModeState);
const setIsAnalyticsEnabled = useSetRecoilState(isAnalyticsEnabledState);
const setUrlManager = useSetRecoilState(urlManagerState);
const setIsDeveloperDefaultSignInPrefilled = useSetRecoilState(
isDeveloperDefaultSignInPrefilledState,
);
const setIsSignUpDisabled = useSetRecoilState(isSignUpDisabledState);
const setIsMultiWorkspaceEnabled = useSetRecoilState(
isMultiWorkspaceEnabledState,
);
const setIsSSOEnabledState = useSetRecoilState(isSSOEnabledState);
const setBilling = useSetRecoilState(billingState);
const setSupportChat = useSetRecoilState(supportChatState);
@ -69,17 +73,10 @@ export const ClientConfigProviderEffect = () => {
error: undefined,
}));
setAuthProviders({
google: data?.clientConfig.authProviders.google,
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);
setIsDeveloperDefaultSignInPrefilled(data?.clientConfig.signInPrefilled);
setIsSignUpDisabled(data?.clientConfig.signUpDisabled);
setIsMultiWorkspaceEnabled(data?.clientConfig.isMultiWorkspaceEnabled);
setBilling(data?.clientConfig.billing);
setSupportChat(data?.clientConfig.support);
@ -97,12 +94,16 @@ export const ClientConfigProviderEffect = () => {
setChromeExtensionId(data?.clientConfig?.chromeExtensionId);
setApiConfig(data?.clientConfig?.api);
setIsSSOEnabledState(data?.clientConfig?.isSSOEnabled);
setUrlManager({
defaultSubdomain: data?.clientConfig?.defaultSubdomain,
frontDomain: data?.clientConfig?.frontDomain,
});
}, [
data,
setAuthProviders,
setIsDebugMode,
setIsDeveloperDefaultSignInPrefilled,
setIsSignUpDisabled,
setIsMultiWorkspaceEnabled,
setSupportChat,
setBilling,
setSentryConfig,
@ -113,6 +114,8 @@ export const ClientConfigProviderEffect = () => {
setApiConfig,
setIsAnalyticsEnabled,
error,
setUrlManager,
setIsSSOEnabledState,
]);
return <></>;

View File

@ -3,19 +3,16 @@ import { gql } from '@apollo/client';
export const GET_CLIENT_CONFIG = gql`
query GetClientConfig {
clientConfig {
authProviders {
google
password
microsoft
sso
}
billing {
isBillingEnabled
billingUrl
billingFreeTrialDurationInDays
}
signInPrefilled
signUpDisabled
isMultiWorkspaceEnabled
isSSOEnabled
defaultSubdomain
frontDomain
debugMode
analyticsEnabled
support {

View File

@ -5,10 +5,10 @@ import { AuthProviders } from '~/generated/graphql';
export const authProvidersState = createState<AuthProviders>({
key: 'authProvidersState',
defaultValue: {
google: false,
google: true,
magicLink: false,
password: false,
password: true,
microsoft: false,
sso: false,
sso: [],
},
});

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const isMultiWorkspaceEnabledState = createState<boolean>({
key: 'isMultiWorkspaceEnabled',
defaultValue: false,
});

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const isSSOEnabledState = createState<boolean>({
key: 'isSSOEnabledState',
defaultValue: false,
});

View File

@ -1,6 +0,0 @@
import { createState } from 'twenty-ui';
export const isSignUpDisabledState = createState<boolean>({
key: 'isSignUpDisabledState',
defaultValue: false,
});

View File

@ -15,10 +15,14 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({
id: '1',
featureFlags: [],
allowImpersonation: false,
subdomain: 'test',
activationStatus: WorkspaceActivationStatus.Active,
hasValidEntrepriseKey: false,
metadataVersion: 1,
isPublicInviteLinkEnabled: false,
isGoogleAuthEnabled: true,
isMicrosoftAuthEnabled: false,
isPasswordAuthEnabled: true,
});
},
});

View File

@ -58,7 +58,7 @@ const StyledIconContainer = styled.div`
height: 75%;
`;
const StyledDeveloperSection = styled.div`
const StyledContainer = styled.div`
display: flex;
width: 100%;
gap: ${({ theme }) => theme.spacing(1)};
@ -82,7 +82,6 @@ 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;
@ -192,14 +191,20 @@ export const SettingsNavigationDrawerItems = () => {
Icon={IconCode}
/>
)}
{isSSOEnabled && (
<SettingsNavigationDrawerItem
label="Security"
path={SettingsPath.Security}
Icon={IconKey}
/>
{isAdvancedModeEnabled && (
<StyledContainer>
<StyledIconContainer>
<StyledIconTool size={12} color={MAIN_COLORS.yellow} />
</StyledIconContainer>
<SettingsNavigationDrawerItem
label="Security"
path={SettingsPath.Security}
Icon={IconKey}
/>
</StyledContainer>
)}
</NavigationDrawerSection>
<AnimatePresence>
{isAdvancedModeEnabled && (
<motion.div
@ -209,7 +214,7 @@ export const SettingsNavigationDrawerItems = () => {
exit="exit"
variants={motionAnimationVariants}
>
<StyledDeveloperSection>
<StyledContainer>
<StyledIconContainer>
<StyledIconTool size={12} color={MAIN_COLORS.yellow} />
</StyledIconContainer>
@ -228,7 +233,7 @@ export const SettingsNavigationDrawerItems = () => {
/>
)}
</NavigationDrawerSection>
</StyledDeveloperSection>
</StyledContainer>
</motion.div>
)}
</AnimatePresence>

View File

@ -10,7 +10,7 @@ import styled from '@emotion/styled';
import { ReactElement } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { H2Title, IconComponent, IconKey, Section } from 'twenty-ui';
import { IdpType } from '~/generated/graphql';
import { IdentityProviderType } from '~/generated/graphql';
const StyledInputsContainer = styled.div`
display: grid;
@ -30,8 +30,8 @@ export const SettingsSSOIdentitiesProvidersForm = () => {
const { control, getValues } =
useFormContext<SettingSecurityNewSSOIdentityFormValues>();
const IdpMap: Record<
IdpType,
const IdentitiesProvidersMap: Record<
IdentityProviderType,
{
form: ReactElement;
option: {
@ -62,12 +62,12 @@ export const SettingsSSOIdentitiesProvidersForm = () => {
},
};
const getFormByType = (type: Uppercase<IdpType> | undefined) => {
const getFormByType = (type: Uppercase<IdentityProviderType> | undefined) => {
switch (type) {
case IdpType.Oidc:
return IdpMap.OIDC.form;
case IdpType.Saml:
return IdpMap.SAML.form;
case IdentityProviderType.Oidc:
return IdentitiesProvidersMap.OIDC.form;
case IdentityProviderType.Saml:
return IdentitiesProvidersMap.SAML.form;
default:
return null;
}
@ -106,7 +106,7 @@ export const SettingsSSOIdentitiesProvidersForm = () => {
render={({ field: { onChange, value } }) => (
<SettingsRadioCardContainer
value={value}
options={Object.values(IdpMap).map(
options={Object.values(IdentitiesProvidersMap).map(
(identityProviderType) => identityProviderType.option,
)}
onChange={onChange}

View File

@ -8,11 +8,14 @@ import { SettingsPath } from '@/types/SettingsPath';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsSSOIdentitiesProvidersListCardWrapper } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import isPropValid from '@emotion/is-prop-valid';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { useRecoilValue, useRecoilState } from 'recoil';
import { IconKey } from 'twenty-ui';
import { useListSsoIdentityProvidersByWorkspaceIdQuery } from '~/generated/graphql';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
const StyledLink = styled(Link, {
shouldForwardProp: (prop) => isPropValid(prop) && prop !== 'isDisabled',
@ -22,11 +25,29 @@ const StyledLink = styled(Link, {
`;
export const SettingsSSOIdentitiesProvidersListCard = () => {
const { enqueueSnackBar } = useSnackBar();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState);
const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState(
SSOIdentitiesProvidersState,
);
return !SSOIdentitiesProviders.length ? (
const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({
skip: currentWorkspace?.hasValidEntrepriseKey === false,
onCompleted: (data) => {
setSSOIdentitiesProviders(
data?.listSSOIdentityProvidersByWorkspaceId ?? [],
);
},
onError: (error: Error) => {
enqueueSnackBar(error.message, {
variant: SnackBarVariant.Error,
});
},
});
return loading || !SSOIdentitiesProviders.length ? (
<StyledLink
to={getSettingsPagePath(SettingsPath.NewSSOIdentityProvider)}
isDisabled={currentWorkspace?.hasValidEntrepriseKey !== true}

View File

@ -5,33 +5,14 @@ import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/securit
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { useListSsoIdentityProvidersByWorkspaceIdQuery } from '~/generated/graphql';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilState } from 'recoil';
import { useNavigate } from 'react-router-dom';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
import { useRecoilValue } from 'recoil';
export const SettingsSSOIdentitiesProvidersListCardWrapper = () => {
const { enqueueSnackBar } = useSnackBar();
const navigate = useNavigate();
const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState(
SSOIdentitiesProvidersState,
);
const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({
onCompleted: (data) => {
setSSOIdentitiesProviders(
data?.listSSOIdentityProvidersByWorkspaceId ?? [],
);
},
onError: (error: Error) => {
enqueueSnackBar(error.message, {
variant: SnackBarVariant.Error,
});
},
});
const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState);
return (
<SettingsListCard
@ -39,7 +20,6 @@ export const SettingsSSOIdentitiesProvidersListCardWrapper = () => {
getItemLabel={(SSOIdentityProvider) =>
`${SSOIdentityProvider.name} - ${SSOIdentityProvider.type}`
}
isLoading={loading}
RowIconFn={(SSOIdentityProvider) =>
guessSSOIdentityProviderIconByUrl(SSOIdentityProvider.issuer)
}

View File

@ -2,24 +2,98 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
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 { Card, IconLink, isDefined } from 'twenty-ui';
import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil';
import {
IconLink,
Card,
IconGoogle,
IconMicrosoft,
IconPassword,
} from 'twenty-ui';
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
import { AuthProviders } from '~/generated-metadata/graphql';
import { capitalize } from '~/utils/string/capitalize';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state';
const StyledSettingsSecurityOptionsList = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsSecurityOptionsList = () => {
const { enqueueSnackBar } = useSnackBar();
const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState);
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
if (!isDefined(currentWorkspace)) {
throw new Error(
'The current workspace must be defined to edit its security options.',
);
}
const [updateWorkspace] = useUpdateWorkspaceMutation();
const isValidAuthProvider = (
key: string,
): key is Exclude<keyof typeof currentWorkspace, '__typename'> => {
if (!currentWorkspace) return false;
return Reflect.has(currentWorkspace, key);
};
const toggleAuthMethod = async (
authProvider: keyof Omit<AuthProviders, '__typename' | 'magicLink' | 'sso'>,
) => {
if (!currentWorkspace?.id) {
throw new Error('User is not logged in');
}
const key = `is${capitalize(authProvider)}AuthEnabled`;
if (!isValidAuthProvider(key)) {
throw new Error('Invalid auth provider');
}
const allAuthProvidersEnabled = [
currentWorkspace.isGoogleAuthEnabled,
currentWorkspace.isMicrosoftAuthEnabled,
currentWorkspace.isPasswordAuthEnabled,
(SSOIdentitiesProviders?.length ?? 0) > 0,
];
if (
currentWorkspace[key] === true &&
allAuthProvidersEnabled.filter((isAuthEnable) => isAuthEnable).length <= 1
) {
return enqueueSnackBar(
'At least one authentication method must be enabled',
{
variant: SnackBarVariant.Error,
},
);
}
setCurrentWorkspace({
...currentWorkspace,
[key]: !currentWorkspace[key],
});
updateWorkspace({
variables: {
input: {
[key]: !currentWorkspace[key],
},
},
}).catch((err) => {
// rollback optimistic update if err
setCurrentWorkspace({
...currentWorkspace,
[key]: !currentWorkspace[key],
});
enqueueSnackBar(err?.message, {
variant: SnackBarVariant.Error,
});
});
};
const handleChange = async (value: boolean) => {
try {
if (!currentWorkspace?.id) {
@ -44,17 +118,49 @@ export const SettingsSecurityOptionsList = () => {
};
return (
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconLink}
title="Invite by Link"
description="Allow the invitation of new users by sharing an invite link."
checked={currentWorkspace.isPublicInviteLinkEnabled}
advancedMode
onChange={() =>
handleChange(!currentWorkspace.isPublicInviteLinkEnabled)
}
/>
</Card>
<StyledSettingsSecurityOptionsList>
{currentWorkspace && (
<>
<Card>
<SettingsOptionCardContentToggle
Icon={IconGoogle}
title="Google"
description="Allow logins through Google's single sign-on functionality."
checked={currentWorkspace.isGoogleAuthEnabled}
advancedMode
onChange={() => toggleAuthMethod('google')}
/>
<SettingsOptionCardContentToggle
Icon={IconMicrosoft}
title="Microsoft"
description="Allow logins through Microsoft's single sign-on functionality."
checked={currentWorkspace.isMicrosoftAuthEnabled}
advancedMode
onChange={() => toggleAuthMethod('microsoft')}
/>
<SettingsOptionCardContentToggle
Icon={IconPassword}
title="Password"
description="Allow users to sign in with an email and password."
checked={currentWorkspace.isPasswordAuthEnabled}
advancedMode
onChange={() => toggleAuthMethod('password')}
/>
</Card>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconLink}
title="Invite by Link"
description="Allow the invitation of new users by sharing an invite link."
checked={currentWorkspace.isPublicInviteLinkEnabled}
advancedMode
onChange={() =>
handleChange(!currentWorkspace.isPublicInviteLinkEnabled)
}
/>
</Card>
</>
)}
</StyledSettingsSecurityOptionsList>
);
};

View File

@ -0,0 +1,4 @@
export type AuthProvidersKeys =
| 'isGoogleAuthEnabled'
| 'isMicrosoftAuthEnabled'
| 'isPasswordAuthEnabled';

View File

@ -2,12 +2,15 @@
import { SSOIdentitiesProvidersParamsSchema } from '@/settings/security/validation-schemas/SSOIdentityProviderSchema';
import { z } from 'zod';
import { IdpType, SsoIdentityProviderStatus } from '~/generated/graphql';
import {
IdentityProviderType,
SsoIdentityProviderStatus,
} from '~/generated/graphql';
export type SSOIdentityProvider = {
__typename: 'SSOIdentityProvider';
id: string;
type: IdpType;
type: IdentityProviderType;
issuer: string;
name?: string | null;
status: SsoIdentityProviderStatus;

View File

@ -16,7 +16,6 @@ export const parseSAMLMetadataFromXMLFile = (
try {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, 'application/xml');
if (xmlDoc.getElementsByTagName('parsererror').length > 0) {
throw new Error('Error parsing XML');
}
@ -28,10 +27,10 @@ export const parseSAMLMetadataFromXMLFile = (
'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 keyInfo = keyDescriptor?.getElementsByTagName('ds:KeyInfo')[0];
const x509Data = keyInfo?.getElementsByTagName('ds:X509Data')[0];
const x509Certificate = x509Data
.getElementsByTagName('ds:X509Certificate')?.[0]
?.getElementsByTagName('ds:X509Certificate')?.[0]
.textContent?.trim();
const singleSignOnServices = Array.from(

View File

@ -1,17 +1,25 @@
/* @license Enterprise */
import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider';
import { IdpType } from '~/generated/graphql';
import { IdentityProviderType } from '~/generated/graphql';
export const sSOIdentityProviderDefaultValues: Record<
IdpType,
IdentityProviderType,
() => SettingSecurityNewSSOIdentityFormValues
> = {
SAML: () => ({
type: 'SAML',
ssoURL: '',
name: '',
id: crypto.randomUUID(),
id:
window.location.protocol === 'https:'
? crypto.randomUUID()
: '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
(
+c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))
).toString(16),
),
certificate: '',
issuer: '',
}),

View File

@ -10,7 +10,7 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const WorkspaceLogoUploader = () => {
const [uploadLogo] = useUploadWorkspaceLogoMutation();
const [updateWorkspce] = useUpdateWorkspaceMutation();
const [updateWorkspace] = useUpdateWorkspaceMutation();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
@ -39,7 +39,7 @@ export const WorkspaceLogoUploader = () => {
if (!currentWorkspace?.id) {
throw new Error('Workspace id not found');
}
await updateWorkspce({
await updateWorkspace({
variables: {
input: {
logo: null,

View File

@ -19,6 +19,7 @@ export enum SettingsPath {
ServerlessFunctionDetail = 'functions/:serverlessFunctionId',
WorkspaceMembersPage = 'workspace-members',
Workspace = 'workspace',
Domain = 'domain',
CRMMigration = 'crm-migration',
Developers = 'developers',
ServerlessFunctions = 'functions',

View File

@ -15,6 +15,13 @@ import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { IconChevronDown, MenuItemSelectAvatar } from 'twenty-ui';
import { getImageAbsoluteURI } from '~/utils/image/getImageAbsoluteURI';
import { Link } from 'react-router-dom';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
const StyledLink = styled(Link)`
text-decoration: none;
width: 100%;
`;
const StyledLogo = styled.div<{ logo: string }>`
background: url(${({ logo }) => logo});
@ -72,6 +79,7 @@ export const MultiWorkspaceDropdownButton = ({
useState(false);
const { switchWorkspace } = useWorkspaceSwitching();
const { buildWorkspaceUrl } = useUrlManager();
const { closeDropdown } = useDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
@ -96,13 +104,9 @@ export const MultiWorkspaceDropdownButton = ({
isNavigationDrawerExpanded={isNavigationDrawerExpanded}
>
<StyledLogo
logo={
getImageAbsoluteURI(
currentWorkspace?.logo === null
? DEFAULT_WORKSPACE_LOGO
: currentWorkspace?.logo,
) ?? ''
}
logo={getImageAbsoluteURI(
currentWorkspace?.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
/>
<NavigationDrawerAnimatedCollapseWrapper>
<StyledLabel>{currentWorkspace?.displayName ?? ''}</StyledLabel>
@ -118,23 +122,26 @@ export const MultiWorkspaceDropdownButton = ({
dropdownComponents={
<DropdownMenuItemsContainer>
{workspaces.map((workspace) => (
<MenuItemSelectAvatar
<StyledLink
key={workspace.id}
text={workspace.displayName ?? ''}
avatar={
<StyledLogo
logo={
getImageAbsoluteURI(
workspace.logo === null
? DEFAULT_WORKSPACE_LOGO
: workspace.logo,
) ?? ''
}
/>
}
selected={currentWorkspace?.id === workspace.id}
onClick={() => handleChange(workspace.id)}
/>
to={buildWorkspaceUrl(workspace.subdomain)}
>
<MenuItemSelectAvatar
text={workspace.displayName ?? ''}
avatar={
<StyledLogo
logo={getImageAbsoluteURI(
workspace.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
/>
}
selected={currentWorkspace?.id === workspace.id}
onClick={(event) => {
event?.preventDefault();
handleChange(workspace.id);
}}
/>
</StyledLink>
))}
</DropdownMenuItemsContainer>
}

View File

@ -11,6 +11,7 @@ import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigat
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { isNonEmptyString } from '@sniptt/guards';
import { NavigationDrawerCollapseButton } from './NavigationDrawerCollapseButton';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
const StyledContainer = styled.div`
align-items: center;
@ -60,14 +61,17 @@ export const NavigationDrawerHeader = ({
}: NavigationDrawerHeaderProps) => {
const isMobile = useIsMobile();
const workspaces = useRecoilValue(workspacesState);
const isMultiWorkspace = workspaces !== null && workspaces.length > 1;
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const isNavigationDrawerExpanded = useRecoilValue(
isNavigationDrawerExpandedState,
);
return (
<StyledContainer>
{isMultiWorkspace ? (
{isMultiWorkspaceEnabled &&
workspaces !== null &&
workspaces.length > 1 ? (
<MultiWorkspaceDropdownButton workspaces={workspaces} />
) : (
<StyledSingleWorkspaceContainer>

View File

@ -1,74 +1,44 @@
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilValue } from 'recoil';
import { useAuth } from '@/auth/hooks/useAuth';
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { AppPath } from '@/types/AppPath';
import { useGenerateJwtMutation } from '~/generated/graphql';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSwitchWorkspaceMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
export const useWorkspaceSwitching = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
const [generateJWT] = useGenerateJwtMutation();
const { redirectToSSOLoginPage } = useSSO();
const [switchWorkspaceMutation] = useSwitchWorkspaceMutation();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const setAvailableWorkspacesForSSOState = useSetRecoilState(
availableSSOIdentityProvidersState,
);
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const { clearSession } = useAuth();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const { enqueueSnackBar } = useSnackBar();
const { redirectToHome, redirectToWorkspace } = useUrlManager();
const switchWorkspace = async (workspaceId: string) => {
if (currentWorkspace?.id === workspaceId) return;
const jwt = await generateJWT({
if (!isMultiWorkspaceEnabled) {
return enqueueSnackBar(
'Switching workspace is not available in single workspace mode',
{
variant: SnackBarVariant.Error,
},
);
}
const { data, errors } = await switchWorkspaceMutation({
variables: {
workspaceId,
},
});
if (isDefined(jwt.errors)) {
throw jwt.errors;
if (isDefined(errors) || !isDefined(data?.switchWorkspace.subdomain)) {
return redirectToHome();
}
if (!isDefined(jwt.data?.generateJWT)) {
throw new Error('could not create token');
}
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 clearSession();
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;
}
redirectToWorkspace(data.switchWorkspace.subdomain);
};
return { switchWorkspace };

View File

@ -0,0 +1,110 @@
import { useMemo, useCallback } from 'react';
import { isDefined } from '~/utils/isDefined';
import { urlManagerState } from '@/url-manager/states/url-manager.state';
import { useRecoilValue } from 'recoil';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
export const useUrlManager = () => {
const urlManager = useRecoilValue(urlManagerState);
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const homePageDomain = useMemo(() => {
return isMultiWorkspaceEnabled
? `${urlManager.defaultSubdomain}.${urlManager.frontDomain}`
: urlManager.frontDomain;
}, [
isMultiWorkspaceEnabled,
urlManager.defaultSubdomain,
urlManager.frontDomain,
]);
const isTwentyHomePage = useMemo(() => {
if (!isMultiWorkspaceEnabled) return true;
return window.location.hostname === homePageDomain;
}, [homePageDomain, isMultiWorkspaceEnabled]);
const isTwentyWorkspaceSubdomain = useMemo(() => {
if (!isMultiWorkspaceEnabled) return false;
if (
!isDefined(urlManager.frontDomain) ||
!isDefined(urlManager.defaultSubdomain)
) {
throw new Error('frontDomain and defaultSubdomain are required');
}
return window.location.hostname !== homePageDomain;
}, [
homePageDomain,
isMultiWorkspaceEnabled,
urlManager.defaultSubdomain,
urlManager.frontDomain,
]);
const getWorkspaceSubdomain = useMemo(() => {
if (!isDefined(urlManager.frontDomain)) {
throw new Error('frontDomain is not defined');
}
return isTwentyWorkspaceSubdomain
? window.location.hostname.replace(`.${urlManager.frontDomain}`, '')
: null;
}, [isTwentyWorkspaceSubdomain, urlManager.frontDomain]);
const buildWorkspaceUrl = useCallback(
(
subdomain?: string,
onPage?: string,
searchParams?: Record<string, string>,
) => {
const url = new URL(window.location.href);
if (isDefined(subdomain) && subdomain.length !== 0) {
url.hostname = `${subdomain}.${urlManager.frontDomain}`;
}
if (isDefined(onPage)) {
url.pathname = onPage;
}
if (isDefined(searchParams)) {
Object.entries(searchParams).forEach(([key, value]) =>
url.searchParams.set(key, value),
);
}
return url.toString();
},
[urlManager.frontDomain],
);
const redirectToWorkspace = useCallback(
(
subdomain: string,
onPage?: string,
searchParams?: Record<string, string>,
) => {
if (!isMultiWorkspaceEnabled) return;
window.location.href = buildWorkspaceUrl(subdomain, onPage, searchParams);
},
[buildWorkspaceUrl, isMultiWorkspaceEnabled],
);
const redirectToHome = useCallback(() => {
const url = new URL(window.location.href);
if (url.hostname !== homePageDomain) {
url.hostname = homePageDomain;
window.location.href = url.toString();
}
}, [homePageDomain]);
return {
redirectToHome,
redirectToWorkspace,
homePageDomain,
isTwentyHomePage,
buildWorkspaceUrl,
isTwentyWorkspaceSubdomain,
getWorkspaceSubdomain,
};
};

View File

@ -0,0 +1,12 @@
import { createState } from 'twenty-ui';
import { ClientConfig } from '~/generated/graphql';
export const urlManagerState = createState<
Pick<ClientConfig, 'frontDomain' | 'defaultSubdomain'>
>({
key: 'urlManager',
defaultValue: {
frontDomain: '',
defaultSubdomain: undefined,
},
});

View File

@ -32,6 +32,10 @@ export const USER_QUERY_FRAGMENT = gql`
allowImpersonation
activationStatus
isPublicInviteLinkEnabled
isGoogleAuthEnabled
isMicrosoftAuthEnabled
isPasswordAuthEnabled
subdomain
hasValidEntrepriseKey
featureFlags {
id
@ -53,6 +57,7 @@ export const USER_QUERY_FRAGMENT = gql`
logo
displayName
domainName
subdomain
}
}
userVars

View File

@ -0,0 +1,96 @@
import { useRecoilValue, useSetRecoilState, useRecoilState } from 'recoil';
import { useGetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { useEffect } from 'react';
import { isDefined } from '~/utils/isDefined';
import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
export const WorkspaceProviderEffect = () => {
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const setAuthProviders = useSetRecoilState(authProvidersState);
const setWorkspacePublicDataState = useSetRecoilState(
workspacePublicDataState,
);
const [lastAuthenticateWorkspace, setLastAuthenticateWorkspace] =
useRecoilState(lastAuthenticateWorkspaceState);
const {
redirectToHome,
getWorkspaceSubdomain,
redirectToWorkspace,
isTwentyHomePage,
} = useUrlManager();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
useGetPublicWorkspaceDataBySubdomainQuery({
skip:
(isMultiWorkspaceEnabled && isTwentyHomePage) ||
isDefined(workspacePublicData),
onCompleted: (data) => {
setAuthProviders(data.getPublicWorkspaceDataBySubdomain.authProviders);
setWorkspacePublicDataState(data.getPublicWorkspaceDataBySubdomain);
},
onError: (error) => {
// eslint-disable-next-line no-console
console.error(error);
setLastAuthenticateWorkspace(null);
redirectToHome();
},
});
useEffect(() => {
if (
isMultiWorkspaceEnabled &&
isDefined(workspacePublicData?.subdomain) &&
workspacePublicData.subdomain !== getWorkspaceSubdomain
) {
redirectToWorkspace(workspacePublicData.subdomain);
}
}, [
getWorkspaceSubdomain,
isMultiWorkspaceEnabled,
redirectToWorkspace,
workspacePublicData,
]);
useEffect(() => {
if (
isMultiWorkspaceEnabled &&
isDefined(lastAuthenticateWorkspace?.subdomain) &&
isTwentyHomePage
) {
redirectToWorkspace(lastAuthenticateWorkspace.subdomain);
}
}, [
isMultiWorkspaceEnabled,
isTwentyHomePage,
lastAuthenticateWorkspace,
redirectToWorkspace,
]);
useEffect(() => {
try {
if (isDefined(workspacePublicData?.logo)) {
const link: HTMLLinkElement =
document.querySelector("link[rel*='icon']") ||
document.createElement('link');
link.rel = 'icon';
link.href = workspacePublicData.logo;
document.getElementsByTagName('head')[0].appendChild(link);
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
}
}, [workspacePublicData]);
return <></>;
};

View File

@ -3,7 +3,13 @@ import { gql } from '@apollo/client';
export const ACTIVATE_WORKSPACE = gql`
mutation ActivateWorkspace($input: ActivateWorkspaceInput!) {
activateWorkspace(data: $input) {
id
workspace {
id
subdomain
}
loginToken {
...AuthTokenFragment
}
}
}
`;

View File

@ -5,9 +5,14 @@ export const UPDATE_WORKSPACE = gql`
updateWorkspace(data: $input) {
id
domainName
subdomain
displayName
logo
allowImpersonation
isPublicInviteLinkEnabled
isGoogleAuthEnabled
isMicrosoftAuthEnabled
isPasswordAuthEnabled
}
}
`;

View File

@ -1,7 +1,7 @@
import { Logo } from '@/auth/components/Logo';
import { Title } from '@/auth/components/Title';
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm';
import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
@ -16,6 +16,7 @@ import {
useAddUserToWorkspaceMutation,
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { currentUserState } from '@/auth/states/currentUserState';
const StyledContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
@ -28,6 +29,7 @@ export const Invite = () => {
const { form } = useSignInUpForm();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const currentUser = useRecoilValue(currentUserState);
const [addUserToWorkspace] = useAddUserToWorkspaceMutation();
const [addUserToWorkspaceByInviteToken] =
useAddUserToWorkspaceByInviteTokenMutation();
@ -77,7 +79,7 @@ export const Invite = () => {
<Logo secondaryLogo={workspaceFromInviteHash?.logo} />
</AnimatedEaseIn>
<Title animate>{title}</Title>
{isDefined(currentWorkspace) ? (
{isDefined(currentUser) ? (
<>
<StyledContentContainer>
<MainButton
@ -91,7 +93,7 @@ export const Invite = () => {
<FooterNote />
</>
) : (
<SignInUpForm />
<SignInUpWorkspaceScopeForm />
)}
</>
);

View File

@ -19,7 +19,7 @@ import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useNavigate, useParams } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { AnimatedEaseIn, MainButton } from 'twenty-ui';
import { z } from 'zod';
import {
@ -27,6 +27,7 @@ import {
useValidatePasswordResetTokenQuery,
} from '~/generated/graphql';
import { logError } from '~/utils/logError';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
const validationSchema = z
.object({
@ -71,6 +72,8 @@ const StyledInputContainer = styled.div`
export const PasswordReset = () => {
const { enqueueSnackBar } = useSnackBar();
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const navigate = useNavigate();
const [email, setEmail] = useState('');
@ -163,7 +166,7 @@ export const PasswordReset = () => {
isTokenValid && (
<StyledMainContainer>
<AnimatedEaseIn>
<Logo />
<Logo secondaryLogo={workspacePublicData?.logo} />
</AnimatedEaseIn>
<Title animate>Reset Password</Title>
<StyledContentContainer>

View File

@ -1,68 +0,0 @@
/* @license Enterprise */
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { HorizontalSeparator, MainButton } from 'twenty-ui';
import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO';
import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl';
import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
const StyledContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
margin-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledTitle = styled.h2`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: 0;
`;
export const SSOWorkspaceSelection = () => {
const availableSSOIdentityProviders = useRecoilValue(
availableSSOIdentityProvidersState,
);
const { redirectToSSOLoginPage } = useSSO();
const availableWorkspacesForSSOGroupByWorkspace =
availableSSOIdentityProviders.reduce(
(acc, idp) => {
acc[idp.workspace.id] = [...(acc[idp.workspace.id] ?? []), idp];
return acc;
},
{} as Record<string, typeof availableSSOIdentityProviders>,
);
return (
<>
<StyledContentContainer>
{Object.values(availableWorkspacesForSSOGroupByWorkspace).map(
(idps) => (
<>
<StyledTitle>
{idps[0].workspace.displayName ?? DEFAULT_WORKSPACE_NAME}
</StyledTitle>
<HorizontalSeparator visible={false} />
{idps.map((idp) => (
<>
<MainButton
title={idp.name}
onClick={() => redirectToSSOLoginPage(idp.id)}
Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)}
fullWidth
/>
<HorizontalSeparator visible={false} />
</>
))}
</>
),
)}
</StyledContentContainer>
<FooterNote />
</>
);
};

View File

@ -1,58 +1,70 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { SignInUpStep } from '@/auth/states/signInUpStepState';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm';
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { AnimatedEaseIn } from 'twenty-ui';
import { Logo } from '@/auth/components/Logo';
import { Title } from '@/auth/components/Title';
import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm';
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 { SignInUpStep } from '@/auth/states/signInUpStepState';
import { IconLockCustom } from '@ui/display/icon/components/IconLock';
import { AnimatedEaseIn } from 'twenty-ui';
import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm';
import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName';
import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
import { useMemo } from 'react';
import { isDefined } from '~/utils/isDefined';
import { SSOWorkspaceSelection } from './SSOWorkspaceSelection';
export const SignInUp = () => {
const { form } = useSignInUpForm();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { signInUpStep } = useSignInUp(form);
const { isTwentyHomePage, isTwentyWorkspaceSubdomain } = useUrlManager();
const { signInUpStep, signInUpMode } = useSignInUp(form);
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const signInUpForm = useMemo(() => {
if (isTwentyHomePage && isMultiWorkspaceEnabled) {
return <SignInUpGlobalScopeForm />;
}
const title = useMemo(() => {
if (
signInUpStep === SignInUpStep.Init ||
signInUpStep === SignInUpStep.Email
(!isMultiWorkspaceEnabled ||
(isMultiWorkspaceEnabled && isTwentyWorkspaceSubdomain)) &&
signInUpStep === SignInUpStep.SSOIdentityProviderSelection
) {
return 'Welcome to Twenty';
return <SignInUpSSOIdentityProviderSelection />;
}
if (signInUpStep === SignInUpStep.SSOWorkspaceSelection) {
return 'Choose SSO connection';
}
return signInUpMode === SignInUpMode.SignIn
? 'Sign in to Twenty'
: 'Sign up to Twenty';
}, [signInUpMode, signInUpStep]);
if (isDefined(currentWorkspace)) {
return <></>;
}
if (
isDefined(workspacePublicData) &&
(!isMultiWorkspaceEnabled || isTwentyWorkspaceSubdomain)
) {
return <SignInUpWorkspaceScopeForm />;
}
return <SignInUpGlobalScopeForm />;
}, [
isTwentyHomePage,
isMultiWorkspaceEnabled,
isTwentyWorkspaceSubdomain,
signInUpStep,
workspacePublicData,
]);
return (
<>
<AnimatedEaseIn>
{signInUpStep === SignInUpStep.SSOWorkspaceSelection ? (
<IconLockCustom size={40} />
) : (
<Logo />
)}
<Logo secondaryLogo={workspacePublicData?.logo} />
</AnimatedEaseIn>
<Title animate>{title}</Title>
{signInUpStep === SignInUpStep.SSOWorkspaceSelection ? (
<SSOWorkspaceSelection />
) : (
<SignInUpForm />
)}
<Title animate>
{`Welcome to ${workspacePublicData?.displayName ?? DEFAULT_WORKSPACE_NAME}`}
</Title>
{signInUpForm}
{signInUpStep !== SignInUpStep.Password && <FooterNote />}
</>
);
};

View File

@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useSetRecoilState } from 'recoil';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { H2Title, Loader, MainButton } from 'twenty-ui';
import { z } from 'zod';
@ -22,6 +22,9 @@ import {
useActivateWorkspaceMutation,
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { AppPath } from '@/types/AppPath';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
const StyledContentContainer = styled.div`
width: 100%;
@ -47,6 +50,8 @@ type Form = z.infer<typeof validationSchema>;
export const CreateWorkspace = () => {
const { enqueueSnackBar } = useSnackBar();
const onboardingStatus = useOnboardingStatus();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const { redirectToWorkspace } = useUrlManager();
const [activateWorkspace] = useActivateWorkspaceMutation();
const apolloMetadataClient = useApolloMetadataClient();
@ -75,8 +80,19 @@ export const CreateWorkspace = () => {
},
},
});
setIsCurrentUserLoaded(false);
if (isDefined(result.data) && isMultiWorkspaceEnabled) {
return redirectToWorkspace(
result.data.activateWorkspace.workspace.subdomain,
AppPath.Verify,
{
loginToken: result.data.activateWorkspace.loginToken.token,
},
);
}
await apolloMetadataClient?.refetchQueries({
include: [FIND_MANY_OBJECT_METADATA_ITEMS],
});
@ -93,7 +109,9 @@ export const CreateWorkspace = () => {
[
activateWorkspace,
setIsCurrentUserLoaded,
isMultiWorkspaceEnabled,
apolloMetadataClient,
redirectToWorkspace,
enqueueSnackBar,
],
);

View File

@ -1,4 +1,8 @@
import { GithubVersionLink, H2Title, Section } from 'twenty-ui';
import { GithubVersionLink, H2Title, Section, IconWorld } from 'twenty-ui';
import { Link } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import styled from '@emotion/styled';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace';
@ -9,39 +13,61 @@ import { WorkspaceLogoUploader } from '@/settings/workspace/components/Workspace
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import packageJson from '../../../package.json';
export const SettingsWorkspace = () => (
<SubMenuTopBarContainer
title="General"
links={[
{
children: 'Workspace',
href: getSettingsPagePath(SettingsPath.Workspace),
},
{ children: 'General' },
]}
>
<SettingsPageContainer>
<Section>
<H2Title title="Picture" />
<WorkspaceLogoUploader />
</Section>
<Section>
<H2Title title="Name" description="Name of your workspace" />
<NameField />
</Section>
<Section>
<H2Title
title="Support"
adornment={<ToggleImpersonate />}
description="Grant Twenty support temporary access to your workspace so we can troubleshoot problems or recover content on your behalf. You can revoke access at any time."
/>
</Section>
<Section>
<DeleteWorkspace />
</Section>
<Section>
<GithubVersionLink version={packageJson.version} />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
import { SettingsCard } from '@/settings/components/SettingsCard';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
const StyledLink = styled(Link)`
text-decoration: none;
`;
export const SettingsWorkspace = () => {
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
return (
<SubMenuTopBarContainer
title="General"
links={[
{
children: 'Workspace',
href: getSettingsPagePath(SettingsPath.Workspace),
},
{ children: 'General' },
]}
>
<SettingsPageContainer>
<Section>
<H2Title title="Picture" />
<WorkspaceLogoUploader />
</Section>
<Section>
<H2Title title="Name" description="Name of your workspace" />
<NameField />
</Section>
{isMultiWorkspaceEnabled && (
<Section>
<H2Title
title="Domain"
description="Edit your subdomain name or set a custom domain."
/>
<StyledLink to={getSettingsPagePath(SettingsPath.Domain)}>
<SettingsCard title="Customize Domain" Icon={<IconWorld />} />
</StyledLink>
</Section>
)}
<Section>
<H2Title
title="Support"
adornment={<ToggleImpersonate />}
description="Grant Twenty support temporary access to your workspace so we can troubleshoot problems or recover content on your behalf. You can revoke access at any time."
/>
</Section>
<Section>
<DeleteWorkspace />
</Section>
<Section>
<GithubVersionLink version={packageJson.version} />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -9,6 +9,9 @@ import { SettingsSecurityOptionsList } from '@/settings/security/components/Sett
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isSSOEnabledState } from '@/client-config/states/isSSOEnabledState';
import { useRecoilValue } from 'recoil';
const StyledContainer = styled.div`
width: 100%;
@ -26,6 +29,10 @@ const StyledSSOSection = styled(Section)`
`;
export const SettingsSecurity = () => {
const isSSOEnabled = useRecoilValue(isSSOEnabledState);
const isSSOSectionDisplay =
useIsFeatureEnabled('IS_SSO_ENABLED') && isSSOEnabled;
return (
<SubMenuTopBarContainer
title="Security"
@ -40,26 +47,28 @@ export const SettingsSecurity = () => {
>
<SettingsPageContainer>
<StyledMainContent>
<StyledSSOSection>
<H2Title
title="SSO"
description="Configure an SSO connection"
adornment={
<Tag
text={'Enterprise'}
color={'transparent'}
Icon={IconLock}
variant={'border'}
/>
}
/>
<SettingsSSOIdentitiesProvidersListCard />
</StyledSSOSection>
{isSSOSectionDisplay && (
<StyledSSOSection>
<H2Title
title="SSO"
description="Configure an SSO connection"
adornment={
<Tag
text={'Enterprise'}
color={'transparent'}
Icon={IconLock}
variant={'border'}
/>
}
/>
<SettingsSSOIdentitiesProvidersListCard />
</StyledSSOSection>
)}
<Section>
<AdvancedSettingsWrapper>
<StyledContainer>
<H2Title
title="Other"
title="Authentication"
description="Customize your workspace security"
/>
<SettingsSecurityOptionsList />

View File

@ -0,0 +1,154 @@
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { zodResolver } from '@hookform/resolvers/zod';
import { H2Title, Section } from 'twenty-ui';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilState, useRecoilValue } from 'recoil';
import styled from '@emotion/styled';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useNavigate } from 'react-router-dom';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
import { useUrlManager } from '@/url-manager/hooks/useUrlManager';
import { urlManagerState } from '@/url-manager/states/url-manager.state';
import { isDefined } from '~/utils/isDefined';
const validationSchema = z
.object({
subdomain: z
.string()
.min(1, { message: 'Subdomain can not be empty' })
.max(63, { message: 'Subdomain can not be longer than 63 characters' }),
})
.required();
type Form = z.infer<typeof validationSchema>;
const StyledDomainFromWrapper = styled.div`
align-items: center;
display: flex;
`;
const StyledDomain = styled.h2`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: 8px;
`;
export const SettingsDomain = () => {
const navigate = useNavigate();
const urlManager = useRecoilValue(urlManagerState);
const { enqueueSnackBar } = useSnackBar();
const [updateWorkspace] = useUpdateWorkspaceMutation();
const { buildWorkspaceUrl } = useUrlManager();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const handleSave = async () => {
try {
const values = getValues();
if (!values || !isValid || !currentWorkspace) {
throw new Error('Invalid form values');
}
await updateWorkspace({
variables: {
input: {
subdomain: values.subdomain,
},
},
});
setCurrentWorkspace({
...currentWorkspace,
subdomain: values.subdomain,
});
window.location.href = buildWorkspaceUrl(values.subdomain);
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
};
const {
control,
getValues,
formState: { isValid },
} = useForm<Form>({
mode: 'onChange',
defaultValues: {
subdomain: currentWorkspace?.subdomain ?? '',
},
resolver: zodResolver(validationSchema),
});
return (
<SubMenuTopBarContainer
title="General"
links={[
{
children: 'Workspace',
href: getSettingsPagePath(SettingsPath.Workspace),
},
{
children: 'General',
href: getSettingsPagePath(SettingsPath.Workspace),
},
{ children: 'Domain' },
]}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!isValid}
onCancel={() => navigate(getSettingsPagePath(SettingsPath.Workspace))}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title="Domain"
description="Set the name of your subdomain"
/>
{currentWorkspace?.subdomain && (
<StyledDomainFromWrapper>
<Controller
name="subdomain"
control={control}
render={({
field: { onChange, value },
fieldState: { error },
}) => (
<TextInputV2
value={value}
type="text"
onChange={onChange}
error={error?.message}
fullWidth
/>
)}
/>
{isDefined(urlManager) && isDefined(urlManager.frontDomain) && (
<StyledDomain>.{urlManager.frontDomain}</StyledDomain>
)}
</StyledDomainFromWrapper>
)}
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -25,6 +25,7 @@ import { ObjectMetadataItemsProvider } from '@/object-metadata/components/Object
import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider';
import { IconsProvider } from 'twenty-ui';
import { FullHeightStorybookLayout } from '../FullHeightStorybookLayout';
import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect';
export type PageDecoratorArgs = {
routePath: string;
@ -72,6 +73,7 @@ const Providers = () => {
<ClientConfigProviderEffect />
<ClientConfigProvider>
<UserProviderEffect />
<WorkspaceProviderEffect />
<UserProvider>
<ApolloMetadataClientMockedProvider>
<ObjectMetadataItemsProvider>

View File

@ -23,6 +23,7 @@ import { mockedStandardObjectMetadataQueryResult } from '~/testing/mock-data/gen
import { mockedTasks } from '~/testing/mock-data/tasks';
import { mockedRemoteServers } from './mock-data/remote-servers';
import { mockedViewFieldsData } from './mock-data/view-fields';
import { GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataBySubdomain';
const peopleMock = getPeopleMock();
const companiesMock = getCompaniesMock();
@ -41,6 +42,28 @@ export const graphqlMocks = {
},
});
}),
graphql.query(
getOperationName(GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN) ?? '',
() => {
return HttpResponse.json({
data: {
getPublicWorkspaceDataBySubdomain: {
id: 'id',
logo: 'logo',
displayName: 'displayName',
subdomain: 'subdomain',
authProviders: {
google: true,
microsoft: false,
password: true,
magicLink: false,
sso: [],
},
},
},
});
},
),
graphql.mutation(getOperationName(TRACK) ?? '', () => {
return HttpResponse.json({
data: {

View File

@ -3,18 +3,13 @@ import { CaptchaDriverType } from '~/generated/graphql';
export const mockedClientConfig: ClientConfig = {
signInPrefilled: true,
signUpDisabled: false,
isMultiWorkspaceEnabled: false,
isSSOEnabled: false,
frontDomain: 'localhost',
defaultSubdomain: 'app',
chromeExtensionId: 'MOCKED_EXTENSION_ID',
debugMode: false,
analyticsEnabled: true,
authProviders: {
sso: false,
google: true,
password: true,
magicLink: false,
microsoft: false,
__typename: 'AuthProviders',
},
support: {
supportDriver: 'front',
supportFrontChatId: null,

View File

@ -36,6 +36,7 @@ export const workspaceLogoUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII=';
export const mockDefaultWorkspace: Workspace = {
subdomain: 'acme.twenty.com',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w',
displayName: 'Twenty',
domainName: 'twenty.com',
@ -45,6 +46,9 @@ export const mockDefaultWorkspace: Workspace = {
allowImpersonation: true,
activationStatus: WorkspaceActivationStatus.Active,
hasValidEntrepriseKey: false,
isGoogleAuthEnabled: true,
isPasswordAuthEnabled: true,
isMicrosoftAuthEnabled: false,
featureFlags: [
{
id: '1492de61-5018-4368-8923-4f1eeaf988c4',

View File

@ -16,9 +16,9 @@ class CookieStorage {
Cookies.set(key, value, attributes);
}
removeItem(key: string): void {
removeItem(key: string, attributes?: Cookies.CookieAttributes): void {
this.keys.delete(key);
Cookies.remove(key);
Cookies.remove(key, attributes);
}
clear(): void {

View File

@ -1,4 +1,5 @@
import { AtomEffect } from 'recoil';
import omit from 'lodash.omit';
import { cookieStorage } from '~/utils/cookie-storage';
@ -20,25 +21,50 @@ export const localStorageEffect =
};
export const cookieStorageEffect =
<T>(key: string): AtomEffect<T | null> =>
<T>(
key: string,
attributes?: Cookies.CookieAttributes,
hooks?: {
validateInitFn?: (payload: T) => boolean;
},
): AtomEffect<T | null> =>
({ setSelf, onSet }) => {
const savedValue = cookieStorage.getItem(key);
if (
isDefined(savedValue) &&
isDefined(JSON.parse(savedValue)['accessToken'])
(!isDefined(hooks?.validateInitFn) ||
hooks.validateInitFn(JSON.parse(savedValue)))
) {
setSelf(JSON.parse(savedValue));
}
const defaultAttributes = {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
...(attributes ?? {}),
};
onSet((newValue, _, isReset) => {
if (!newValue) {
cookieStorage.removeItem(key);
cookieStorage.removeItem(key, defaultAttributes);
return;
}
const cookieAttributes = {
...defaultAttributes,
...(typeof newValue === 'object' &&
'cookieAttributes' in newValue &&
typeof newValue.cookieAttributes === 'object'
? newValue.cookieAttributes
: {}),
};
isReset
? cookieStorage.removeItem(key)
: cookieStorage.setItem(key, JSON.stringify(newValue), {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
});
? cookieStorage.removeItem(key, defaultAttributes)
: cookieStorage.setItem(
key,
JSON.stringify(omit(newValue, ['cookieAttributes'])),
cookieAttributes,
);
});
};

View File

@ -22,7 +22,7 @@ ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
# IS_BILLING_ENABLED=false
# BILLING_PLAN_REQUIRED_LINK=https://twenty.com/stripe-redirection
# AUTH_PASSWORD_ENABLED=false
# IS_SIGN_UP_DISABLED=false
# IS_MULTIWORKSPACE_ENABLED=false
# AUTH_MICROSOFT_ENABLED=false
# AUTH_MICROSOFT_CLIENT_ID=replace_me_with_azure_client_id
# AUTH_MICROSOFT_TENANT_ID=replace_me_with_azure_tenant_id

View File

@ -0,0 +1,123 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository, In } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { BaseCommandOptions } from 'src/database/commands/base.command';
// For DX only
type WorkspaceId = string;
type Subdomain = string;
@Command({
name: 'feat-0.34:add-subdomain-to-workspace',
description: 'Add a default subdomain to each workspace',
})
export class GenerateDefaultSubdomainCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
) {
super(workspaceRepository);
}
private generatePayloadForQuery({
id,
subdomain,
domainName,
displayName,
}: Workspace) {
const result = { id, subdomain };
if (domainName) {
const subdomain = domainName.split('.')[0];
if (subdomain.length > 0) {
result.subdomain = subdomain;
}
}
if (!domainName && displayName) {
const displayNameWords = displayName.match(/(\w| |\d)+/);
if (displayNameWords) {
result.subdomain = displayNameWords
.join('-')
.replace(/ /g, '')
.toLowerCase();
}
}
return result;
}
private groupBySubdomainName(
acc: Record<Subdomain, Array<WorkspaceId>>,
workspace: Workspace,
) {
const payload = this.generatePayloadForQuery(workspace);
acc[payload.subdomain] = acc[payload.subdomain]
? acc[payload.subdomain].concat([payload.id])
: [payload.id];
return acc;
}
private async deduplicateAndSave(
subdomain: Subdomain,
workspaceIds: Array<WorkspaceId>,
options: BaseCommandOptions,
) {
for (const [index, workspaceId] of workspaceIds.entries()) {
const subdomainDeduplicated =
index === 0 ? subdomain : `${subdomain}-${index}`;
this.logger.log(
`Updating workspace ${workspaceId} with subdomain ${subdomainDeduplicated}`,
);
if (!options.dryRun) {
await this.workspaceRepository.update(workspaceId, {
subdomain: subdomainDeduplicated,
});
}
}
}
async executeActiveWorkspacesCommand(
passedParam: string[],
options: BaseCommandOptions,
activeWorkspaceIds: string[],
): Promise<void> {
const workspaces = await this.workspaceRepository.find(
activeWorkspaceIds.length > 0
? {
where: {
id: In(activeWorkspaceIds),
},
}
: undefined,
);
if (workspaces.length === 0) {
this.logger.log('No workspaces found');
return;
}
const workspaceBySubdomain = Object.entries(
workspaces.reduce(
(acc, workspace) => this.groupBySubdomainName(acc, workspace),
{} as ReturnType<typeof this.groupBySubdomainName>,
),
);
for (const [subdomain, workspaceIds] of workspaceBySubdomain) {
await this.deduplicateAndSave(subdomain, workspaceIds, options);
}
}
}

View File

@ -0,0 +1,38 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { GenerateDefaultSubdomainCommand } from 'src/database/commands/upgrade-version/0-34/0-34-generate-subdomain.command';
interface UpdateTo0_34CommandOptions {
workspaceId?: string;
}
@Command({
name: 'upgrade-0.34',
description: 'Upgrade to 0.34',
})
export class UpgradeTo0_34Command extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly generateDefaultSubdomainCommand: GenerateDefaultSubdomainCommand,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
passedParam: string[],
options: UpdateTo0_34CommandOptions,
workspaceIds: string[],
): Promise<void> {
await this.generateDefaultSubdomainCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
}
}

View File

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { SearchModule } from 'src/engine/metadata-modules/search/search.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module';
import { UpgradeTo0_34Command } from 'src/database/commands/upgrade-version/0-34/0-34-upgrade-version.command';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
TypeOrmModule.forFeature(
[ObjectMetadataEntity, FieldMetadataEntity],
'metadata',
),
WorkspaceSyncMetadataCommandsModule,
SearchModule,
WorkspaceMigrationRunnerModule,
],
providers: [UpgradeTo0_34Command],
})
export class UpgradeTo0_33CommandModule {}

View File

@ -23,6 +23,7 @@ export const seedWorkspaces = async (
| 'domainName'
| 'inviteHash'
| 'logo'
| 'subdomain'
| 'activationStatus'
>;
} = {
@ -30,6 +31,7 @@ export const seedWorkspaces = async (
id: workspaceId,
displayName: 'Apple',
domainName: 'apple.dev',
subdomain: 'apple',
inviteHash: 'apple.dev-invite-hash',
logo: 'https://twentyhq.github.io/placeholder-images/workspaces/apple-logo.png',
activationStatus: WorkspaceActivationStatus.ACTIVE,
@ -38,6 +40,7 @@ export const seedWorkspaces = async (
id: workspaceId,
displayName: 'Acme',
domainName: 'acme.dev',
subdomain: 'acme',
inviteHash: 'acme.dev-invite-hash',
logo: 'https://logos-world.net/wp-content/uploads/2022/05/Acme-Logo-700x394.png',
activationStatus: WorkspaceActivationStatus.ACTIVE,
@ -51,6 +54,7 @@ export const seedWorkspaces = async (
'id',
'displayName',
'domainName',
'subdomain',
'inviteHash',
'logo',
'activationStatus',

View File

@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSubdomainToWorkspace1730137590546
implements MigrationInterface
{
name = 'AddSubdomainToWorkspace1730137590546';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "subdomain" varchar NULL`,
);
await queryRunner.query(`UPDATE "core"."workspace" SET "subdomain" = "id"`);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ALTER COLUMN "subdomain" SET NOT NULL`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX workspace_subdomain_unique_index ON "core"."workspace" (subdomain)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "subdomain"`,
);
}
}

View File

@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAuthProvidersColumnsToWorkspace1730298416367
implements MigrationInterface
{
name = 'AddAuthProvidersColumnsToWorkspace1730298416367';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "isMicrosoftAuthEnabled" BOOLEAN DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "isGoogleAuthEnabled" BOOLEAN DEFAULT true`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "isPasswordAuthEnabled" BOOLEAN DEFAULT true`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "isMicrosoftAuthEnabled"`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "isGoogleAuthEnabled"`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "isPasswordAuthEnabled"`,
);
}
}

View File

@ -19,9 +19,9 @@ import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { Query } from 'src/engine/api/rest/core/types/query.type';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Injectable()
export class CoreQueryBuilderFactory {
@ -40,7 +40,7 @@ export class CoreQueryBuilderFactory {
private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory,
private readonly objectMetadataService: ObjectMetadataService,
private readonly accessTokenService: AccessTokenService,
private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
) {}
async getObjectMetadata(
@ -50,16 +50,20 @@ export class CoreQueryBuilderFactory {
objectMetadataItems: ObjectMetadataEntity[];
objectMetadataItem: ObjectMetadataEntity;
}> {
const { workspace } = await this.accessTokenService.validateToken(request);
const { workspace } =
await this.accessTokenService.validateTokenByRequest(request);
const objectMetadataItems =
await this.objectMetadataService.findManyWithinWorkspace(workspace.id);
if (!objectMetadataItems.length) {
throw new BadRequestException(
`No object was found for the workspace associated with this API key. You may generate a new one here ${this.environmentService.get(
'FRONT_BASE_URL',
)}/settings/developers`,
`No object was found for the workspace associated with this API key. You may generate a new one here ${this.domainManagerService
.buildWorkspaceURL({
subdomain: workspace.subdomain,
pathname: '/settings/developers',
})
.toString()}`,
);
}

View File

@ -4,9 +4,10 @@ import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/
import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
@Module({
imports: [ObjectMetadataModule, AuthModule],
imports: [ObjectMetadataModule, AuthModule, DomainManagerModule],
providers: [...coreQueryBuilderFactories, CoreQueryBuilderFactory],
exports: [CoreQueryBuilderFactory],
})

View File

@ -18,7 +18,7 @@ export class RestApiMetadataService {
) {}
async get(request: Request) {
await this.accessTokenService.validateToken(request);
await this.accessTokenService.validateTokenByRequest(request);
const data = await this.metadataQueryBuilderFactory.get(request);
return await this.restApiService.call(
@ -29,7 +29,7 @@ export class RestApiMetadataService {
}
async create(request: Request) {
await this.accessTokenService.validateToken(request);
await this.accessTokenService.validateTokenByRequest(request);
const data = await this.metadataQueryBuilderFactory.create(request);
return await this.restApiService.call(
@ -40,7 +40,7 @@ export class RestApiMetadataService {
}
async update(request: Request) {
await this.accessTokenService.validateToken(request);
await this.accessTokenService.validateTokenByRequest(request);
const data = await this.metadataQueryBuilderFactory.update(request);
return await this.restApiService.call(
@ -51,7 +51,7 @@ export class RestApiMetadataService {
}
async delete(request: Request) {
await this.accessTokenService.validateToken(request);
await this.accessTokenService.validateTokenByRequest(request);
const data = await this.metadataQueryBuilderFactory.delete(request);
return await this.restApiService.call(

View File

@ -1,6 +1,6 @@
/* eslint-disable no-restricted-imports */
import { HttpModule } from '@nestjs/axios';
import { forwardRef, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
@ -11,7 +11,6 @@ import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/g
import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller';
import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller';
import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller';
import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller';
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
@ -24,7 +23,6 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
@ -36,13 +34,15 @@ import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/worksp
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { User } from 'src/engine/core-modules/user/user.entity';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
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 { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { AuthResolver } from './auth.resolver';
@ -54,7 +54,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
JwtModule,
FileUploadModule,
DataSourceModule,
forwardRef(() => UserModule),
DomainManagerModule,
TokenModule,
UserModule,
WorkspaceManagerModule,
TypeORMModule,
TypeOrmModule.forFeature(
@ -69,22 +71,20 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
'core',
),
HttpModule,
TokenModule,
UserWorkspaceModule,
WorkspaceModule,
OnboardingModule,
WorkspaceDataSourceModule,
WorkspaceInvitationModule,
ConnectedAccountModule,
WorkspaceSSOModule,
FeatureFlagModule,
WorkspaceInvitationModule,
],
controllers: [
GoogleAuthController,
MicrosoftAuthController,
GoogleAPIsAuthController,
MicrosoftAPIsAuthController,
VerifyAuthController,
SSOAuthController,
],
providers: [

View File

@ -7,6 +7,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
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';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { AuthResolver } from './auth.resolver';
@ -43,6 +44,14 @@ describe('AuthResolver', () => {
provide: UserService,
useValue: {},
},
{
provide: DomainManagerService,
useValue: {
buildWorkspaceURL: jest
.fn()
.mockResolvedValue(new URL('http://localhost:3001')),
},
},
{
provide: UserWorkspaceService,
useValue: {},

View File

@ -9,12 +9,6 @@ import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-p
import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input';
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
import { GenerateJwtInput } from 'src/engine/core-modules/auth/dto/generate-jwt.input';
import {
GenerateJWTOutput,
GenerateJWTOutputWithAuthTokens,
GenerateJWTOutputWithSSOAUTH,
} from 'src/engine/core-modules/auth/dto/generateJWT.output';
import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity';
import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.entity';
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
@ -36,12 +30,22 @@ 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 { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input';
import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { ChallengeInput } from './dto/challenge.input';
import { LoginToken } from './dto/login-token.entity';
import { SignUpInput } from './dto/sign-up.input';
import { ApiKeyToken, AuthTokens } from './dto/token.entity';
import { UserExists } from './dto/user-exists.entity';
import { UserExistsOutput } from './dto/user-exists.entity';
import { CheckUserExistsInput } from './dto/user-exists.input';
import { Verify } from './dto/verify.entity';
import { VerifyInput } from './dto/verify.input';
@ -62,18 +66,15 @@ export class AuthResolver {
private switchWorkspaceService: SwitchWorkspaceService,
private transientTokenService: TransientTokenService,
private oauthService: OAuthService,
private domainManagerService: DomainManagerService,
) {}
@UseGuards(CaptchaGuard)
@Query(() => UserExists)
@Query(() => UserExistsOutput)
async checkUserExists(
@Args() checkUserExistsInput: CheckUserExistsInput,
): Promise<UserExists> {
const { exists } = await this.authService.checkUserExists(
checkUserExistsInput.email,
);
return { exists };
): Promise<typeof UserExistsOutput> {
return await this.authService.checkUserExists(checkUserExistsInput.email);
}
@Query(() => WorkspaceInviteHashValid)
@ -96,8 +97,20 @@ export class AuthResolver {
@UseGuards(CaptchaGuard)
@Mutation(() => LoginToken)
async challenge(@Args() challengeInput: ChallengeInput): Promise<LoginToken> {
const user = await this.authService.challenge(challengeInput);
async challenge(
@Args() challengeInput: ChallengeInput,
@OriginHeader() origin: string,
): Promise<LoginToken> {
const workspace =
await this.domainManagerService.getWorkspaceByOrigin(origin);
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
const user = await this.authService.challenge(challengeInput, workspace);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
@ -107,10 +120,22 @@ export class AuthResolver {
@UseGuards(CaptchaGuard)
@Mutation(() => LoginToken)
async signUp(@Args() signUpInput: SignUpInput): Promise<LoginToken> {
async signUp(
@Args() signUpInput: SignUpInput,
@OriginHeader() origin: string,
): Promise<LoginToken> {
const user = await this.authService.signInUp({
...signUpInput,
targetWorkspaceSubdomain:
this.domainManagerService.getWorkspaceSubdomainByOrigin(origin),
fromSSO: false,
isAuthEnabled: workspaceValidator.isAuthEnabled(
'password',
new AuthException(
'Password auth is not enabled for this workspace',
AuthExceptionCode.OAUTH_ACCESS_DENIED,
),
),
});
const loginToken = await this.loginTokenService.generateLoginToken(
@ -124,11 +149,9 @@ export class AuthResolver {
async exchangeAuthorizationCode(
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
) {
const tokens = await this.oauthService.verifyAuthorizationCode(
return await this.oauthService.verifyAuthorizationCode(
exchangeAuthCodeInput,
);
return tokens;
}
@Mutation(() => TransientToken)
@ -156,14 +179,18 @@ export class AuthResolver {
}
@Mutation(() => Verify)
async verify(@Args() verifyInput: VerifyInput): Promise<Verify> {
const email = await this.loginTokenService.verifyLoginToken(
async verify(
@Args() verifyInput: VerifyInput,
@OriginHeader() origin: string,
): Promise<Verify> {
const workspace =
await this.domainManagerService.getWorkspaceByOrigin(origin);
const { sub: email } = await this.loginTokenService.verifyLoginToken(
verifyInput.loginToken,
);
const result = await this.authService.verify(email);
return result;
return await this.authService.verify(email, workspace?.id);
}
@Mutation(() => AuthorizeApp)
@ -172,50 +199,22 @@ export class AuthResolver {
@Args() authorizeAppInput: AuthorizeAppInput,
@AuthUser() user: User,
): Promise<AuthorizeApp> {
const authorizedApp = await this.authService.generateAuthorizationCode(
return await this.authService.generateAuthorizationCode(
authorizeAppInput,
user,
);
return authorizedApp;
}
@Mutation(() => GenerateJWTOutput)
@Mutation(() => PublicWorkspaceDataOutput)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
async generateJWT(
async switchWorkspace(
@AuthUser() user: User,
@Args() args: GenerateJwtInput,
): Promise<GenerateJWTOutputWithAuthTokens | GenerateJWTOutputWithSSOAUTH> {
const result = await this.switchWorkspaceService.switchWorkspace(
@Args() args: SwitchWorkspaceInput,
): Promise<PublicWorkspaceDataOutput> {
return await this.switchWorkspaceService.switchWorkspace(
user,
args.workspaceId,
);
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.switchWorkspaceService.generateSwitchWorkspaceToken(
user,
result.workspace,
),
};
}
@Mutation(() => AuthTokens)
@ -278,4 +277,11 @@ export class AuthResolver {
args.passwordResetToken,
);
}
@Query(() => [AvailableWorkspaceOutput])
async findAvailableWorkspacesByEmail(
@Args('email') email: string,
): Promise<AvailableWorkspaceOutput[]> {
return this.authService.findAvailableWorkspacesByEmail(email);
}
}

View File

@ -6,8 +6,10 @@ import {
UseFilters,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { Repository } from 'typeorm';
import {
AuthException,
@ -21,6 +23,8 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Controller('auth/google-apis')
@UseFilters(AuthRestApiExceptionFilter)
@ -30,6 +34,9 @@ export class GoogleAPIsAuthController {
private readonly transientTokenService: TransientTokenService,
private readonly environmentService: EnvironmentService,
private readonly onboardingService: OnboardingService,
private readonly domainManagerService: DomainManagerService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
@Get()
@ -96,10 +103,24 @@ export class GoogleAPIsAuthController {
});
}
const workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
return res.redirect(
`${this.environmentService.get('FRONT_BASE_URL')}${
redirectLocation || '/settings/accounts'
}`,
this.domainManagerService
.buildWorkspaceURL({
subdomain: workspace.subdomain,
pathname: redirectLocation || '/settings/accounts',
})
.toString(),
);
}
}

View File

@ -6,7 +6,9 @@ import {
UseFilters,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Response } from 'express';
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
@ -16,6 +18,14 @@ import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Controller('auth/google')
@UseFilters(AuthRestApiExceptionFilter)
@ -23,6 +33,10 @@ export class GoogleAuthController {
constructor(
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
@Get()
@ -36,29 +50,81 @@ export class GoogleAuthController {
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard)
@UseFilters(AuthOAuthExceptionFilter)
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
} = req.user;
try {
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
} = req.user;
const user = await this.authService.signInUp({
email,
firstName,
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
fromSSO: true,
});
const signInUpParams = {
email,
firstName,
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
fromSSO: true,
isAuthEnabled: workspaceValidator.isAuthEnabled(
'google',
new AuthException(
'Google auth is not enabled for this workspace',
AuthExceptionCode.OAUTH_ACCESS_DENIED,
),
),
};
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
if (
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
targetWorkspaceSubdomain ===
this.environmentService.get('DEFAULT_SUBDOMAIN')
) {
const workspaceWithGoogleAuthActive =
await this.workspaceRepository.findOne({
where: {
isGoogleAuthEnabled: true,
workspaceUsers: {
user: {
email,
},
},
},
relations: ['userWorkspaces', 'userWorkspaces.user'],
});
return res.redirect(this.authService.computeRedirectURI(loginToken.token));
if (workspaceWithGoogleAuthActive) {
signInUpParams.targetWorkspaceSubdomain =
workspaceWithGoogleAuthActive.subdomain;
}
}
const user = await this.authService.signInUp(signInUpParams);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
return res.redirect(
await this.authService.computeRedirectURI(
loginToken.token,
user.defaultWorkspace.subdomain,
),
);
} catch (err) {
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
}),
);
}
throw err;
}
}
}

View File

@ -6,8 +6,10 @@ import {
UseFilters,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { Repository } from 'typeorm';
import {
AuthException,
@ -21,6 +23,9 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic
import { MicrosoftAPIsRequest } from 'src/engine/core-modules/auth/types/microsoft-api-request.type';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Controller('auth/microsoft-apis')
@UseFilters(AuthRestApiExceptionFilter)
@ -29,7 +34,11 @@ export class MicrosoftAPIsAuthController {
private readonly microsoftAPIsService: MicrosoftAPIsService,
private readonly transientTokenService: TransientTokenService,
private readonly environmentService: EnvironmentService,
private readonly workspaceService: WorkspaceService,
private readonly domainManagerService: DomainManagerService,
private readonly onboardingService: OnboardingService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
@Get()
@ -96,10 +105,24 @@ export class MicrosoftAPIsAuthController {
});
}
const workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
return res.redirect(
`${this.environmentService.get('FRONT_BASE_URL')}${
redirectLocation || '/settings/accounts'
}`,
this.domainManagerService
.buildWorkspaceURL({
subdomain: workspace.subdomain,
pathname: redirectLocation || '/settings/accounts',
})
.toString(),
);
}
}

View File

@ -15,6 +15,13 @@ import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guar
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter)
@ -22,6 +29,8 @@ export class MicrosoftAuthController {
constructor(
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService,
) {}
@Get()
@ -37,29 +46,55 @@ export class MicrosoftAuthController {
@Req() req: MicrosoftRequest,
@Res() res: Response,
) {
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
} = req.user;
try {
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
} = req.user;
const user = await this.authService.signInUp({
email,
firstName,
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
fromSSO: true,
});
const user = await this.authService.signInUp({
email,
firstName,
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
fromSSO: true,
isAuthEnabled: workspaceValidator.isAuthEnabled(
'microsoft',
new AuthException(
'Microsoft auth is not enabled for this workspace',
AuthExceptionCode.OAUTH_ACCESS_DENIED,
),
),
});
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
return res.redirect(this.authService.computeRedirectURI(loginToken.token));
return res.redirect(
await this.authService.computeRedirectURI(
loginToken.token,
user.defaultWorkspace.subdomain,
),
);
} catch (err) {
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
}),
);
}
throw err;
}
}
}

View File

@ -25,14 +25,14 @@ import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.gua
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 { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-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';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Controller('auth')
@UseFilters(AuthRestApiExceptionFilter)
@ -40,9 +40,9 @@ export class SSOAuthController {
constructor(
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly environmentService: EnvironmentService,
private readonly ssoService: SSOService,
@InjectRepository(WorkspaceSSOIdentityProvider, 'core')
private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>,
@ -50,7 +50,7 @@ export class SSOAuthController {
@Get('saml/metadata/:identityProviderId')
@UseGuards(SSOProviderEnabledGuard)
async generateMetadata(@Req() req: any): Promise<string> {
async generateMetadata(@Req() req: any): Promise<string | void> {
return generateServiceProviderMetadata({
wantAssertionsSigned: false,
issuer: this.ssoService.buildIssuerURL({
@ -81,14 +81,26 @@ export class SSOAuthController {
@UseGuards(SSOProviderEnabledGuard, OIDCAuthGuard)
async oidcAuthCallback(@Req() req: any, @Res() res: Response) {
try {
const loginToken = await this.generateLoginToken(req.user);
const { loginToken, identityProvider } = await this.generateLoginToken(
req.user,
);
return res.redirect(
this.authService.computeRedirectURI(loginToken.token),
await this.authService.computeRedirectURI(
loginToken.token,
identityProvider.workspace.subdomain,
),
);
} catch (err) {
// TODO: improve error management
res.status(403).send(err.message);
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
}),
);
}
throw err;
}
}
@ -96,16 +108,26 @@ export class SSOAuthController {
@UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard)
async samlAuthCallback(@Req() req: any, @Res() res: Response) {
try {
const loginToken = await this.generateLoginToken(req.user);
const { loginToken, identityProvider } = await this.generateLoginToken(
req.user,
);
return res.redirect(
this.authService.computeRedirectURI(loginToken.token),
await this.authService.computeRedirectURI(
loginToken.token,
identityProvider.workspace.subdomain,
),
);
} catch (err) {
// TODO: improve error management
res
.status(403)
.redirect(`${this.environmentService.get('FRONT_BASE_URL')}/verify`);
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
}),
);
}
throw err;
}
}
@ -116,6 +138,13 @@ export class SSOAuthController {
identityProviderId?: string;
user: { email: string } & Record<string, string>;
}) {
if (!identityProviderId) {
throw new AuthException(
'Identity provider ID is required',
AuthExceptionCode.INVALID_DATA,
);
}
const identityProvider =
await this.workspaceSSOIdentityProviderRepository.findOne({
where: { id: identityProviderId },
@ -129,20 +158,15 @@ export class SSOAuthController {
);
}
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,
});
}
await this.authService.signInUp({
...user,
...(this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')
? {
targetWorkspaceSubdomain: identityProvider.workspace.subdomain,
}
: {}),
fromSSO: true,
});
const isUserExistInWorkspace =
await this.userWorkspaceService.checkUserWorkspaceExistsByEmail(
@ -157,6 +181,9 @@ export class SSOAuthController {
);
}
return this.loginTokenService.generateLoginToken(user.email);
return {
identityProvider,
loginToken: await this.loginTokenService.generateLoginToken(user.email),
};
}
}

View File

@ -1,32 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { VerifyAuthController } from './verify-auth.controller';
describe('VerifyAuthController', () => {
let controller: VerifyAuthController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [VerifyAuthController],
providers: [
{
provide: AuthService,
useValue: {},
},
{
provide: LoginTokenService,
useValue: {},
},
],
}).compile();
controller = module.get<VerifyAuthController>(VerifyAuthController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -1,26 +0,0 @@
import { Body, Controller, Post, UseFilters } from '@nestjs/common';
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
@Controller('auth/verify')
@UseFilters(AuthRestApiExceptionFilter)
export class VerifyAuthController {
constructor(
private readonly authService: AuthService,
private readonly loginTokenService: LoginTokenService,
) {}
@Post()
async verify(@Body() verifyInput: VerifyInput): Promise<Verify> {
const email = await this.loginTokenService.verifyLoginToken(
verifyInput.loginToken,
);
const result = await this.authService.verify(email);
return result;
}
}

View File

@ -0,0 +1,45 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import {
IdentityProviderType,
SSOIdentityProviderStatus,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
@ObjectType()
class SSOConnection {
@Field(() => IdentityProviderType)
type: SSOConfiguration['type'];
@Field(() => String)
id: string;
@Field(() => String)
issuer: string;
@Field(() => String)
name: string;
@Field(() => SSOIdentityProviderStatus)
status: SSOConfiguration['status'];
}
@ObjectType()
export class AvailableWorkspaceOutput {
@Field(() => String)
id: string;
@Field(() => String, { nullable: true })
displayName?: string;
@Field(() => String)
subdomain: string;
@Field(() => String, { nullable: true })
logo?: string;
@Field(() => [SSOConnection])
sso: SSOConnection[];
}

View File

@ -1,43 +0,0 @@
import { Field, ObjectType, createUnionType } from '@nestjs/graphql';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output';
@ObjectType()
export class GenerateJWTOutputWithAuthTokens {
@Field(() => Boolean)
success: boolean;
@Field(() => String)
reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH';
@Field(() => AuthTokens)
authTokens: AuthTokens;
}
@ObjectType()
export class GenerateJWTOutputWithSSOAUTH {
@Field(() => Boolean)
success: boolean;
@Field(() => String)
reason: 'WORKSPACE_USE_SSO_AUTH';
@Field(() => [FindAvailableSSOIDPOutput])
availableSSOIDPs: Array<FindAvailableSSOIDPOutput>;
}
export const GenerateJWTOutput = createUnionType({
name: 'GenerateJWT',
types: () => [GenerateJWTOutputWithAuthTokens, GenerateJWTOutputWithSSOAUTH],
resolveType(value) {
if (value.reason === 'WORKSPACE_AVAILABLE_FOR_SWITCH') {
return GenerateJWTOutputWithAuthTokens;
}
if (value.reason === 'WORKSPACE_USE_SSO_AUTH') {
return GenerateJWTOutputWithSSOAUTH;
}
return null;
},
});

View File

@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class GenerateJwtInput {
export class SwitchWorkspaceInput {
@Field(() => String)
@IsNotEmpty()
@IsString()

View File

@ -1,7 +1,30 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, ObjectType, createUnionType } from '@nestjs/graphql';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
@ObjectType()
export class UserExists {
@Field(() => Boolean)
exists: boolean;
exists: true;
@Field(() => [AvailableWorkspaceOutput])
availableWorkspaces: Array<AvailableWorkspaceOutput>;
}
@ObjectType()
export class UserNotExists {
@Field(() => Boolean)
exists: false;
}
export const UserExistsOutput = createUnionType({
name: 'UserExistsOutput',
types: () => [UserExists, UserNotExists] as const,
resolveType(value) {
if (value.exists === true) {
return UserExists;
}
return UserNotExists;
},
});

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